Compare commits

...

95 Commits

Author SHA1 Message Date
Daniel
8ce34045d6 fix: mobile adjustments 2025-11-25 22:04:04 +01:00
Daniel
bcd1f66c9c fix: force default values 2025-11-25 21:51:00 +01:00
Daniel
5a891483b6 update: add dropdowns to content 2025-11-24 17:20:28 +01:00
Daniel
0eafc9b599 update: open dialog from footer 2025-11-23 17:47:10 +01:00
Daniel
e48e539bd6 update: add a dialog for settings 2025-11-23 17:02:40 +01:00
Daniel González-Albo
9aac285964 Merge pull request #570 from ironcalc/dani/app/localstorage-warning
update: add data-storage warnings to the app
2025-11-23 13:12:35 +01:00
Daniel
ba40c3c673 update: copy 2025-11-23 13:04:32 +01:00
Daniel
cc01556387 fix: nicos suggestions 2025-11-23 13:03:47 +01:00
Daniel
35323df20e fix: copilot's suggestions 2025-11-23 13:03:47 +01:00
Daniel
19c115b32f update: allow to edit sheet anems directly from tab buttons 2025-11-23 13:03:47 +01:00
Daniel
6b60b339d6 update: show tab menu on right click 2025-11-23 13:03:47 +01:00
Nicolás Hatcher
41c8d88b80 UPDATE: Adds the rest of the DATABASE functions 2025-11-23 10:48:23 +01:00
Daniel
73e5c305cc update: add a dismissable alert to the left drawer 2025-11-21 00:26:51 +01:00
Elsa Minsut
774b447c84 update: adds xlsx test for these functions 2025-11-20 22:13:23 +01:00
Elsa Minsut
23b7333572 docs: available status for implemented functions 2025-11-20 22:13:23 +01:00
Elsa Minsut
ef47c26c50 update: adds unit test for the reciprocal trigonometric functions 2025-11-20 22:13:23 +01:00
Elsa Minsut
5cc61b0de4 update: adds unit test for EXP and SIGN 2025-11-20 22:12:29 +01:00
Elsa Minsut
42e8d44454 docs: available status for implemented functions 2025-11-20 22:12:29 +01:00
Elsa Minsut
f840806f94 docs: adds style guide page to sidebar 2025-11-20 22:11:45 +01:00
Elsa Minsut
4a21d4b03a docs: style guide clarity fix 2025-11-20 22:11:45 +01:00
Elsa Minsut
4cf162eb82 docs: documentation guide edits for clarity, readability and consistency 2025-11-20 22:11:45 +01:00
Nicolás Hatcher
2cab93be18 UPDATE: Also use erfc (thanks copilot!) 2025-11-20 22:01:00 +01:00
Nicolás Hatcher
fd34e46689 UPDATE: Uses statrs instead of our own erf
This adds 2630 bytes to the wasm build and a dependency.
It is ok-ish

The idea is that it will help us greatly with the statistical functions
2025-11-20 22:01:00 +01:00
Nicolás Hatcher
3bb49d1e8f FIX: Minor cleanups 2025-11-20 21:44:05 +01:00
Nicolás Hatcher
1391f196b5 UPDATE: Adds name validation and exposes it in wasm
We do a trick I am not proud of. Because all of our errors are Strings,
we don't have a way to separate a name error from an index error,
for instance. What I do in prepend the error with a string that indicates
where it comes from.
2025-11-20 21:44:05 +01:00
Nicolás Hatcher
3db094c956 FIX: Select range in worksheet when the name is selected if possible 2025-11-20 21:44:05 +01:00
Nicolás Hatcher
50941cb6ef FIX: Make properties not optional 2025-11-20 21:44:05 +01:00
Daniel
150b516863 update: add a warning tooltip next to the title 2025-11-20 00:55:32 +01:00
Nicolás Hatcher
dc49afa2c3 FIX: Format numbers a tad better
I still think there is some way to go, but this is closer to Excel
2025-11-19 23:53:07 +01:00
Nicolás Hatcher
acb90fbb9d FIX: Issues with trigonometric functions
* Right branch for ACOT for negative numbers
* correct error for ACOTH
* Correct approx for COTH for x > 20
2025-11-19 23:53:07 +01:00
Nicolás Hatcher
7676efca44 FIX: Issues with SIGN and EXP
Fixes #563
2025-11-19 04:24:23 +01:00
Daniel
8e15c623dd docs: add a guide for documenting functions 2025-11-17 22:45:53 +01:00
Nicolás Hatcher
eb76d8dd23 FIX: Issues with INT
Fixes #535
2025-11-16 20:34:25 +01:00
Nicolás Hatcher
1053d00d22 FIX: Copilot's suggestions 2025-11-16 19:45:18 +01:00
Nicolás Hatcher
5ff4774c5a FIX: Cast to string now checks for dates, currencies or percentages
Fixes part of #535
2025-11-16 19:45:18 +01:00
Nicolás Hatcher
7e966baa0d FIX: Copilot's catch 2025-11-16 11:29:57 +01:00
Nicolás Hatcher
c52c05aa8e FIX: Fixes several issues with DATABASE functions
Fixes #547
2025-11-16 11:29:57 +01:00
Elsa Minsut
129959137d update: adds testing for MROUND, TRUNC, and INT (#542)
* update: available status for implemented functions

* update: adds xlsx test for MROUND, TRUNC and INT

* update: adds unit test for MROUND, TRUNC and INT
2025-11-16 11:25:28 +01:00
Elsa Minsut
4d5af45711 fix: removes failing cases from xlsx test 2025-11-16 11:22:29 +01:00
Elsa Minsut
471f32f92a update: adds unit test for ARABIC and ROMAN 2025-11-16 11:22:29 +01:00
Elsa Minsut
7b5427196d update: updates docs to show ARABIC and ROMAN as implemented functions 2025-11-16 11:22:29 +01:00
Elsa Minsut
66b7586730 update: adds xlsx test for ARABIC and ROMAN 2025-11-16 11:22:29 +01:00
Nicolás Hatcher
630f0e1baf FIX: Biome automatic "unsafe" updates 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
bc9fefcb70 FIX: Biome automatic updates 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
3d970acc34 FIX: Make biome happy 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
e0e566db76 UPDATE: Update frontend dependencies 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
e3fc1d229a FIX: Disables telemetry for storybook 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
78d1f6b4a4 UPDATE: Updates vite and dependencies 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
45ee1c35fe UPDATE: Update dependencies 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
671cfff619 UPDATE: Update React and Storybook 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
7e2fcec4a3 FIX: Biome apply "unsafe" changes 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
12342da649 FIX: Delete unused button 2025-11-13 19:49:04 +01:00
Nicolás Hatcher
4e9d7611a8 FIX: Update biome and apply automatic fixes 2025-11-13 19:49:04 +01:00
Elsa Minsut
e0339f641b UPDATE: updates test for TIME, HOUR, MINUTE, SECOND (#461)
* UPDATE: updates test for TIME, HOUR, MINUTE, SECOND

* fix: updates test to remove failing edge cases

* fix: xlsx file rename for compatibility
2025-11-13 18:15:34 +01:00
Nicolás Hatcher
aa953e1ece UPDATE: Add some DATABASE functions
DAVERAGE
DCOUNT
DGET
DMAX
DMIN
DSUM
2025-11-12 23:18:47 +01:00
Daniel Gonzalez Albo
cbf75c059b fix: nicos suggestions 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
b2744efeb5 fix: copilot and nicos comments 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
ef6849e822 fix: copilot suggestions 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
aa4dd598b1 chore: remove old name manager 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
8b3bd7943e update: mobile support 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
a1d1b64b76 update: add empty space 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
5094a7fe4d update: in toolbar, open drawer instead of dialog 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
c283fd7b60 update: improve error handling 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
36beccd4ae style: adjustments in scope select 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
a252f9c626 fix: footer, header, translation file 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
f8bd03d92c update: add actions, allow drawer resize 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
e44a2e8c3e update: styling and layout 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
4217c1455b update: move all functionalities from dialog to drawer 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
d8b3ba0dae update: populate drawer, styling 2025-11-12 22:33:44 +01:00
Daniel Gonzalez Albo
95a7782f22 update: move drawer to its own component 2025-11-12 22:33:44 +01:00
Nicolás Hatcher
087211ebc3 UPDATE: WIP 2025-11-12 22:33:44 +01:00
Elsa Minsut
46d766c85c update: warning message shows function as implemented 2025-11-12 20:44:08 +01:00
Elsa Minsut
2a14ee73c4 update: replaces warning text on doc pages 2025-11-12 20:44:08 +01:00
Elsa Minsut
401c7c4289 update: sets implemented functions as available 2025-11-12 20:44:08 +01:00
Elsa Minsut
3246137545 update: adds unit test for COMBIN and COMBINA 2025-11-12 20:44:08 +01:00
Elsa Minsut
b1f45511d0 update: adds xlsx test for COMBIN and COMBINA 2025-11-12 20:44:08 +01:00
Nicolás Hatcher
4b93174261 FIX: Value of SEC at 0 was incorrect
Also fixed imported errors of trigonometrical functions

Fixes #531
2025-11-11 22:25:10 +01:00
Nicolás Hatcher
3111a74530 FIX: Propagate name correctly 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
ae3fcaf9e9 FIX: New workbooks are created in the users TZ falling back to UTC 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
dd78db3d2b FIX: NOW shows now formatted output 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
acf334074f FIX: Include misconfigured test file 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
e48810d91b FIX: Removed some console.log lines 2025-11-11 08:28:50 +01:00
Nicolás Hatcher
18db1cf052 FIX: Two small fixes to YEARFRAC
* Takes abs value in between two dates
* Follows ODFv1.2 part 2 section 4.11.7.7
2025-11-08 22:40:18 +01:00
Elsa Minsut
ed40f79324 FIX: Skip numerical failure in windows 2025-11-08 17:56:07 +01:00
Elsa Minsut
10ee95c48f FIX: Badge type 2025-11-08 17:56:07 +01:00
Elsa Minsut
741a223f3d update: Math and Trigonometry main page links to new docs 2025-11-08 17:56:07 +01:00
Elsa Minsut
ba139d1b6c update: adds MOD and QUOTIENT doc pages 2025-11-08 17:56:07 +01:00
Elsa Minsut
e0306cb161 update: adds unit test for MOD and QUOTIENT 2025-11-08 17:56:07 +01:00
Elsa Minsut
cea1f67cd0 update: adds xlsx tests for MOD and QUOTIENT 2025-11-08 17:56:07 +01:00
Nicolás Hatcher Andrés
4a3eef5a81 FIX: TRUE/FALSE for QUOTIENT (#524)
Excel returns #VALUE! when arguments are boolean

NB: MOD is different!
2025-11-08 17:25:02 +01:00
Elsa Minsut
91299e3c0b update: fixes status for implemented functions (#520) 2025-11-08 08:54:38 +01:00
Nicolás Hatcher Andrés
1b38d79b81 FIX: Make clippy happy (#521) 2025-11-08 08:53:50 +01:00
Elsa Minsut
a2d11a42cc update: adds docs, unit tests and xlsx tests for EVEN and ODD functions (#517)
* update: adds unit test for EVEN and ODD functions

* update: adds xlsx test for EVEN and ODD functions

* update: adds EVEN and ODD doc pages

* update: Math and Trigonometry main page links to new functions

* update: changes to functions badge type in main Math and Trigonometry page
2025-11-07 04:26:01 +01:00
Elsa Minsut
480a2d1769 update: adds docs, unit tests and xlsx tests for DATEVALUE and TIMEVALUE functions (#506)
* update: adds documentation for DATEVALUE and TIMEVALUE functions

* update: adds DATEVALUE and TIMEVALUE unit tests

* update: adds DATEVALUE and TIMEVALUE xlsx tests

* update: Date and Time main page links

* update: adds testing for multiple arguments

* update: removes links to example files

* update: removes DATEVALUE and TIMEVALUE xlsx tests
2025-11-06 22:56:14 +01:00
Elsa Minsut
f30f6864e2 update: adds docs and xlsx tests for DEGREES and RADIANS functions (#507)
* update: adds DEGREES and RADIANS documentation pages

* update: adds DEGREES and RADIANS xlsx tests

* update: Math and Trigonometry main page links

* update: removes links to missing example file
2025-11-06 22:55:28 +01:00
132 changed files with 6222 additions and 2791 deletions

20
Cargo.lock generated
View File

@@ -43,6 +43,15 @@ dependencies = [
"libc",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -443,6 +452,7 @@ dependencies = [
"ryu",
"serde",
"serde_json",
"statrs",
]
[[package]]
@@ -965,6 +975,16 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "statrs"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e"
dependencies = [
"approx",
"num-traits",
]
[[package]]
name = "subtle"
version = "2.5.0"

View File

@@ -19,6 +19,7 @@ regex = { version = "1.0", optional = true}
regex-lite = { version = "0.1.6", optional = true}
bitcode = "0.6.3"
csv = "1.3.0"
statrs = { version = "0.18.0", default-features = false, features = [] }
[features]
default = ["use_regex_full"]

View File

@@ -5,6 +5,7 @@ use crate::{
token::Error,
types::CellReferenceIndex,
},
formatter::format::parse_formatted_number,
model::Model,
};
@@ -14,6 +15,23 @@ pub(crate) enum NumberOrArray {
}
impl Model {
pub(crate) fn cast_number(&self, s: &str) -> Option<f64> {
match s.trim().parse::<f64>() {
Ok(f) => Some(f),
_ => {
let currency = &self.locale.currency.symbol;
let mut currencies = vec!["$", ""];
if !currencies.iter().any(|e| *e == currency) {
currencies.push(currency);
}
// Try to parse as a formatted number (e.g., dates, currencies, percentages)
if let Ok((v, _number_format)) = parse_formatted_number(s, &currencies) {
return Some(v);
}
None
}
}
}
pub(crate) fn get_number_or_array(
&mut self,
node: &Node,
@@ -21,9 +39,9 @@ impl Model {
) -> Result<NumberOrArray, CalcResult> {
match self.evaluate_node_in_context(node, cell) {
CalcResult::Number(f) => Ok(NumberOrArray::Number(f)),
CalcResult::String(s) => match s.parse::<f64>() {
Ok(f) => Ok(NumberOrArray::Number(f)),
_ => Err(CalcResult::new_error(
CalcResult::String(s) => match self.cast_number(&s) {
Some(f) => Ok(NumberOrArray::Number(f)),
None => Err(CalcResult::new_error(
Error::VALUE,
cell,
"Expecting number".to_string(),
@@ -89,16 +107,16 @@ impl Model {
self.cast_to_number(result, cell)
}
fn cast_to_number(
pub(crate) fn cast_to_number(
&mut self,
result: CalcResult,
cell: CellReferenceIndex,
) -> Result<f64, CalcResult> {
match result {
CalcResult::Number(f) => Ok(f),
CalcResult::String(s) => match s.parse::<f64>() {
Ok(f) => Ok(f),
_ => Err(CalcResult::new_error(
CalcResult::String(s) => match self.cast_number(&s) {
Some(f) => Ok(f),
None => Err(CalcResult::new_error(
Error::VALUE,
cell,
"Expecting number".to_string(),

View File

@@ -876,6 +876,19 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Sheets => args_signature_scalars(arg_count, 0, 1),
Function::Cell => args_signature_scalars(arg_count, 1, 1),
Function::Info => args_signature_scalars(arg_count, 1, 1),
Function::Daverage => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dcount => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dget => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dmax => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dmin => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dsum => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dcounta => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dproduct => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dstdev => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dvar => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dvarp => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
Function::Dstdevp => vec![Signature::Vector, Signature::Scalar, Signature::Vector],
}
}
@@ -1139,5 +1152,18 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Sheets => scalar_arguments(args),
Function::Cell => scalar_arguments(args),
Function::Info => scalar_arguments(args),
Function::Dget => not_implemented(args),
Function::Dmax => not_implemented(args),
Function::Dmin => not_implemented(args),
Function::Dcount => not_implemented(args),
Function::Daverage => not_implemented(args),
Function::Dsum => not_implemented(args),
Function::Dcounta => not_implemented(args),
Function::Dproduct => not_implemented(args),
Function::Dstdev => not_implemented(args),
Function::Dvar => not_implemented(args),
Function::Dvarp => not_implemented(args),
Function::Dstdevp => not_implemented(args),
}
}

View File

@@ -211,15 +211,19 @@ pub fn parse_reference_a1(r: &str) -> Option<ParsedReference> {
pub fn is_valid_identifier(name: &str) -> bool {
// https://support.microsoft.com/en-us/office/names-in-formulas-fc2935f9-115d-4bef-a370-3aa8bb4c91f1
// https://github.com/MartinTrummer/excel-names/
// NOTE: We are being much more restrictive than Excel.
// In particular we do not support non ascii characters.
let upper = name.to_ascii_uppercase();
let bytes = upper.as_bytes();
let len = bytes.len();
// length of chars
let len = upper.chars().count();
let mut chars = upper.chars();
if len > 255 || len == 0 {
return false;
}
let first = bytes[0] as char;
let first = match chars.next() {
Some(ch) => ch,
None => return false,
};
// The first character of a name must be a letter, an underscore character (_), or a backslash (\).
if !(first.is_ascii_alphabetic() || first == '_' || first == '\\') {
return false;
@@ -237,20 +241,10 @@ pub fn is_valid_identifier(name: &str) -> bool {
if parse_reference_r1c1(name).is_some() {
return false;
}
let mut i = 1;
while i < len {
let ch = bytes[i] as char;
match ch {
'a'..='z' => {}
'A'..='Z' => {}
'0'..='9' => {}
'_' => {}
'.' => {}
_ => {
return false;
}
for ch in chars {
if !(ch.is_alphanumeric() || ch == '_' || ch == '.') {
return false;
}
i += 1;
}
true

View File

@@ -196,6 +196,7 @@ fn test_names() {
assert!(is_valid_identifier("_."));
assert!(is_valid_identifier("_1"));
assert!(is_valid_identifier("\\."));
assert!(is_valid_identifier("truñe"));
// invalid
assert!(!is_valid_identifier("true"));
@@ -209,7 +210,6 @@ fn test_names() {
assert!(!is_valid_identifier("1true"));
assert!(!is_valid_identifier("test€"));
assert!(!is_valid_identifier("truñe"));
assert!(!is_valid_identifier("tr&ue"));
assert!(!is_valid_identifier("LOG10"));

View File

@@ -15,7 +15,7 @@ pub struct Formatted {
/// Returns the vector of chars of the fractional part of a *positive* number:
/// 3.1415926 ==> ['1', '4', '1', '5', '9', '2', '6']
fn get_fract_part(value: f64, precision: i32) -> Vec<char> {
fn get_fract_part(value: f64, precision: i32, int_len: usize) -> Vec<char> {
let b = format!("{:.1$}", value.fract(), precision as usize)
.chars()
.collect::<Vec<char>>();
@@ -30,6 +30,12 @@ fn get_fract_part(value: f64, precision: i32) -> Vec<char> {
if last_non_zero < 2 {
return vec![];
}
let max_len = if int_len > 15 {
2_usize
} else {
15_usize - int_len + 1
};
let last_non_zero = usize::min(last_non_zero, max_len + 1);
b[2..last_non_zero].to_vec()
}
@@ -423,7 +429,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
if value_abs as i64 == 0 {
int_part = vec![];
}
let fract_part = get_fract_part(value_abs, p.precision);
let fract_part = get_fract_part(value_abs, p.precision, int_part.len());
// ln is the number of digits of the integer part of the value
let ln = int_part.len() as i32;
// digit count is the number of digit tokens ('0', '?' and '#') to the left of the decimal point
@@ -744,10 +750,10 @@ fn parse_date(value: &str) -> Result<(i32, String), String> {
/// "30.34%" => (0.3034, "0.00%")
/// 100€ => (100, "100€")
pub(crate) fn parse_formatted_number(
value: &str,
original: &str,
currencies: &[&str],
) -> Result<(f64, Option<String>), String> {
let value = value.trim();
let value = original.trim();
let scientific_format = "0.00E+00";
// Check if it is a percentage
@@ -799,7 +805,8 @@ pub(crate) fn parse_formatted_number(
}
}
if let Ok((serial_number, format)) = parse_date(value) {
// check if it is a date. NOTE: we don't trim the original here
if let Ok((serial_number, format)) = parse_date(original) {
return Ok((serial_number as f64, Some(format)));
}

View File

@@ -0,0 +1,946 @@
use chrono::Datelike;
use crate::{
calc_result::CalcResult,
expressions::{parser::Node, token::Error, types::CellReferenceIndex},
formatter::dates::date_to_serial_number,
Model,
};
use super::util::{compare_values, from_wildcard_to_regex, result_matches_regex};
impl Model {
// =DAVERAGE(database, field, criteria)
pub(crate) fn fn_daverage(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut sum = 0.0f64;
let mut count = 0usize;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
sum += n;
count += 1;
}
}
}
row += 1;
}
if count == 0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "No numeric values matched criteria".to_string(),
};
}
CalcResult::Number(sum / count as f64)
}
// =DCOUNT(database, field, criteria)
// Counts numeric entries in the field for rows that match criteria
pub(crate) fn fn_dcount(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut count = 0usize;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if matches!(v, CalcResult::Number(_)) {
count += 1;
}
}
row += 1;
}
CalcResult::Number(count as f64)
}
// =DGET(database, field, criteria)
// Returns the (single) field value for the unique matching row
pub(crate) fn fn_dget(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut result: Option<CalcResult> = None;
let mut matches = 0usize;
let mut row = db_left.row + 1;
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
matches += 1;
if matches > 1 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "More than one matching record".to_string(),
};
}
result = Some(self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
}));
}
row += 1;
}
match (matches, result) {
(0, _) | (_, None) => CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No matching record".to_string(),
},
(_, Some(v)) => v,
}
}
// =DMAX(database, field, criteria)
pub(crate) fn fn_dmax(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
self.db_extreme(args, cell, true)
}
// =DMIN(database, field, criteria)
pub(crate) fn fn_dmin(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
self.db_extreme(args, cell, false)
}
// =DSUM(database, field, criteria)
pub(crate) fn fn_dsum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut sum = 0.0;
// skip header
let mut row = db_left.row + 1;
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
sum += n;
}
}
}
row += 1;
}
CalcResult::Number(sum)
}
// =DCOUNTA(database, field, criteria)
// Counts non-empty entries (any type) in the field for rows that match criteria
pub(crate) fn fn_dcounta(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut count = 0;
for row in (db_left.row + 1)..=db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if !matches!(v, CalcResult::EmptyCell | CalcResult::EmptyArg) {
count += 1;
}
}
}
CalcResult::Number(count as f64)
}
// =DPRODUCT(database, field, criteria)
pub(crate) fn fn_dproduct(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut product = 1.0f64;
let mut has_numeric = false;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
product *= n;
has_numeric = true;
}
}
}
row += 1;
}
// Excel returns 0 when no rows / no numeric values match for DPRODUCT
if has_numeric {
CalcResult::Number(product)
} else {
CalcResult::Number(0.0)
}
}
// Small internal helper for DSTDEV / DVAR
// Collects sum, sum of squares, and count of numeric values in the field
// for rows that match the criteria.
fn db_numeric_stats(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
) -> Result<(f64, f64, usize), CalcResult> {
if args.len() != 3 {
return Err(CalcResult::new_args_number_error(cell));
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return Err(e),
};
let field_col = self.resolve_db_field_column(db_left, db_right, &args[1], cell)?;
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return Err(e),
};
if db_right.row <= db_left.row {
// no data rows
return Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
});
}
let mut sum = 0.0f64;
let mut sumsq = 0.0f64;
let mut count = 0usize;
let mut row = db_left.row + 1; // skip header
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(n) = v {
if n.is_finite() {
sum += n;
sumsq += n * n;
count += 1;
}
}
}
row += 1;
}
Ok((sum, sumsq, count))
}
// =DSTDEV(database, field, criteria)
// Sample standard deviation of matching numeric values
pub(crate) fn fn_dstdev(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if 0 or 1 numeric values match
if count < 2 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Not enough numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / (n - 1.0);
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var.sqrt())
}
// =DVAR(database, field, criteria)
// Sample variance of matching numeric values
pub(crate) fn fn_dvar(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if 0 or 1 numeric values match
if count < 2 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Not enough numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / (n - 1.0);
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var)
}
// =DSTDEVP(database, field, criteria)
// Population standard deviation of matching numeric values
pub(crate) fn fn_dstdevp(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if no numeric values match
if count == 0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "No numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / n;
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var.sqrt())
}
// =DVARP(database, field, criteria)
// Population variance of matching numeric values
pub(crate) fn fn_dvarp(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
let (sum, sumsq, count) = match self.db_numeric_stats(args, cell) {
Ok(stats) => stats,
Err(e) => return e,
};
// Excel behaviour: #DIV/0! if no numeric values match
if count == 0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "No numeric values matched criteria".to_string(),
};
}
let n = count as f64;
let var = (sumsq - (sum * sum) / n) / n;
let var = if var < 0.0 { 0.0 } else { var };
CalcResult::Number(var)
}
/// Resolve the "field" (2nd arg) to an absolute column index (i32) within the sheet.
/// Field can be a number (1-based index) or a header name (case-insensitive).
/// Returns the absolute column index, not a 1-based offset within the database range.
fn resolve_db_field_column(
&mut self,
db_left: CellReferenceIndex,
db_right: CellReferenceIndex,
field_arg: &Node,
cell: CellReferenceIndex,
) -> Result<i32, CalcResult> {
let field_column_name = match self.evaluate_node_in_context(field_arg, cell) {
CalcResult::String(s) => s.to_lowercase(),
CalcResult::Number(index) => {
let index = index.floor() as i32;
if index < 1 || db_left.column + index - 1 > db_right.column {
return Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Field index out of range".to_string(),
});
}
return Ok(db_left.column + index - 1);
}
CalcResult::Boolean(b) => {
return if b {
Ok(db_left.column)
} else {
// Index 0 is out of range
Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Invalid field specifier".to_string(),
})
};
}
error @ CalcResult::Error { .. } => {
return Err(error);
}
CalcResult::Range { .. } => {
return Err(CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Arrays not supported yet".to_string(),
})
}
CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(),
CalcResult::Array(_) => {
return Err(CalcResult::Error {
error: Error::NIMPL,
origin: cell,
message: "Arrays not supported yet".to_string(),
})
}
};
// We search in the database a column whose header matches field_column_name
for column in db_left.column..=db_right.column {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row: db_left.row,
column,
});
match &v {
CalcResult::String(s) => {
if s.to_lowercase() == field_column_name {
return Ok(column);
}
}
CalcResult::Number(n) => {
if field_column_name == n.to_string() {
return Ok(column);
}
}
CalcResult::Boolean(b) => {
if field_column_name == b.to_string() {
return Ok(column);
}
}
CalcResult::Error { .. }
| CalcResult::Range { .. }
| CalcResult::EmptyCell
| CalcResult::EmptyArg
| CalcResult::Array(_) => {}
}
}
Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Field header not found".to_string(),
})
}
/// Check whether a database row matches the criteria range.
/// Criteria logic: OR across criteria rows; AND across columns within a row.
fn db_row_matches_criteria(
&mut self,
db_left: CellReferenceIndex,
db_right: CellReferenceIndex,
row: i32,
criteria: (CellReferenceIndex, CellReferenceIndex),
) -> bool {
let (c_left, c_right) = criteria;
// Read criteria headers (first row of criteria range)
// Map header name (lowercased) -> db column (if exists)
let mut crit_cols: Vec<i32> = Vec::new();
let mut header_count = 0;
// We cover the criteria table:
// headerA | headerB | ...
// critA1 | critA2 | ...
// critB1 | critB2 | ...
// ...
for column in c_left.column..=c_right.column {
let cell = CellReferenceIndex {
sheet: c_left.sheet,
row: c_left.row,
column,
};
let criteria_header = self.evaluate_cell(cell);
if let Ok(s) = self.cast_to_string(criteria_header, cell) {
// Non-empty string header. If the header is non string we skip it
header_count += 1;
let wanted = s.to_lowercase();
// Find corresponding Database column
let mut found = false;
for db_column in db_left.column..=db_right.column {
let db_header = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row: db_left.row,
column: db_column,
});
if let Ok(hs) = self.cast_to_string(db_header, cell) {
if hs.to_lowercase() == wanted {
crit_cols.push(db_column);
found = true;
break;
}
}
}
if !found {
// that means the criteria column has no matching DB column
// If the criteria condition is empty then we remove this condition
// otherwise this condition can never be satisfied
// We evaluate all criteria rows to see if any is non-empty
let mut has_non_empty = false;
for r in (c_left.row + 1)..=c_right.row {
let ccell = self.evaluate_cell(CellReferenceIndex {
sheet: c_left.sheet,
row: r,
column,
});
if !matches!(ccell, CalcResult::EmptyCell | CalcResult::EmptyArg) {
has_non_empty = true;
break;
}
}
if has_non_empty {
// This criteria column can never be satisfied
header_count -= 1;
}
}
};
}
if c_right.row <= c_left.row {
// If no criteria rows (only headers), everything matches
return true;
}
if header_count == 0 {
// If there are not "String" headers, nothing matches
// NB: There might be String headers that do not match any DB columns,
// in that case everything matches.
return false;
}
// Evaluate each criteria row (OR)
for r in (c_left.row + 1)..=c_right.row {
// AND across columns for this criteria row
let mut and_ok = true;
for (offset, db_col) in crit_cols.iter().enumerate() {
// Criteria cell
let ccell = self.evaluate_cell(CellReferenceIndex {
sheet: c_left.sheet,
row: r,
column: c_left.column + offset as i32,
});
// Empty criteria cell -> ignored
if matches!(ccell, CalcResult::EmptyCell | CalcResult::EmptyArg) {
continue;
}
// Database value for this row/column
let db_val = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: *db_col,
});
if !self.criteria_cell_matches(&db_val, &ccell) {
and_ok = false;
break;
}
}
if and_ok {
// This criteria row satisfied (OR)
return true;
}
}
// none matched
false
}
/// Implements Excel-like criteria matching for a single value.
/// Supports prefixes: <>, >=, <=, >, <, = ; wildcards * and ? for string equals.
fn criteria_cell_matches(&self, db_val: &CalcResult, crit_cell: &CalcResult) -> bool {
// Convert the criteria cell to a string for operator parsing if possible,
// otherwise fall back to equality via compare_values.
let mut criteria = match crit_cell {
CalcResult::String(s) => s.trim().to_string(),
CalcResult::Number(n) => {
// treat as equality with number
return match db_val {
CalcResult::Number(v) => (*v - *n).abs() <= f64::EPSILON,
_ => false,
};
}
CalcResult::Boolean(b) => {
// check equality with boolean
return match db_val {
CalcResult::Boolean(v) => *v == *b,
_ => false,
};
}
CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(),
CalcResult::Error { .. } => return false,
CalcResult::Range { .. } | CalcResult::Array(_) => return false,
};
// Detect operator prefix
let mut op = "="; // default equality (with wildcard semantics for strings)
let prefixes = ["<>", ">=", "<=", ">", "<", "="];
for p in prefixes.iter() {
if criteria.starts_with(p) {
op = p;
criteria = criteria[p.len()..].trim().to_string();
break;
}
}
// Is it a number?
let rhs_num = criteria.parse::<f64>().ok();
// Is it a date?
// FIXME: We should parse dates according to locale settings
let rhs_date = criteria.parse::<chrono::NaiveDate>().ok();
match op {
">" | ">=" | "<" | "<=" => {
if let Some(d) = rhs_date {
// date comparison
let serial = match date_to_serial_number(d.day(), d.month(), d.year()) {
Ok(sn) => sn as f64,
Err(_) => return false,
};
if let CalcResult::Number(n) = db_val {
match op {
">" => *n > serial,
">=" => *n >= serial,
"<" => *n < serial,
"<=" => *n <= serial,
_ => false,
}
} else {
false
}
} else if let Some(t) = rhs_num {
// numeric comparison
if let CalcResult::Number(n) = db_val {
match op {
">" => *n > t,
">=" => *n >= t,
"<" => *n < t,
"<=" => *n <= t,
_ => false,
}
} else {
false
}
} else {
// string comparison (case-insensitive) using compare_values semantics
let rhs = CalcResult::String(criteria.to_lowercase());
let lhs = match db_val {
CalcResult::String(s) => CalcResult::String(s.to_lowercase()),
x => x.clone(),
};
let c = compare_values(&lhs, &rhs);
match op {
">" => c > 0,
">=" => c >= 0,
"<" => c < 0,
"<=" => c <= 0,
_ => false,
}
}
}
"<>" => {
// not equal (with wildcard semantics for strings)
// If rhs has wildcards and db_val is string, do regex; else use compare_values != 0
if let CalcResult::String(s) = db_val {
if criteria.contains('*') || criteria.contains('?') {
if let Ok(re) = from_wildcard_to_regex(&criteria.to_lowercase(), true) {
return !result_matches_regex(
&CalcResult::String(s.to_lowercase()),
&re,
);
}
}
}
let rhs = if let Some(n) = rhs_num {
CalcResult::Number(n)
} else {
CalcResult::String(criteria.to_lowercase())
};
let lhs = match db_val {
CalcResult::String(s) => CalcResult::String(s.to_lowercase()),
x => x.clone(),
};
compare_values(&lhs, &rhs) != 0
}
_ => {
// equality. For strings, support wildcards (*, ?)
if let Some(n) = rhs_num {
// numeric equals
if let CalcResult::Number(m) = db_val {
(*m - n).abs() <= f64::EPSILON
} else {
compare_values(db_val, &CalcResult::Number(n)) == 0
}
} else {
// textual/boolean equals (case-insensitive), wildcard-enabled for strings
if let CalcResult::String(s) = db_val {
if criteria.contains('*') || criteria.contains('?') {
if let Ok(re) = from_wildcard_to_regex(&criteria.to_lowercase(), true) {
return result_matches_regex(
&CalcResult::String(s.to_lowercase()),
&re,
);
}
}
// This is weird but we only need to check if "starts with" for equality
return s.to_lowercase().starts_with(&criteria.to_lowercase());
}
// Fallback: compare_values equality
compare_values(db_val, &CalcResult::String(criteria.to_lowercase())) == 0
}
}
}
}
/// Shared implementation for DMAX/DMIN
fn db_extreme(
&mut self,
args: &[Node],
cell: CellReferenceIndex,
want_max: bool,
) -> CalcResult {
if args.len() != 3 {
return CalcResult::new_args_number_error(cell);
}
let (db_left, db_right) = match self.get_reference(&args[0], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) {
Ok(c) => c,
Err(e) => return e,
};
let criteria = match self.get_reference(&args[2], cell) {
Ok(r) => (r.left, r.right),
Err(e) => return e,
};
if db_right.row <= db_left.row {
// no data rows
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "No data rows in database".to_string(),
};
}
let mut best: Option<f64> = None;
let mut row = db_left.row + 1;
while row <= db_right.row {
if self.db_row_matches_criteria(db_left, db_right, row, criteria) {
let v = self.evaluate_cell(CellReferenceIndex {
sheet: db_left.sheet,
row,
column: field_col,
});
if let CalcResult::Number(value) = v {
if value.is_finite() {
best = Some(match best {
None => value,
Some(cur) => {
if want_max {
value.max(cur)
} else {
value.min(cur)
}
}
});
}
}
}
row += 1;
}
match best {
Some(v) => CalcResult::Number(v),
None => CalcResult::Number(0.0),
}
}
}

View File

@@ -8,6 +8,26 @@ use chrono::Timelike;
const SECONDS_PER_DAY: i32 = 86_400;
const SECONDS_PER_DAY_F64: f64 = SECONDS_PER_DAY as f64;
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
}
fn is_feb_29_between_dates(start: chrono::NaiveDate, end: chrono::NaiveDate) -> bool {
let start_year = start.year();
let end_year = end.year();
for year in start_year..=end_year {
if is_leap_year(year)
&& (year < end_year
|| (year == end_year && end.month() > 2)
&& (year > start_year || (year == start_year && start.month() <= 2)))
{
return true;
}
}
false
}
// ---------------------------------------------------------------------------
// Helper macros to eliminate boilerplate in date/time component extraction
// functions (DAY, MONTH, YEAR, HOUR, MINUTE, SECOND).
@@ -1567,18 +1587,44 @@ impl Model {
}
}
1 => {
let year_days = if start_date.year() == end_date.year() {
if (start_date.year() % 4 == 0 && start_date.year() % 100 != 0)
|| start_date.year() % 400 == 0
{
366.0
} else {
365.0
// Procedure E
let start_year = start_date.year();
let end_year = end_date.year();
let step_a = start_year != end_year;
let step_b = start_year + 1 != end_year;
let step_c = start_date.month() < end_date.month();
let step_d = start_date.month() == end_date.month();
let step_e = start_date.day() <= end_date.day();
let step_f = step_a && (step_b || step_c || (step_d && step_e));
if step_f {
// 7.
// return average of days in year between start_year and end_year, inclusive
let mut total_days = 0;
for year in start_year..=end_year {
if is_leap_year(year) {
total_days += 366;
} else {
total_days += 365;
}
}
days / (total_days as f64 / (end_year - start_year + 1) as f64)
} else if step_a && is_leap_year(start_year) {
// 8.
days / 366.0
} else if is_feb_29_between_dates(start_date, end_date) {
// 9. If a February 29 occurs between date1 and date2 then return 366
days / 366.0
} else if end_date.month() == 2 && end_date.day() == 29 {
// 10. If date2 is February 29 then return 366
days / 366.0
} else if !step_a && is_leap_year(start_year) {
days / 366.0
} else {
365.0
};
days / year_days
// 11.
days / 365.0
}
}
2 => days / 360.0,
3 => days / 365.0,
@@ -1595,6 +1641,34 @@ impl Model {
}
_ => return CalcResult::new_error(Error::NUM, cell, "Invalid basis".to_string()),
};
CalcResult::Number(result)
CalcResult::Number(result.abs())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_is_leap_year() {
assert!(is_leap_year(2000));
assert!(!is_leap_year(1900));
assert!(is_leap_year(2004));
assert!(!is_leap_year(2001));
}
#[test]
fn test_is_feb_29_between_dates() {
let d1 = chrono::NaiveDate::from_ymd_opt(2020, 2, 28).unwrap();
let d2 = chrono::NaiveDate::from_ymd_opt(2020, 3, 1).unwrap();
assert!(is_feb_29_between_dates(d1, d2));
}
#[test]
fn test_is_feb_29_between_dates_false() {
let d1 = chrono::NaiveDate::from_ymd_opt(2021, 2, 28).unwrap();
let d2 = chrono::NaiveDate::from_ymd_opt(2021, 3, 1).unwrap();
assert!(!is_feb_29_between_dates(d1, d2));
}
}

View File

@@ -1,10 +1,12 @@
use statrs::function::erf::{erf, erfc};
use crate::{
calc_result::CalcResult,
expressions::{parser::Node, token::Error, types::CellReferenceIndex},
model::Model,
};
use super::transcendental::{bessel_i, bessel_j, bessel_k, bessel_y, erf};
use super::transcendental::{bessel_i, bessel_j, bessel_k, bessel_y};
// https://root.cern/doc/v610/TMath_8cxx_source.html
// Notice that the parameters for Bessel functions in Excel and here have inverted order
@@ -160,7 +162,7 @@ impl Model {
Ok(f) => f,
Err(s) => return s,
};
CalcResult::Number(1.0 - erf(x))
CalcResult::Number(erfc(x))
}
pub(crate) fn fn_erfcprecise(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
@@ -171,6 +173,6 @@ impl Model {
Ok(f) => f,
Err(s) => return s,
};
CalcResult::Number(1.0 - erf(x))
CalcResult::Number(erfc(x))
}
}

View File

@@ -1,53 +0,0 @@
pub(crate) fn erf(x: f64) -> f64 {
let cof = vec![
-1.3026537197817094,
6.419_697_923_564_902e-1,
1.9476473204185836e-2,
-9.561_514_786_808_63e-3,
-9.46595344482036e-4,
3.66839497852761e-4,
4.2523324806907e-5,
-2.0278578112534e-5,
-1.624290004647e-6,
1.303655835580e-6,
1.5626441722e-8,
-8.5238095915e-8,
6.529054439e-9,
5.059343495e-9,
-9.91364156e-10,
-2.27365122e-10,
9.6467911e-11,
2.394038e-12,
-6.886027e-12,
8.94487e-13,
3.13092e-13,
-1.12708e-13,
3.81e-16,
7.106e-15,
-1.523e-15,
-9.4e-17,
1.21e-16,
-2.8e-17,
];
let mut d = 0.0;
let mut dd = 0.0;
let x_abs = x.abs();
let t = 2.0 / (2.0 + x_abs);
let ty = 4.0 * t - 2.0;
for j in (1..=cof.len() - 1).rev() {
let tmp = d;
d = ty * d - dd + cof[j];
dd = tmp;
}
let res = t * f64::exp(-x_abs * x_abs + 0.5 * (cof[0] + ty * d) - dd);
if x < 0.0 {
res - 1.0
} else {
1.0 - res
}
}

View File

@@ -4,7 +4,6 @@ mod bessel_j1_y1;
mod bessel_jn_yn;
mod bessel_k;
mod bessel_util;
mod erf;
#[cfg(test)]
mod test_bessel;
@@ -13,4 +12,3 @@ pub(crate) use bessel_i::bessel_i;
pub(crate) use bessel_jn_yn::jn as bessel_j;
pub(crate) use bessel_jn_yn::yn as bessel_y;
pub(crate) use bessel_k::bessel_k;
pub(crate) use erf::erf;

View File

@@ -68,14 +68,14 @@ macro_rules! single_number_fn {
},
// If String, parse to f64 then apply or #VALUE! error
ArrayNode::String(s) => {
let node = match s.parse::<f64>() {
Ok(f) => match $op(f) {
let node = match self.cast_number(&s) {
Some(f) => match $op(f) {
Ok(x) => ArrayNode::Number(x),
Err(Error::DIV) => ArrayNode::Error(Error::DIV),
Err(Error::VALUE) => ArrayNode::Error(Error::VALUE),
Err(e) => ArrayNode::Error(e),
},
Err(_) => ArrayNode::Error(Error::VALUE),
None => ArrayNode::Error(Error::VALUE),
};
data_row.push(node);
}

View File

@@ -934,11 +934,11 @@ impl Model {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let value = match self.get_number(&args[0], cell) {
let value = match self.get_number_no_bools(&args[0], cell) {
Ok(f) => f,
Err(s) => return s,
};
let divisor = match self.get_number(&args[1], cell) {
let divisor = match self.get_number_no_bools(&args[1], cell) {
Ok(f) => f,
Err(s) => return s,
};
@@ -1163,11 +1163,34 @@ impl Model {
if args.len() != 2 {
return CalcResult::new_args_number_error(cell);
}
let value = match self.get_number(&args[0], cell) {
let value = self.evaluate_node_in_context(&args[0], cell);
let multiple = self.evaluate_node_in_context(&args[1], cell);
// if either is empty => #N/A
if matches!(value, CalcResult::EmptyArg) || matches!(multiple, CalcResult::EmptyArg) {
return CalcResult::Error {
error: Error::NA,
origin: cell,
message: "Bad argument for MROUND".to_string(),
};
}
// Booleans are not cast
if matches!(value, CalcResult::Boolean(_)) {
return CalcResult::new_error(Error::VALUE, cell, "Expecting number".to_string());
}
if matches!(multiple, CalcResult::Boolean(_)) {
return CalcResult::new_error(Error::VALUE, cell, "Expecting number".to_string());
}
let value = match self.cast_to_number(value, cell) {
Ok(f) => f,
Err(s) => return s,
};
let multiple = match self.get_number(&args[1], cell) {
let multiple = match self.cast_to_number(multiple, cell) {
Ok(f) => f,
Err(s) => return s,
};
@@ -1210,11 +1233,15 @@ impl Model {
if !(-15.0..=15.0).contains(&num_digits) {
return CalcResult::Number(value);
}
CalcResult::Number(if value >= 0.0 {
let v = if value >= 0.0 {
f64::floor(value * 10f64.powf(num_digits)) / 10f64.powf(num_digits)
} else {
f64::ceil(value * 10f64.powf(num_digits)) / 10f64.powf(num_digits)
})
};
if value.is_finite() && v.is_infinite() {
return CalcResult::Number(value);
}
CalcResult::Number(v)
}
single_number_fn!(fn_log10, |f| if f <= 0.0 {
@@ -1250,13 +1277,19 @@ impl Model {
} else {
Ok((f * PI).sqrt())
});
single_number_fn!(fn_acot, |f| if f == 0.0 {
Err(Error::DIV)
} else {
Ok(f64::atan(1.0 / f))
single_number_fn!(fn_acot, |f| {
let v = f64::atan(1.0 / f);
if f >= 0.0 {
Ok(v)
} else {
// To be compatible with Excel we need a different branch
// when f < 0
Ok(v + PI)
}
});
single_number_fn!(fn_acoth, |f: f64| if f.abs() == 1.0 {
Err(Error::DIV)
Err(Error::NUM)
} else {
Ok(0.5 * (f64::ln((f + 1.0) / (f - 1.0))))
});
@@ -1265,8 +1298,11 @@ impl Model {
} else {
Ok(f64::cos(f) / f64::sin(f))
});
single_number_fn!(fn_coth, |f| if f == 0.0 {
single_number_fn!(fn_coth, |f: f64| if f == 0.0 {
Err(Error::DIV)
} else if f.abs() > 20.0 {
// for values > 20.0 this is exact in f64
Ok(f.signum())
} else {
Ok(f64::cosh(f) / f64::sinh(f))
});
@@ -1280,16 +1316,8 @@ impl Model {
} else {
Ok(1.0 / f64::sinh(f))
});
single_number_fn!(fn_sec, |f| if f == 0.0 {
Err(Error::DIV)
} else {
Ok(1.0 / f64::cos(f))
});
single_number_fn!(fn_sech, |f| if f == 0.0 {
Err(Error::DIV)
} else {
Ok(1.0 / f64::cosh(f))
});
single_number_fn!(fn_sec, |f| Ok(1.0 / f64::cos(f)));
single_number_fn!(fn_sech, |f| Ok(1.0 / f64::cosh(f)));
single_number_fn!(fn_exp, |f: f64| Ok(f64::exp(f)));
single_number_fn!(fn_fact, |x: f64| {
let x = x.floor();
@@ -1320,7 +1348,13 @@ impl Model {
}
Ok(acc)
});
single_number_fn!(fn_sign, |f| Ok(f64::signum(f)));
single_number_fn!(fn_sign, |f| {
if f == 0.0 {
Ok(0.0)
} else {
Ok(f64::signum(f))
}
});
single_number_fn!(fn_degrees, |f| Ok(f * (180.0 / PI)));
single_number_fn!(fn_radians, |f| Ok(f * (PI / 180.0)));
single_number_fn!(fn_odd, |f| {

View File

@@ -8,6 +8,7 @@ use crate::{
};
pub(crate) mod binary_search;
mod database;
mod date_and_time;
mod engineering;
mod financial;
@@ -310,10 +311,24 @@ pub enum Function {
Delta,
Gestep,
Subtotal,
// Database
Daverage,
Dcount,
Dget,
Dmax,
Dmin,
Dsum,
Dcounta,
Dproduct,
Dstdev,
Dvar,
Dvarp,
Dstdevp,
}
impl Function {
pub fn into_iter() -> IntoIter<Function, 256> {
pub fn into_iter() -> IntoIter<Function, 268> {
[
Function::And,
Function::False,
@@ -571,6 +586,18 @@ impl Function {
Function::Cell,
Function::Info,
Function::Sheets,
Function::Daverage,
Function::Dcount,
Function::Dget,
Function::Dmax,
Function::Dmin,
Function::Dsum,
Function::Dcounta,
Function::Dproduct,
Function::Dstdev,
Function::Dvar,
Function::Dvarp,
Function::Dstdevp,
]
.into_iter()
}
@@ -624,6 +651,14 @@ impl Function {
Function::Arabic => "_xlfn.ARABIC".to_string(),
Function::Combina => "_xlfn.COMBINA".to_string(),
Function::Sheets => "_xlfn.SHEETS".to_string(),
Function::Acoth => "_xlfn.ACOTH".to_string(),
Function::Cot => "_xlfn.COT".to_string(),
Function::Coth => "_xlfn.COTH".to_string(),
Function::Csc => "_xlfn.CSC".to_string(),
Function::Csch => "_xlfn.CSCH".to_string(),
Function::Sec => "_xlfn.SEC".to_string(),
Function::Sech => "_xlfn.SECH".to_string(),
Function::Acot => "_xlfn.ACOT".to_string(),
_ => self.to_string(),
}
@@ -659,14 +694,14 @@ impl Function {
"ASINH" => Some(Function::Asinh),
"ACOSH" => Some(Function::Acosh),
"ATANH" => Some(Function::Atanh),
"ACOT" => Some(Function::Acot),
"COTH" => Some(Function::Coth),
"COT" => Some(Function::Cot),
"CSC" => Some(Function::Csc),
"CSCH" => Some(Function::Csch),
"SEC" => Some(Function::Sec),
"SECH" => Some(Function::Sech),
"ACOTH" => Some(Function::Acoth),
"ACOT" | "_XLFN.ACOT" => Some(Function::Acot),
"COTH" | "_XLFN.COTH" => Some(Function::Coth),
"COT" | "_XLFN.COT" => Some(Function::Cot),
"CSC" | "_XLFN.CSC" => Some(Function::Csc),
"CSCH" | "_XLFN.CSCH" => Some(Function::Csch),
"SEC" | "_XLFN.SEC" => Some(Function::Sec),
"SECH" | "_XLFN.SECH" => Some(Function::Sech),
"ACOTH" | "_XLFN.ACOTH" => Some(Function::Acoth),
"FACT" => Some(Function::Fact),
"FACTDOUBLE" => Some(Function::Factdouble),
"EXP" => Some(Function::Exp),
@@ -909,6 +944,19 @@ impl Function {
"INFO" => Some(Function::Info),
"SHEETS" | "_XLFN.SHEETS" => Some(Function::Sheets),
"DAVERAGE" => Some(Function::Daverage),
"DCOUNT" => Some(Function::Dcount),
"DGET" => Some(Function::Dget),
"DMAX" => Some(Function::Dmax),
"DMIN" => Some(Function::Dmin),
"DSUM" => Some(Function::Dsum),
"DCOUNTA" => Some(Function::Dcounta),
"DPRODUCT" => Some(Function::Dproduct),
"DSTDEV" => Some(Function::Dstdev),
"DVAR" => Some(Function::Dvar),
"DVARP" => Some(Function::Dvarp),
"DSTDEVP" => Some(Function::Dstdevp),
_ => None,
}
}
@@ -1174,6 +1222,18 @@ impl fmt::Display for Function {
Function::Cell => write!(f, "CELL"),
Function::Info => write!(f, "INFO"),
Function::Sheets => write!(f, "SHEETS"),
Function::Daverage => write!(f, "DAVERAGE"),
Function::Dcount => write!(f, "DCOUNT"),
Function::Dget => write!(f, "DGET"),
Function::Dmax => write!(f, "DMAX"),
Function::Dmin => write!(f, "DMIN"),
Function::Dsum => write!(f, "DSUM"),
Function::Dcounta => write!(f, "DCOUNTA"),
Function::Dproduct => write!(f, "DPRODUCT"),
Function::Dstdev => write!(f, "DSTDEV"),
Function::Dvar => write!(f, "DVAR"),
Function::Dvarp => write!(f, "DVARP"),
Function::Dstdevp => write!(f, "DSTDEVP"),
}
}
}
@@ -1458,6 +1518,18 @@ impl Model {
Function::Cell => self.fn_cell(args, cell),
Function::Info => self.fn_info(args, cell),
Function::Sheets => self.fn_sheets(args, cell),
Function::Daverage => self.fn_daverage(args, cell),
Function::Dcount => self.fn_dcount(args, cell),
Function::Dget => self.fn_dget(args, cell),
Function::Dmax => self.fn_dmax(args, cell),
Function::Dmin => self.fn_dmin(args, cell),
Function::Dsum => self.fn_dsum(args, cell),
Function::Dcounta => self.fn_dcounta(args, cell),
Function::Dproduct => self.fn_dproduct(args, cell),
Function::Dstdev => self.fn_dstdev(args, cell),
Function::Dvar => self.fn_dvar(args, cell),
Function::Dvarp => self.fn_dvarp(args, cell),
Function::Dstdevp => self.fn_dstdevp(args, cell),
}
}
}

View File

@@ -1,7 +1,10 @@
#[cfg(feature = "use_regex_lite")]
use regex_lite as regex;
use crate::{calc_result::CalcResult, expressions::token::is_english_error_string};
use crate::{
calc_result::CalcResult, expressions::token::is_english_error_string,
number_format::to_excel_precision,
};
/// This test for exact match (modulo case).
/// * strings are not cast into bools or numbers
@@ -34,6 +37,8 @@ pub(crate) fn values_are_equal(left: &CalcResult, right: &CalcResult) -> bool {
pub(crate) fn compare_values(left: &CalcResult, right: &CalcResult) -> i32 {
match (left, right) {
(CalcResult::Number(value1), CalcResult::Number(value2)) => {
let value1 = to_excel_precision(*value1, 15);
let value2 = to_excel_precision(*value2, 15);
if (value2 - value1).abs() < f64::EPSILON {
return 0;
}

View File

@@ -2068,21 +2068,7 @@ impl Model {
scope: Option<u32>,
formula: &str,
) -> Result<(), String> {
if !is_valid_identifier(name) {
return Err("Invalid defined name".to_string());
};
let name_upper = name.to_uppercase();
let defined_names = &self.workbook.defined_names;
let sheet_id = match scope {
Some(index) => Some(self.workbook.worksheet(index)?.sheet_id),
None => None,
};
// if the defined name already exist return error
for df in defined_names {
if df.name.to_uppercase() == name_upper && df.sheet_id == sheet_id {
return Err("Defined name already exists".to_string());
}
}
let sheet_id = self.is_valid_defined_name(name, scope, formula)?;
self.workbook.defined_names.push(DefinedName {
name: name.to_string(),
formula: formula.to_string(),
@@ -2093,6 +2079,48 @@ impl Model {
Ok(())
}
/// Validates if a defined name can be created
pub fn is_valid_defined_name(
&self,
name: &str,
scope: Option<u32>,
formula: &str,
) -> Result<Option<u32>, String> {
if !is_valid_identifier(name) {
return Err("Name: Invalid defined name".to_string());
}
let name_upper = name.to_uppercase();
let defined_names = &self.workbook.defined_names;
let sheet_id = match scope {
Some(index) => match self.workbook.worksheet(index) {
Ok(ws) => Some(ws.sheet_id),
Err(_) => return Err("Scope: Invalid sheet index".to_string()),
},
None => None,
};
// if the defined name already exist return error
for df in defined_names {
if df.name.to_uppercase() == name_upper && df.sheet_id == sheet_id {
return Err("Name: Defined name already exists".to_string());
}
}
// Make sure the formula is valid
match common::ParsedReference::parse_reference_formula(
None,
formula,
&self.locale,
|name| self.get_sheet_index_by_name(name),
) {
Ok(_) => {}
Err(_) => {
return Err("Formula: Invalid defined name formula".to_string());
}
};
Ok(sheet_id)
}
/// Delete defined name of name and scope
pub fn delete_defined_name(&mut self, name: &str, scope: Option<u32>) -> Result<(), String> {
let name_upper = name.to_uppercase();
@@ -2126,7 +2154,7 @@ impl Model {
new_formula: &str,
) -> Result<(), String> {
if !is_valid_identifier(new_name) {
return Err("Invalid defined name".to_string());
return Err("Name: Invalid defined name".to_string());
};
let name_upper = name.to_uppercase();
let new_name_upper = new_name.to_uppercase();
@@ -2134,18 +2162,28 @@ impl Model {
if name_upper != new_name_upper || scope != new_scope {
for key in self.parsed_defined_names.keys() {
if key.1.to_uppercase() == new_name_upper && key.0 == new_scope {
return Err("Defined name already exists".to_string());
return Err("Name: Defined name already exists".to_string());
}
}
}
let defined_names = &self.workbook.defined_names;
let sheet_id = match scope {
Some(index) => Some(self.workbook.worksheet(index)?.sheet_id),
Some(index) => Some(
self.workbook
.worksheet(index)
.map_err(|_| "Scope: Invalid sheet index")?
.sheet_id,
),
None => None,
};
let new_sheet_id = match new_scope {
Some(index) => Some(self.workbook.worksheet(index)?.sheet_id),
Some(index) => Some(
self.workbook
.worksheet(index)
.map_err(|_| "Scope: Invalid sheet index")?
.sheet_id,
),
None => None,
};

View File

@@ -112,29 +112,36 @@ pub fn to_precision(value: f64, precision: usize) -> f64 {
/// ```
/// This intends to be equivalent to the js: `${parseFloat(value.toPrecision(precision)})`
/// See ([ecma](https://tc39.es/ecma262/#sec-number.prototype.toprecision)).
/// FIXME: There has to be a better algorithm :/
pub fn to_excel_precision_str(value: f64) -> String {
to_precision_str(value, 15)
}
pub fn to_excel_precision(value: f64, precision: usize) -> f64 {
if !value.is_finite() {
return value;
}
let s = format!("{:.*e}", precision.saturating_sub(1), value);
s.parse::<f64>().unwrap_or(value)
}
pub fn to_precision_str(value: f64, precision: usize) -> String {
if value.is_infinite() {
return "inf".to_string();
if !value.is_finite() {
if value.is_infinite() {
return "inf".to_string();
} else {
return "NaN".to_string();
}
}
if value.is_nan() {
return "NaN".to_string();
}
let exponent = value.abs().log10().floor();
let base = value / 10.0_f64.powf(exponent);
let base = format!("{0:.1$}", base, precision - 1);
let value = format!("{base}e{exponent}").parse::<f64>().unwrap_or({
// TODO: do this in a way that does not require a possible error
0.0
});
let s = format!("{:.*e}", precision.saturating_sub(1), value);
let parsed = s.parse::<f64>().unwrap_or(value);
// I would love to use the std library. There is not a speed concern here
// problem is it doesn't do the right thing
// Also ryu is my favorite _modern_ algorithm
let mut buffer = ryu::Buffer::new();
let text = buffer.format(value);
let text = buffer.format(parsed);
// The above algorithm converts 2 to 2.0 regrettably
if let Some(stripped) = text.strip_suffix(".0") {
return stripped.to_string();

View File

@@ -133,6 +133,7 @@ fn fn_imcot() {
);
}
#[cfg_attr(target_os = "windows", ignore)]
#[test]
fn fn_imtan() {
let mut model = new_empty_model();

View File

@@ -73,7 +73,9 @@ mod test_issue_483;
mod test_ln;
mod test_log;
mod test_log10;
mod test_mod_quotient;
mod test_networkdays;
mod test_now;
mod test_percentage;
mod test_set_functions_error_handling;
mod test_sheet_names;

View File

@@ -0,0 +1,27 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=ARABIC()");
model._set("A2", "=ARABIC(V)");
model._set("A3", "=ARABIC(V, 2)");
model._set("A4", "=ROMAN()");
model._set("A5", "=ROMAN(5)");
model._set("A6", "=ROMAN(5, 0)");
model._set("A7", "=ROMAN(5, 0, 2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"5");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"V");
assert_eq!(model._get_text("A6"), *"V");
assert_eq!(model._get_text("A7"), *"#ERROR!");
}

View File

@@ -0,0 +1,27 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=COMBIN(5,2)");
model._set("A2", "=COMBINA(5,2)");
model._set("A3", "=COMBIN()");
model._set("A4", "=COMBINA()");
model._set("A5", "=COMBIN(2)");
model._set("A6", "=COMBINA(2)");
model._set("A5", "=COMBIN(1, 2, 3)");
model._set("A6", "=COMBINA(1, 2, 3)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"10");
assert_eq!(model._get_text("A2"), *"15");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#ERROR!");
}

View File

@@ -542,7 +542,6 @@ fn test_yearfrac_function() {
// Edge cases
model._set("A4", "=YEARFRAC(44561,44561,1)"); // Same date = 0
model._set("A5", "=YEARFRAC(44926,44561,1)"); // Reverse = negative
model._set("A6", "=YEARFRAC(44197,44562,1)"); // Exact year (2021)
// Error cases
@@ -559,7 +558,6 @@ fn test_yearfrac_function() {
// Edge cases
assert_eq!(model._get_text("A4"), *"0"); // Same date
assert_eq!(model._get_text("A5"), *"-1"); // Negative
assert_eq!(model._get_text("A6"), *"1"); // Exact year
// Error cases

View File

@@ -0,0 +1,23 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=EVEN(2)");
model._set("A2", "=ODD(2)");
model._set("A3", "=EVEN()");
model._set("A4", "=ODD()");
model._set("A5", "=EVEN(1, 2)");
model._set("A6", "=ODD(1, 2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"2");
assert_eq!(model._get_text("A2"), *"3");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
}

View File

@@ -0,0 +1,26 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn fn_arguments() {
let mut model = new_empty_model();
model._set("A1", "=EXP()");
model._set("A2", "=SIGN()");
model._set("A3", "=EXP(0)");
model._set("A4", "=SIGN(-10)");
model._set("A5", "=EXP(1, 2)");
model._set("A6", "=SIGN(1, 2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"1");
assert_eq!(model._get_text("A4"), *"-1");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
}

View File

@@ -0,0 +1,24 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn datevalue_timevalue_arguments() {
let mut model = new_empty_model();
model._set("A1", "=DATEVALUE()");
model._set("A2", "=TIMEVALUE()");
model._set("A3", "=DATEVALUE("2000-01-01")")
model._set("A4", "=TIMEVALUE("12:00:00")")
model._set("A5", "=DATEVALUE(1,2)");
model._set("A6", "=TIMEVALUE(1,2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"36526");
assert_eq!(model._get_text("A4"), *"0.5");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
}

View File

@@ -0,0 +1,22 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=MOD(5,2)");
model._set("A2", "=MOD()");
model._set("A3", "=MOD(5, 2, 1)");
model._set("A4", "=QUOTIENT(5, 2)");
model._set("A5", "=QUOTIENT()");
model._set("A6", "=QUOTIENT(5, 2, 1)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"1");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"2");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
}

View File

@@ -0,0 +1,40 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=MROUND()");
model._set("A2", "=MROUND(10)");
model._set("A3", "=MROUND(10, 3)");
model._set("A4", "=MROUND(10, 3, 1)");
model._set("A5", "=TRUNC()");
model._set("A6", "=TRUNC(10)");
model._set("A7", "=TRUNC(10.22, 1)");
model._set("A8", "=TRUNC(10, 3, 1)");
model._set("A9", "=INT()");
model._set("A10", "=INT(10.22)");
model._set("A11", "=INT(10.22, 1)");
model._set("A12", "=INT(10.22, 1, 2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"9");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"10.2");
assert_eq!(model._get_text("A8"), *"#ERROR!");
assert_eq!(model._get_text("A9"), *"#ERROR!");
assert_eq!(model._get_text("A10"), *"10");
assert_eq!(model._get_text("A11"), *"#ERROR!");
assert_eq!(model._get_text("A12"), *"#ERROR!");
}

30
base/src/test/test_now.rs Normal file
View File

@@ -0,0 +1,30 @@
#![allow(clippy::unwrap_used)]
use crate::{mock_time, test::util::new_empty_model};
// 14:44 20 Mar 2023 Berlin
const TIMESTAMP_2023: i64 = 1679319865208;
#[test]
fn arguments() {
let mut model = new_empty_model();
model._set("A1", "=NOW(1)");
model.evaluate();
assert_eq!(
model._get_text("A1"),
"#ERROR!",
"NOW should not accept arguments"
);
}
#[test]
fn returns_date_time() {
mock_time::set_mock_time(TIMESTAMP_2023);
let mut model = new_empty_model();
model._set("A1", "=NOW()");
model.evaluate();
let text = model._get_text("A1");
assert_eq!(text, *"20/03/2023 13:44:25");
}

View File

@@ -8,6 +8,15 @@ fn test_simple_format() {
assert_eq!(formatted.text, "2.3".to_string());
}
#[test]
fn test_maximum_zeros() {
let formatted = format_number(1.0 / 3.0, "#,##0.0000000000000000000", "en");
assert_eq!(formatted.text, "0.3333333333333330000".to_string());
let formatted = format_number(1234.0 + 1.0 / 3.0, "#,##0.0000000000000000000", "en");
assert_eq!(formatted.text, "1,234.3333333333300000000".to_string());
}
#[test]
#[ignore = "not yet implemented"]
fn test_wrong_locale() {

View File

@@ -33,7 +33,8 @@ fn now_basic_utc() {
model.evaluate();
assert_eq!(model._get_text("A1"), *"20/03/2023");
assert_eq!(model._get_text("A2"), *"45005.572511574");
// 45005.572511574
assert_eq!(model._get_text("A2"), *"20/03/2023 13:44:25");
}
#[test]
@@ -46,5 +47,5 @@ fn now_basic_europe_berlin() {
assert_eq!(model._get_text("A1"), *"20/03/2023");
// This is UTC + 1 hour: 45005.572511574 + 1/24
assert_eq!(model._get_text("A2"), *"45005.614178241");
assert_eq!(model._get_text("A2"), *"20/03/2023 14:44:25");
}

View File

@@ -96,3 +96,14 @@ fn test_fn_tan_pi2() {
// This is consistent with IEEE 754 but inconsistent with Excel
assert_eq!(model._get_text("A1"), *"1.63312E+16");
}
#[test]
fn test_trigonometric_identity() {
let mut model = new_empty_model();
model._set("A1", "=COTH(1)*CSCH(1)");
model._set("A2", "=COSH(1)/(SINH(1))^2");
model._set("A3", "=A1=A2");
model.evaluate();
assert_eq!(model._get_text("A3"), *"TRUE");
}

View File

@@ -0,0 +1,53 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn fn_arguments() {
let mut model = new_empty_model();
model._set("A1", "=CSC()");
model._set("A2", "=SEC()");
model._set("A3", "=COT()");
model._set("A4", "=CSCH()");
model._set("A5", "=SECH()");
model._set("A6", "=COTH()");
model._set("A7", "=ACOT()");
model._set("A8", "=ACOTH()");
model._set("B1", "=CSC(1, 2)");
model._set("B2", "=SEC(1, 2)");
model._set("B3", "=COT(1, 2)");
model._set("B4", "=CSCH(1, 2)");
model._set("B5", "=SECH(1, 2)");
model._set("B6", "=COTH(1, 2)");
model._set("B7", "=ACOT(1, 2)");
model._set("B8", "=ACOTH(1, 2)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#ERROR!");
assert_eq!(model._get_text("B1"), *"#ERROR!");
assert_eq!(model._get_text("B2"), *"#ERROR!");
assert_eq!(model._get_text("B3"), *"#ERROR!");
assert_eq!(model._get_text("B4"), *"#ERROR!");
assert_eq!(model._get_text("B5"), *"#ERROR!");
assert_eq!(model._get_text("B6"), *"#ERROR!");
assert_eq!(model._get_text("B7"), *"#ERROR!");
assert_eq!(model._get_text("B8"), *"#ERROR!");
}

View File

@@ -26,8 +26,8 @@ fn test_yearfrac_basis_2_actual_360() {
panic!("Expected numeric value in A2");
}
// Negative symmetric of A1
assert_eq!(model._get_text("A3"), *"-1");
// always positive A1
assert_eq!(model._get_text("A3"), *"1");
}
#[test]

View File

@@ -254,19 +254,19 @@ fn invalid_names() {
// spaces
assert_eq!(
model.new_defined_name("A real", None, "Sheet1!$A$1"),
Err("Invalid defined name".to_string())
Err("Name: Invalid defined name".to_string())
);
// Starts with number
assert_eq!(
model.new_defined_name("2real", None, "Sheet1!$A$1"),
Err("Invalid defined name".to_string())
Err("Name: Invalid defined name".to_string())
);
// Updating also fails
assert_eq!(
model.update_defined_name("MyName", None, "My Name", None, "Sheet1!$A$1"),
Err("Invalid defined name".to_string())
Err("Name: Invalid defined name".to_string())
);
}
@@ -284,13 +284,13 @@ fn already_existing() {
// Can't create a new name with the same name
assert_eq!(
model.new_defined_name("MyName", None, "Sheet1!$A$2"),
Err("Defined name already exists".to_string())
Err("Name: Defined name already exists".to_string())
);
// Can't update one into an existing
assert_eq!(
model.update_defined_name("Another", None, "MyName", None, "Sheet1!$A$1"),
Err("Defined name already exists".to_string())
Err("Name: Defined name already exists".to_string())
);
}
@@ -304,17 +304,17 @@ fn invalid_sheet() {
assert_eq!(
model.new_defined_name("Mything", Some(2), "Sheet1!$A$1"),
Err("Invalid sheet index".to_string())
Err("Scope: Invalid sheet index".to_string())
);
assert_eq!(
model.update_defined_name("MyName", None, "MyName", Some(2), "Sheet1!$A$1"),
Err("Invalid sheet index".to_string())
Err("Scope: Invalid sheet index".to_string())
);
assert_eq!(
model.update_defined_name("MyName", Some(9), "YourName", None, "Sheet1!$A$1"),
Err("Invalid sheet index".to_string())
Err("General: Failed to get old name".to_string())
);
}
@@ -322,7 +322,7 @@ fn invalid_sheet() {
fn invalid_formula() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "Hello").unwrap();
model.new_defined_name("MyName", None, "A1").unwrap();
assert!(model.new_defined_name("MyName", None, "A1").is_err());
model.set_user_input(0, 1, 2, "=MyName").unwrap();

View File

@@ -445,11 +445,13 @@ impl Default for Fill {
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum HorizontalAlignment {
Center,
CenterContinuous,
Distributed,
Fill,
#[default]
General,
Justify,
Left,
@@ -457,11 +459,6 @@ pub enum HorizontalAlignment {
}
// Note that alignment in "General" depends on type
impl Default for HorizontalAlignment {
fn default() -> Self {
Self::General
}
}
impl HorizontalAlignment {
fn is_default(&self) -> bool {
@@ -487,7 +484,9 @@ impl Display for HorizontalAlignment {
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum VerticalAlignment {
#[default]
Bottom,
Center,
Distributed,
@@ -501,12 +500,6 @@ impl VerticalAlignment {
}
}
impl Default for VerticalAlignment {
fn default() -> Self {
Self::Bottom
}
}
impl Display for VerticalAlignment {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {

View File

@@ -329,6 +329,7 @@ impl Model {
Function::Tbillyield => self.units_fn_percentage_2(args, cell),
Function::Date => self.units_fn_dates(args, cell),
Function::Today => self.units_fn_dates(args, cell),
Function::Now => self.units_fn_date_times(args, cell),
_ => None,
}
}
@@ -375,4 +376,8 @@ impl Model {
// TODO: update locale and use it here
Some(Units::Date("dd/mm/yyyy".to_string()))
}
fn units_fn_date_times(&self, _args: &[Node], _cell: &CellReferenceIndex) -> Option<Units> {
Some(Units::Date("dd/mm/yyyy hh:mm:ss".to_string()))
}
}

View File

@@ -2001,7 +2001,10 @@ impl UserModel {
new_scope: Option<u32>,
new_formula: &str,
) -> Result<(), String> {
let old_formula = self.model.get_defined_name_formula(name, scope)?;
let old_formula = self
.model
.get_defined_name_formula(name, scope)
.map_err(|_| "General: Failed to get old name")?;
let diff_list = vec![Diff::UpdateDefinedName {
name: name.to_string(),
scope,
@@ -2017,6 +2020,16 @@ impl UserModel {
Ok(())
}
/// validates a new defined name
pub fn is_valid_defined_name(
&self,
name: &str,
scope: Option<u32>,
formula: &str,
) -> Result<Option<u32>, String> {
self.model.is_valid_defined_name(name, scope, formula)
}
// **** Private methods ****** //
pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) {

View File

@@ -775,4 +775,17 @@ impl Model {
.get_first_non_empty_in_row_after_column(sheet, row, column)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "isValidDefinedName")]
pub fn is_valid_defined_name(
&self,
name: &str,
scope: Option<u32>,
formula: &str,
) -> Result<(), JsError> {
match self.model.is_valid_defined_name(name, scope, formula) {
Ok(_) => Ok(()),
Err(e) => Err(to_js_error(e.to_string())),
}
}
}

View File

@@ -2036,6 +2036,10 @@ export default defineConfig({
text: "How to contribute",
link: "/contributing/how-to-contribute",
},
{
text: "Function documentation guide",
link: "/contributing/function-documentation-guide",
},
],
},
],

View File

@@ -0,0 +1,389 @@
---
layout: doc
outline: deep
lang: en-US
---
# Function Documentation Guide
This guide explains how to document IronCalc functions following our established format and style conventions.
## File Structure
Function documentation files should be placed in the appropriate category directory under `src/functions/`. For example:
- Financial functions: `src/functions/financial/function-name.md`
- Text functions: `src/functions/text/function-name.md`
- Logical functions: `src/functions/logical/function-name.md`
## Required Frontmatter
Every function documentation file must start with this frontmatter:
```yaml
---
layout: doc
outline: deep
lang: en-US
---
```
## Document Structure
A complete function documentation should include the following sections in order:
### 1. Title
The title should be the function name followed by the word "function":
```markdown
# FV function
```
The function name should be written in uppercase when mentioned in the documentation.
### 2. Draft Warning (Optional)
If the function hasn't been implemented, include this warning box:
```markdown
::: warning
**Note:** This draft page is under construction 🚧
:::
```
If the function has been implemented but not documented, include this warning box:
```markdown
::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::
```
### 3. Overview
Provide a brief, clear description of what the function does. If the function name is an acronym, expand it using underlined text:
```markdown
## Overview
FV (<u>F</u>uture <u>V</u>alue) is a function of the Financial category that can be used to predict the future value of an investment or asset based on its present value.
```
Include:
- Category (Financial, Text, Logical, etc.)
- Primary purpose
- Key use cases (if helpful)
### 4. Usage
This section contains multiple subsections:
#### 4.1 Syntax
Format the function syntax with color-coded argument types. Use the following color scheme:
- **Numbers**: `#2F80ED` (blue)
- **Booleans**: `#27AE60` (green)
- **Text/Strings**: `#2F80ED` (orange)
- **Arrays/Ranges**: `#EB5757` (red)
**Format:**
```markdown
### Syntax
**FUNCTION_NAME(<span title="Type" style="color:#HEXCODE">arg1</span>, <span title="Type" style="color:#HEXCODE">arg2</span>=default, ...) => <span title="ReturnType" style="color:#HEXCODE">return_value</span>**
```
**Example:**
**FV(<span title="Number" style="color:#2F80ED">rate</span>, <span title="Number" style="color:#2F80ED">nper</span>, <span title="Number" style="color:#2F80ED">pmt</span>, [<span title="Number" style="color:#2F80ED">pv</span>], [<span title="Boolean" style="color:#27AE60">type</span>] => <span title="Number" style="color:#2F80ED">fv</span>**
```markdown
### Syntax
**FV(<span title="Number" style="color:#2F80ED">rate</span>, <span title="Number" style="color:#2F80ED">nper</span>, <span title="Number" style="color:#2F80ED">pmt</span>, <span title="Number" style="color:#2F80ED">pv</span>=0, <span title="Boolean" style="color:#27AE60">type</span>=FALSE) => <span title="Number" style="color:#2F80ED">fv</span>**
```
**Guidelines:**
- Use `title` attribute to specify the data type
- Use `style="color:#HEXCODE"` for syntax highlighting
- Use square brackets for optional arguments
- Show the return type after `=>`
- Make the entire syntax **bold**
#### 4.2 Argument Descriptions
List each argument with:
- Argument name in _italics_
- Data type link (e.g., `[number](/features/value-types#numbers)`)
- Required or optional indicator
- Description
**Format:**
```markdown
### Argument descriptions
- _argname_ ([datatype](/features/value-types#datatype), [required|optional](/features/optional-arguments.md)). Description of the argument.
```
**Example:**
```markdown
### Argument descriptions
- _rate_ ([number](/features/value-types#numbers), required). The fixed percentage interest rate or yield per period.
- _pv_ ([number](/features/value-types#numbers), [optional](/features/optional-arguments.md)). "pv" is the <u>p</u>resent <u>v</u>alue or starting amount of the asset (default 0).
- _type_ ([Boolean](/features/value-types#booleans), [optional](/features/optional-arguments.md)). A logical value indicating whether the payment due dates are at the end (FALSE or 0) of the compounding periods or at the beginning (TRUE or any non-zero value). The default is FALSE when omitted.
```
**Guidelines:**
- Use bullet points (`*`)
- Italicize argument names with `*argname*`
- Link to value types documentation
- Link to optional arguments page when applicable
- Expand acronyms in descriptions using `<u>` tags if helpful
- Mention default values for optional arguments
#### 4.3 Additional Guidance
Provide tips, best practices and important notes about using the function:
```markdown
### Additional guidance
- Make sure that the _rate_ argument specifies the interest rate or yield applicable to the compounding period.
- The _pmt_ and _pv_ arguments should be expressed in the same currency unit.
- To ensure a worthwhile result, one of the _pmt_ and _pv_ arguments should be non-zero.
```
#### 4.4 Returned Value
Describe what the function returns:
```markdown
### Returned value
FV returns a [number](/features/value-types#numbers) representing the future value expressed in the same [currency unit](/features/units) that was used for the _pmt_ and _pv_ arguments.
```
Include:
- Return type (with link to value types if applicable)
- Units or format if relevant
- Any important characteristics
#### 4.5 Error Conditions
List all error scenarios the function may encounter:
```markdown
### Error conditions
- In common with many other IronCalc functions, FV propagates errors that are found in any of its arguments.
- If too few or too many arguments are supplied, FV returns the [`#ERROR!`](/features/error-types.md#error) error.
- If the value of any of the _rate_, _nper_, _pmt_ or _pv_ arguments is not (or cannot be converted to) a [number](/features/value-types#numbers), then FV returns the [`#VALUE!`](/features/error-types.md#value) error.
- If the value of the _type_ argument is not (or cannot be converted to) a [Boolean](/features/value-types#booleans), then FV again returns the [`#VALUE!`](/features/error-types.md#value) error.
- For some combinations of valid argument values, FV may return a [`#NUM!`](/features/error-types.md#num) error or a [`#DIV/0!`](/features/error-types.md#div-0) error.
```
**Guidelines:**
- Use bullet points
- Format error types using backticks and link to the error types page: `` [`#ERROR!`](/features/error-types.md#error) ``
- Reference argument names in italics when discussing specific arguments
- Add the include directive at the end if using the error details snippet:
```markdown
<!--@include: ../markdown-snippets/error-type-details.txt-->
```
### 5. Details (Optional but Recommended)
For functions with mathematical formulas or complex behavior, include a Details section. This section can also include plots, graphs or charts to help clarify the function's behavior.
```markdown
## Details
- If $\text{type} \neq 0$, $\text{fv}$ is given by the equation:
$$ \text{fv} = -\text{pv} \times (1 + \text{rate})^\text{nper} - \dfrac{\text{pmt}\times\big({(1+\text{rate})^\text{nper}-1}\big) \times(1+\text{rate})}{\text{rate}}$$
- If $\text{type} = 0$, $\text{fv}$ is given by the equation:
$$ \text{fv} = -\text{pv} \times (1 + \text{rate})^{\text{nper}} - \dfrac{\text{pmt}\times\big({(1+\text{rate})^\text{nper}-1}\big)}{\text{rate}}$$
```
**Guidelines:**
- Use LaTeX math notation with `$` for inline and `$$` for block equations
- Use `\text{}` for variable names in equations
- Explain special cases or edge conditions
### 6. Examples
Link to interactive examples in IronCalc:
```markdown
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=functionname).
```
Replace `functionname` with the actual function name (lowercase).
### 7. Links
Provide external references and related functions:
```markdown
## Links
- For more information about the concept of "future value" in finance, visit Wikipedia's [Future value](https://en.wikipedia.org/wiki/Future_value) page.
- See also IronCalc's [NPER](/functions/financial/nper), [PMT](/functions/financial/pmt), [PV](/functions/financial/pv) and [RATE](/functions/financial/rate) functions.
- Visit Microsoft Excel's [FV function](https://support.microsoft.com/en-gb/office/fv-function-2eef9f44-a084-4c61-bdd8-4fe4bb1b71b3) page.
- Both [Google Sheets](https://support.google.com/docs/answer/3093224) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/FV) provide versions of the FV function.
```
**Guidelines:**
- Include Wikipedia links for concepts when available
- Link to related IronCalc functions in the same category
- Include links to equivalent functions in Excel, Google Sheets, and LibreOffice Calc
- Use bullet points
## Syntax Coloring Reference
### Color Codes
| Data Type | Hex Color | Usage |
| ----------- | --------- | --------------------------------------- |
| Number | `#2F80ED` | All numeric arguments and return values |
| Boolean | `#27AE60` | TRUE/FALSE arguments |
| Text/String | `#F2994A` | Text arguments |
| Array/Range | `#EB5757` | Array or range arguments |
### Syntax Highlighting Template
```html
<span title="Type" style="color:#HEXCODE">argument_name</span>
```
**Examples:**
- Number: `<span title="Number" style="color:#2F80ED">rate</span>`
- Boolean: `<span title="Boolean" style="color:#27AE60">type</span>`
- Text: `<span title="Text" style="color:#F2994A">text</span>`
## Formatting Conventions
### Text Formatting
- **Function names**: Use exact case as in IronCalc
- **Argument names**: Use _italics_ when referencing in prose
- **Acronyms**: Expand using `<u>` tags: `<u>F</u>uture <u>V</u>alue`
- **Code/values**: Use backticks for error codes: `` `#ERROR!` ``
- **Links**: Use descriptive link text, not raw URLs
### Section Headers
- Use `#` for the page title with the function name (e.g., FV Function)
- Use `##` for main sections (Overview, Usage, Details, Examples, Links)
- Use `###` for subsections (Syntax, Argument descriptions, etc.)
### Lists
- Use bullet points (`*`) for argument descriptions and error conditions
- Use numbered lists only when order matters
## Checklist
Before submitting a function documentation, ensure:
- [ ] Frontmatter is correct
- [ ] Title follows the format "FUNCTION_NAME function"
- [ ] Overview clearly explains the function's purpose
- [ ] Syntax is color-coded correctly
- [ ] All arguments are documented with correct types
- [ ] Required vs optional arguments are clearly marked
- [ ] Return value is described
- [ ] Error conditions are comprehensive
- [ ] Examples link is included
- [ ] Links section includes relevant references
- [ ] Mathematical formulas (if any) use proper LaTeX syntax
- [ ] All internal links use relative paths
- [ ] Spelling and grammar are correct
## Example Template
```markdown
---
layout: doc
outline: deep
lang: en-US
---
# FUNCTION_NAME function
::: warning
**Note:** This draft page is under construction 🚧
:::
## Overview
FUNCTION_NAME (<u>A</u>cronym <u>E</u>xplanation) is a function of the [Category] category that can be used to [primary purpose].
[Additional context about when to use this function or related functions.]
## Usage
### Syntax
**FUNCTION_NAME(<span title="Type" style="color:#2F80ED">arg1</span>, [<span title="Type" style="color:#2F80ED">arg2</span>], [<span title="Boolean" style="color:#27AE60">arg3</span>]) => <span title="Type" style="color:#2F80ED">return_value</span>**
### Argument descriptions
- _arg1_ ([type](/features/value-types#type), required). Description.
- _arg2_ ([type](/features/value-types#type), [optional](/features/optional-arguments.md)). Description (default value).
- _arg3_ ([Boolean](/features/value-types#booleans), [optional](/features/optional-arguments.md)). Description (default FALSE).
### Additional guidance
- Tip or best practice.
- Another important note.
### Returned value
FUNCTION_NAME returns a [type](/features/value-types#type) representing [description].
### Error conditions
- General error propagation note.
- Specific error condition with [`#ERROR!`](/features/error-types.md#error) link.
- Another error condition.
<!--@include: ../markdown-snippets/error-type-details.txt-->
## Details
[Mathematical formulas or detailed explanations if needed]
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=functionname).
## Links
- Wikipedia link if applicable.
- Related IronCalc functions.
- Microsoft Excel documentation.
- Google Sheets and LibreOffice Calc links.
```
## Questions?
If you have questions about documenting functions, reach out on our [Discord Channel](https://discord.com/invite/zZYWfh3RHJ) or check existing function documentation for examples.

View File

@@ -6,4 +6,10 @@ lang: en-US
# How to Contribute
If you want to give us a hand, take a look at our [GitHub repository](https://github.com/ironcalc/IronCalc?tab=readme-ov-file#collaborators-needed-call-to-action), join our [Discord Channel](https://discord.com/invite/zZYWfh3RHJ) or send us an email to [hello@ironcalc.com](mailto:hello@ironcalc.com).
If you want to give us a hand, take a look at our [GitHub repository](https://github.com/ironcalc/IronCalc?tab=readme-ov-file#collaborators-needed-call-to-action) we've marked some issues there with the tag 'good first issue' that could serve you as a starting point.
If you also want to discuss topics, share your thoughts or just say 'hi', you can join our [Discord Channel](https://discord.com/invite/zZYWfh3RHJ) or send us an email to [hello@ironcalc.com](mailto:hello@ironcalc.com).
## Documentation
If you're interested in contributing to our documentation, especially function documentation, please see our [Function Documentation Guide](/contributing/function-documentation-guide).

View File

@@ -12,7 +12,7 @@ All Date and Time functions are already supported in IronCalc.
| ---------------- | ---------------------------------------------- | ------------- |
| DATE | <Badge type="tip" text="Available" /> | |
| DATEDIF | <Badge type="tip" text="Available" /> | |
| DATEVALUE | <Badge type="tip" text="Available" /> | |
| DATEVALUE | <Badge type="tip" text="Available" /> | [DATEVALUE](date_and_time/datevalue) |
| DAY | <Badge type="tip" text="Available" /> | [DAY](date_and_time/day) |
| DAYS | <Badge type="tip" text="Available" /> | |
| DAYS360 | <Badge type="tip" text="Available" /> | |
@@ -27,7 +27,7 @@ All Date and Time functions are already supported in IronCalc.
| NOW | <Badge type="tip" text="Available" /> | |
| SECOND | <Badge type="tip" text="Available" /> | |
| TIME | <Badge type="tip" text="Available" /> | |
| TIMEVALUE | <Badge type="tip" text="Available" /> | |
| TIMEVALUE | <Badge type="tip" text="Available" /> | [TIMEVALUE](date_and_time/timevalue) |
| TODAY | <Badge type="tip" text="Available" /> | |
| WEEKDAY | <Badge type="tip" text="Available" /> | |
| WEEKNUM | <Badge type="tip" text="Available" /> | |

View File

@@ -4,8 +4,41 @@ outline: deep
lang: en-US
---
# DATEVALUE
# DATEVALUE function
::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::
## Overview
DATEVALUE is a function of the Date and Time category that converts a date stored as text to a [serial number](/features/serial-numbers.md) corresponding to a date value.
## Usage
### Syntax
**DATEVALUE(<span title="Text" style="color:#1E88E5">date_text</span>) => <span title="Number" style="color:#1E88E5">datevalue</span>**
### Argument descriptions
* *date_text* ([text](/features/value-types#strings), required). A text string that represents a date in a known format. The text must represent a date between December 31, 1899 and December 31, 9999.
### Additional guidance
* If the year portion of the *date_text* argument is omitted, DATEVALUE uses the current year from the system clock.
* Time information in the *date_text* argument is ignored. DATEVALUE processes only the date portion.
### Returned value
DATEVALUE returns a [number](/features/value-types#numbers) that represents the date as a [serial number](/features/serial-numbers.md). The serial number corresponds to the number of days since December 31, 1899.
### Error conditions
* In common with many other IronCalc functions, DATEVALUE propagates errors that are found in its argument.
* If no argument, or more than one argument, is supplied, then DATEVALUE returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the value of the *date_text* argument is not (or cannot be converted to) a [text](/features/value-types#strings) value, then DATEVALUE returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the *date_text* argument represents a date outside the valid range (before December 31, 1899 or after December 31, 9999), then DATEVALUE returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the *date_text* argument cannot be recognized as a valid date format, then DATEVALUE returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
<!-- ## Details
For more information on how IronCalc processes Date and Time functions and values, visit [Date and Time](/features/serial-numbers.md)
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=datevalue).
-->
## Links
* See also IronCalc's [TIMEVALUE](/functions/date_and_time/timevalue.md) function for converting time text to serial numbers.
* Visit Microsoft Excel's [DATEVALUE function](https://support.microsoft.com/en-us/office/datevalue-function-df8b07d4-7761-4a93-bc33-b7471bbff252) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093039) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/DATEVALUE) provide versions of the DATEVALUE function.

View File

@@ -4,9 +4,42 @@ outline: deep
lang: en-US
---
# TIMEVALUE
# TIMEVALUE function
::: warning
**Note:** This draft page is under construction 🚧
The TIMEVALUE function is implemented and available in IronCalc.
:::
## Overview
TIMEVALUE is a function of the Date and Time category that converts a time stored as text to a [serial number](/features/serial-numbers.md) corresponding to a time value. The serial number represents time as a decimal fraction of a 24-hour day (e.g., 0.5 represents 12:00:00 noon).
## Usage
### Syntax
**TIMEVALUE(<span title="Text" style="color:#1E88E5">time_text</span>) => <span title="Number" style="color:#1E88E5">timevalue</span>**
### Argument descriptions
* *time_text* ([text](/features/value-types#strings), required). A text string that represents a time in a known format. The text must represent a time between 00:00:00 and 23:59:59.
### Additional guidance
* Date information in the *time_text* argument is ignored. TIMEVALUE processes only the time portion.
* The function can handle various time formats, including both 12-hour and 24-hour formats, as well as text that includes both date and time information.
### Returned value
TIMEVALUE returns a [number](/features/value-types#numbers) that represents the time as a [serial number](/features/serial-numbers.md). The serial number is a decimal fraction of a 24-hour day, where:
* 0.0 represents 00:00:00 (midnight)
* 0.5 represents 12:00:00 (midday)
* 0.99999... represents 23:59:59 (just before midnight)
### Error conditions
* In common with many other IronCalc functions, TIMEVALUE propagates errors that are found in its argument.
* If no argument, or more than one argument, is supplied, then TIMEVALUE returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the value of the *time_text* argument is not (or cannot be converted to) a [text](/features/value-types#strings) value, then TIMEVALUE returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the *time_text* argument represents a time outside the valid range, then TIMEVALUE returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the *time_text* argument cannot be recognized as a valid time format, then TIMEVALUE returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
<!--
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=timevalue).
-->
## Links
* See also IronCalc's [DATEVALUE](/functions/date_and_time/datevalue.md) function for converting date text to serial numbers.
* Visit Microsoft Excel's [TIMEVALUE function](https://support.microsoft.com/en-us/office/timevalue-function-0b615c12-33d8-4431-bf3d-f3eb6d186645) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3267350) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/TIMEVALUE) provide versions of the TIMEVALUE function.

View File

@@ -14,10 +14,10 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| ABS | <Badge type="tip" text="Available" /> | |
| ACOS | <Badge type="tip" text="Available" /> | [ACOS](math_and_trigonometry/acos) |
| ACOSH | <Badge type="tip" text="Available" /> | [ACOSH](math_and_trigonometry/acosh) |
| ACOT | <Badge type="info" text="Not implemented yet" /> | |
| ACOTH | <Badge type="info" text="Not implemented yet" /> | |
| ACOT | <Badge type="tip" text="Available" /> | |
| ACOTH | <Badge type="tip" text="Available" /> | |
| AGGREGATE | <Badge type="info" text="Not implemented yet" /> | |
| ARABIC | <Badge type="info" text="Not implemented yet" /> | |
| ARABIC | <Badge type="tip" text="Available" /> | |
| ASIN | <Badge type="tip" text="Available" /> | [ASIN](math_and_trigonometry/asin) |
| ASINH | <Badge type="tip" text="Available" /> | [ASINH](math_and_trigonometry/asinh) |
| ATAN | <Badge type="tip" text="Available" /> | [ATAN](math_and_trigonometry/atan) |
@@ -27,64 +27,64 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| CEILING | <Badge type="info" text="Not implemented yet" /> | |
| CEILING.MATH | <Badge type="info" text="Not implemented yet" /> | |
| CEILING.PRECISE | <Badge type="info" text="Not implemented yet" /> | |
| COMBIN | <Badge type="info" text="Not implemented yet" /> | |
| COMBINA | <Badge type="info" text="Not implemented yet" /> | |
| COMBIN | <Badge type="tip" text="Available" /> | |
| COMBINA | <Badge type="tip" text="Available" /> | |
| COS | <Badge type="tip" text="Available" /> | [COS](math_and_trigonometry/cos) |
| COSH | <Badge type="tip" text="Available" /> | [COSH](math_and_trigonometry/cosh) |
| COT | <Badge type="info" text="Not implemented yet" /> | |
| COTH | <Badge type="info" text="Not implemented yet" /> | |
| CSC | <Badge type="info" text="Not implemented yet" /> | |
| CSCH | <Badge type="info" text="Not implemented yet" /> | |
| COT | <Badge type="tip" text="Available" /> | |
| COTH | <Badge type="tip" text="Available" /> | |
| CSC | <Badge type="tip" text="Available" /> | |
| CSCH | <Badge type="tip" text="Available" /> | |
| DECIMAL | <Badge type="info" text="Not implemented yet" /> | |
| DEGREES | <Badge type="info" text="Not implemented yet" /> | |
| EVEN | <Badge type="info" text="Not implemented yet" /> | |
| EXP | <Badge type="info" text="Not implemented yet" /> | |
| DEGREES | <Badge type="tip" text="Available" /> | [DEGREES](math_and_trigonometry/degrees) |
| EVEN | <Badge type="tip" text="Available" /> | [EVEN](math_and_trigonometry/even) |
| EXP | <Badge type="tip" text="Available" /> | |
| FACT | <Badge type="info" text="Not implemented yet" /> | |
| FACTDOUBLE | <Badge type="info" text="Not implemented yet" /> | |
| FLOOR | <Badge type="info" text="Not implemented yet" /> | |
| FLOOR.MATH | <Badge type="info" text="Not implemented yet" /> | |
| FLOOR.PRECISE | <Badge type="info" text="Not implemented yet" /> | |
| GCD | <Badge type="info" text="Not implemented yet" /> | |
| INT | <Badge type="info" text="Not implemented yet" /> | |
| INT | <Badge type="tip" text="Available" /> | |
| ISO.CEILING | <Badge type="info" text="Not implemented yet" /> | |
| LCM | <Badge type="info" text="Not implemented yet" /> | |
| LET | <Badge type="info" text="Not implemented yet" /> | |
| LN | <Badge type="info" text="Available" /> | |
| LOG | <Badge type="info" text="Available" /> | |
| LOG10 | <Badge type="info" text="Available" /> | |
| LN | <Badge type="tip" text="Available" /> | |
| LOG | <Badge type="tip" text="Available" /> | |
| LOG10 | <Badge type="tip" text="Available" /> | |
| MDETERM | <Badge type="info" text="Not implemented yet" /> | |
| MINVERSE | <Badge type="info" text="Not implemented yet" /> | |
| MMULT | <Badge type="info" text="Not implemented yet" /> | |
| MOD | <Badge type="info" text="Not implemented yet" /> | |
| MROUND | <Badge type="info" text="Not implemented yet" /> | |
| MOD | <Badge type="tip" text="Available" /> | [MOD](math_and_trigonometry/mod) |
| MROUND | <Badge type="tip" text="Available" /> | |
| MULTINOMIAL | <Badge type="info" text="Not implemented yet" /> | |
| MUNIT | <Badge type="info" text="Not implemented yet" /> | |
| ODD | <Badge type="info" text="Not implemented yet" /> | |
| ODD | <Badge type="tip" text="Available" /> | [ODD](math_and_trigonometry/odd) |
| PI | <Badge type="info" text="Not implemented yet" /> | |
| POWER | <Badge type="tip" text="Available" /> | |
| PRODUCT | <Badge type="tip" text="Available" /> | |
| QUOTIENT | <Badge type="info" text="Not implemented yet" /> | |
| RADIANS | <Badge type="info" text="Not implemented yet" /> | |
| QUOTIENT | <Badge type="tip" text="Available" /> | [QUOTIENT](math_and_trigonometry/quotient) |
| RADIANS | <Badge type="tip" text="Available" /> | [RADIANS](math_and_trigonometry/radians) |
| RAND | <Badge type="tip" text="Available" /> | |
| RANDARRAY | <Badge type="info" text="Not implemented yet" /> | |
| RANDBETWEEN | <Badge type="tip" text="Available" /> | |
| ROMAN | <Badge type="info" text="Not implemented yet" /> | |
| ROMAN | <Badge type="tip" text="Available" /> | |
| ROUND | <Badge type="tip" text="Available" /> | |
| ROUNDDOWN | <Badge type="tip" text="Available" /> | |
| ROUNDUP | <Badge type="tip" text="Available" /> | |
| SEC | <Badge type="info" text="Not implemented yet" /> | |
| SECH | <Badge type="info" text="Not implemented yet" /> | |
| SEC | <Badge type="tip" text="Available" /> | |
| SECH | <Badge type="tip" text="Available" /> | |
| SERIESSUM | <Badge type="info" text="Not implemented yet" /> | |
| SEQUENCE | <Badge type="info" text="Not implemented yet" /> | |
| SIGN | <Badge type="info" text="Not implemented yet" /> | |
| SIGN | <Badge type="tip" text="Available" /> | |
| SIN | <Badge type="tip" text="Available" /> | [SIN](math_and_trigonometry/sin) |
| SINH | <Badge type="tip" text="Available" /> | [SINH](math_and_trigonometry/sinh) |
| SQRT | <Badge type="tip" text="Available" /> | |
| SQRTPI | <Badge type="info" text="Available" /> | |
| SQRTPI | <Badge type="tip" text="Available" /> | |
| SUBTOTAL | <Badge type="info" text="Not implemented yet" /> | |
| SUM | <Badge type="tip" text="Available" /> | |
| SUMIF | <Badge type="tip" text="Available" /> | |
| SUMIFS | <Badge type="info" text="Available" /> | |
| SUMIFS | <Badge type="tip" text="Available" /> | |
| SUMPRODUCT | <Badge type="info" text="Not implemented yet" /> | |
| SUMSQ | <Badge type="info" text="Not implemented yet" /> | |
| SUMX2MY2 | <Badge type="info" text="Not implemented yet" /> | |
@@ -92,4 +92,4 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| SUMXMY2 | <Badge type="info" text="Not implemented yet" /> | |
| TAN | <Badge type="tip" text="Available" /> | [TAN](math_and_trigonometry/tan) |
| TANH | <Badge type="tip" text="Available" /> | [TANH](math_and_trigonometry/tanh) |
| TRUNC | <Badge type="info" text="Not implemented yet" /> | |
| TRUNC | <Badge type="tip" text="Available" /> | |

View File

@@ -7,6 +7,5 @@ lang: en-US
# ACOT
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# ACOTH
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# ARABIC
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# COMBIN
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# COMBINA
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# COT
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# COTH
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# CSC
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# CSCH
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -4,9 +4,40 @@ outline: deep
lang: en-US
---
# DEGREES
# DEGREES function
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
:::
## Overview
DEGREES is a function of the Math and Trigonometry category that converts an angle measured in radians to an equivalent angle measured in degrees.
## Usage
### Syntax
**DEGREES(<span title="Number" style="color:#1E88E5">angle</span>) => <span title="Number" style="color:#1E88E5">degrees</span>**
### Argument descriptions
* *angle* ([number](/features/value-types#numbers), required). The angle in radians that is to be converted to degrees.
### Additional guidance
The conversion from radians to degrees is based on the relationship:
$$
1~\:~\text{radian} = \dfrac{180}{\pi}~\text{degrees} \approx 57.29577951~\text{degrees}
$$
### Returned value
DEGREES returns a [number](/features/value-types#numbers) that represents the value of the given angle expressed in degrees.
### Error conditions
* In common with many other IronCalc functions, DEGREES propagates errors that are found in its argument.
* If no argument, or more than one argument, is supplied, then DEGREES returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the value of the *angle* argument is not (or cannot be converted to) a [number](/features/value-types#numbers), then DEGREES returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
<!--
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=degrees).
-->
## Links
* For more information about angle conversions, visit Wikipedia's [Degree (angle)](https://en.wikipedia.org/wiki/Degree_(angle)) page.
* See also IronCalc's [RADIANS](/functions/math_and_trigonometry/radians) function for converting degrees to radians.
* Visit Microsoft Excel's [DEGREES function](https://support.microsoft.com/en-us/office/degrees-function-4d6ec4db-e694-4b94-ace0-1cc3f61f9ba1) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093481) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/DEGREES) provide versions of the DEGREES function.

View File

@@ -4,9 +4,41 @@ outline: deep
lang: en-US
---
# EVEN
# EVEN function
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
:::
## Overview
EVEN is a function of the Math and Trigonometry category that rounds a number up (away from zero) to the nearest even integer.
## Usage
### Syntax
**EVEN(<span title="Number" style="color:#1E88E5">number</span>) => <span title="Number" style="color:#1E88E5">even</span>**
### Argument descriptions
* *number* ([number](/features/value-types#numbers), required). The number that is to be rounded to the nearest even integer.
### Additional guidance
* EVEN rounds away from zero, meaning:
* Positive numbers are rounded up to the next even integer.
* Negative numbers are rounded down (toward negative infinity) to the next even integer.
* If the *number* argument is already an even integer, EVEN returns it unchanged.
* Since zero is considered an even number, the EVEN function returns 0 when *number* is 0.
### Returned value
EVEN returns a [number](/features/value-types#numbers) that is the nearest even integer, rounded away from zero.
### Error conditions
* In common with many other IronCalc functions, EVEN propagates errors that are found in its argument.
* If no argument, or more than one argument, is supplied, then EVEN returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the value of the *number* argument is not (or cannot be converted to) a [number](/features/value-types#numbers), then EVEN returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
<!--
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=even).
-->
## Links
* For more information about even and odd numbers, visit Wikipedia's [Parity](https://en.wikipedia.org/wiki/Parity_(mathematics)) page.
* See also IronCalc's [ODD](/functions/math_and_trigonometry/odd) function.
* Visit Microsoft Excel's [EVEN function](https://support.microsoft.com/en-us/office/even-function-197b5f06-c795-4c1e-8696-3c3b8a646cf9) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093409) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/EVEN) provide versions of the EVEN function.

View File

@@ -7,6 +7,5 @@ lang: en-US
# EXP
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# INT
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -4,9 +4,44 @@ outline: deep
lang: en-US
---
# MOD
# MOD function
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
:::
## Overview
MOD is a function of the Math and Trigonometry category that returns the remainder after one number (the dividend) is divided by another number (the divisor). The result has the same sign as the divisor.
## Usage
### Syntax
**MOD(<span title="Number" style="color:#1E88E5">dividend</span>, <span title="Number" style="color:#1E88E5">divisor</span>) => <span title="Number" style="color:#1E88E5">remainder</span>**
### Argument descriptions
* *dividend* ([number](/features/value-types#numbers), required). The number whose remainder is to be calculated.
* *divisor* ([number](/features/value-types#numbers), required). The number by which the dividend is divided.
### Additional guidance
None.
### Returned value
MOD returns a [number](/features/value-types#numbers) that is the remainder after division, with the same sign as the divisor.
### Error conditions
* In common with many other IronCalc functions, MOD propagates errors that are found in its arguments.
* If no argument, or more than two arguments, are supplied, then MOD returns the [`#ERROR!`](/features/error-types.md#error) error.
* If either argument is not (or cannot be converted to) a [number](/features/value-types#numbers), then MOD returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the value of the *divisor* argument is 0, then MOD returns the [`#DIV/0!`](/features/error-types.md#div-0) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
## Details
* MOD follows the formula:
$$
\operatorname{MOD}(n, d) = n - d \times \operatorname{INT}\!\left(\dfrac{n}{d}\right)
$$
Since `INT` returns the greatest integer less than or equal to its argument (it rounds down), the remainder's sign matches the divisor, even though this might appear counterintuitive when the dividend and divisor have different signs.
<!--
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=mod).
-->
## Links
* For more information about the modulo operation, visit Wikipedia's [Modulo](https://en.wikipedia.org/wiki/Modulo) page.
* See also IronCalc's [QUOTIENT](/functions/math_and_trigonometry/quotient) function.
* Visit Microsoft Excel's [MOD function](https://support.microsoft.com/en-us/office/mod-function-9b6cd169-b6ee-406a-a97b-edf2a9dc24f3) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093497) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/MOD) provide versions of the MOD function.

View File

@@ -7,6 +7,5 @@ lang: en-US
# MROUND
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -4,9 +4,41 @@ outline: deep
lang: en-US
---
# ODD
# ODD function
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
:::
## Overview
ODD is a function of the Math and Trigonometry category that rounds a number up (away from zero) to the nearest odd integer.
## Usage
### Syntax
**ODD(<span title="Number" style="color:#1E88E5">number</span>) => <span title="Number" style="color:#1E88E5">odd</span>**
### Argument descriptions
* *number* ([number](/features/value-types#numbers), required). The number that is to be rounded to the nearest odd integer.
### Additional guidance
* ODD rounds away from zero, meaning:
* Positive numbers are rounded up to the next odd integer.
* Negative numbers are rounded down (toward negative infinity) to the next odd integer.
* If the *number* argument is already an odd integer, ODD returns it unchanged.
* Since zero is considered an even number, the ODD function returns 1 when *number* is 0.
### Returned value
ODD returns a [number](/features/value-types#numbers) that is the nearest odd integer, rounded away from zero.
### Error conditions
* In common with many other IronCalc functions, ODD propagates errors that are found in its argument.
* If no argument, or more than one argument, is supplied, then ODD returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the value of the *number* argument is not (or cannot be converted to) a [number](/features/value-types#numbers), then ODD returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
<!--
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=odd).
-->
## Links
* For more information about even and odd numbers, visit Wikipedia's [Parity](https://en.wikipedia.org/wiki/Parity_(mathematics)) page.
* See also IronCalc's [EVEN](/functions/math_and_trigonometry/even) function.
* Visit Microsoft Excel's [ODD function](https://support.microsoft.com/en-us/office/odd-function-deae64eb-e08a-4c88-8b40-6d0b42575c98) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093499) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/ODD) provide versions of the ODD function.

View File

@@ -4,9 +4,44 @@ outline: deep
lang: en-US
---
# QUOTIENT
# QUOTIENT function
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
:::
## Overview
QUOTIENT is a function of the Math and Trigonometry category that returns the integer portion of a division. It divides one number (dividend) by another (divisor) and discards the remainder by truncating toward zero.
## Usage
### Syntax
**QUOTIENT(<span title="Number" style="color:#1E88E5">dividend</span>, <span title="Number" style="color:#1E88E5">divisor</span>) => <span title="Number" style="color:#1E88E5">quotient</span>**
### Argument descriptions
* *dividend* ([number](/features/value-types#numbers), required). The number to be divided.
* *divisor* ([number](/features/value-types#numbers), required). The number by which to divide the dividend.
### Additional guidance
* QUOTIENT returns the integer part of the division and ignores any remainder. For negative results, it truncates toward zero.
### Returned value
QUOTIENT returns a [number](/features/value-types#numbers) that is the integer portion of the division of the dividend by the divisor.
### Error conditions
* In common with many other IronCalc functions, QUOTIENT propagates errors that are found in its arguments.
* If no argument, or more than two arguments, are supplied, then QUOTIENT returns the [`#ERROR!`](/features/error-types.md#error) error.
* If either argument is not (or cannot be converted to) a [number](/features/value-types#numbers), then QUOTIENT returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the value of the *divisor* argument is 0, then QUOTIENT returns the [`#DIV/0!`](/features/error-types.md#div-0) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
## Details
* QUOTIENT corresponds to truncating the exact quotient toward zero:
$$
\operatorname{QUOTIENT}(n, d) = \operatorname{TRUNC}\!\left(\dfrac{n}{d}\right),\quad d \ne 0
$$
This differs from using `INT(n/d)` when the quotient is negative, because `INT` rounds down toward −∞, whereas `TRUNC` and QUOTIENT truncate toward zero.
<!--
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=quotient).
-->
## Links
* For more information about the quotient, visit Wikipedia's [Quotient](https://en.wikipedia.org/wiki/Quotient) page.
* See also IronCalc's [MOD](/functions/math_and_trigonometry/mod) function.
* Visit Microsoft Excel's [QUOTIENT function](https://support.microsoft.com/en-gb/office/quotient-function-9f7bf099-2a18-4282-8fa4-65290cc99dee) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093436) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/QUOTIENT) provide versions of the QUOTIENT function.

View File

@@ -4,9 +4,38 @@ outline: deep
lang: en-US
---
# RADIANS
# RADIANS function
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
:::
## Overview
RADIANS is a function of the Math and Trigonometry category that converts an angle measured in degrees to an equivalent angle measured in radians.
## Usage
### Syntax
**RADIANS(<span title="Number" style="color:#1E88E5">angle</span>) => <span title="Number" style="color:#1E88E5">radians</span>**
### Argument descriptions
* *angle* ([number](/features/value-types#numbers), required). The angle in degrees that is to be converted to radians.
### Additional guidance
The conversion from degrees to radians is based on the relationship:
$$
1~\:~\text{degree} = \dfrac{\pi}{180}~\text{radians} \approx 0.01745329252~\text{radians}
$$
### Returned value
RADIANS returns a [number](/features/value-types#numbers) that represents the value of the given angle expressed in radians.
### Error conditions
* In common with many other IronCalc functions, RADIANS propagates errors that are found in its argument.
* If no argument, or more than one argument, is supplied, then RADIANS returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the value of the *angle* argument is not (or cannot be converted to) a [number](/features/value-types#numbers), then RADIANS returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
<!--
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=radians).
-->
## Links
* For more information about angle conversions, visit Wikipedia's [Radian](https://en.wikipedia.org/wiki/Radian) page.
* See also IronCalc's [DEGREES](/functions/math_and_trigonometry/degrees) function for converting radians to degrees.
* Visit Microsoft Excel's [RADIANS function](https://support.microsoft.com/en-us/office/radians-function-907f0ede-ef2e-4f7b-911a-015e2f8ab878) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093481) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/RADIANS) provide versions of the RADIANS function.

View File

@@ -7,6 +7,5 @@ lang: en-US
# ROMAN
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# SEC
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# SECH
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# SIGN
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -7,6 +7,5 @@ lang: en-US
# TRUNC
::: warning
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
:::

View File

@@ -16,6 +16,9 @@ const config: StorybookConfig = {
}
config.server.fs.allow = ["../.."];
return config;
}
},
core: {
disableTelemetry: true,
},
};
export default config;

View File

@@ -1,5 +1,4 @@
{
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@ironcalc/workbook",
"version": "0.3.2",
"version": "0.6.0",
"type": "module",
"main": "./dist/ironcalc.js",
"module": "./dist/ironcalc.js",
@@ -18,31 +18,31 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@ironcalc/wasm": "file:../../bindings/wasm/pkg",
"@mui/material": "^7.1.1",
"@mui/system": "^7.1.1",
"i18next": "^25.2.1",
"lucide-react": "^0.513.0",
"@mui/material": "^7.3.5",
"@mui/system": "^7.3.5",
"i18next": "^25.6.1",
"lucide-react": "^0.553.0",
"react-colorful": "^5.6.1",
"react-i18next": "^15.5.2"
"react-i18next": "^16.2.2"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@storybook/react": "^9.0.5",
"@storybook/react-vite": "^9.0.5",
"@biomejs/biome": "2.3.5",
"@storybook/react": "^10.0.7",
"@storybook/react-vite": "^10.0.7",
"@vitejs/plugin-react": "^4.2.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"storybook": "^9.0.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"storybook": "^10.0.7",
"ts-node": "^10.9.2",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^3.2.2"
"typescript": "~5.9.3",
"vite": "^7.2.2",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.8"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
"@types/react": "^18.0.0 || ^19.2.0",
"react": "^18.0.0 || ^19.2.0",
"react-dom": "^18.0.0 || ^19.2.0"
},
"files": [
"dist"

View File

@@ -173,18 +173,16 @@ const ColorPicker = ({
<RecentLabel>{t("color_picker.custom")}</RecentLabel>
<RecentColorsList>
{recentColors.current.length > 0 ? (
<>
{recentColors.current.map((recentColor) => (
<ColorSwatch
key={recentColor}
$color={recentColor}
onClick={(): void => {
setSelectedColor(recentColor);
handleColorSelect(recentColor);
}}
/>
))}
</>
recentColors.current.map((recentColor) => (
<ColorSwatch
key={recentColor}
$color={recentColor}
onClick={(): void => {
setSelectedColor(recentColor);
handleColorSelect(recentColor);
}}
/>
))
) : (
<EmptyContainer />
)}

View File

@@ -1,9 +1,9 @@
import {
getTokens,
type Model,
type Range,
type Reference,
type TokenType,
getTokens,
} from "@ironcalc/wasm";
import type { JSX } from "react";
import type { ActiveRange } from "../workbookState";
@@ -197,4 +197,40 @@ function getFormulaHTML(
return { html, activeRanges };
}
// Given a formula (without the equals sign) returns (sheetIndex, rowStart, columnStart, rowEnd, columnEnd)
// if it represent a reference or range like `Sheet1!A1` or `Sheet3!D3:D10` in an existing sheet
// If it is not a reference or range it returns null
export function parseRangeInSheet(
model: Model,
formula: string,
): [number, number, number, number, number] | null {
// HACK: We are checking here the series of tokens in the range formula.
// This is enough for our purposes but probably a more specific ranges in formula method would be better.
const worksheets = model.getWorksheetsProperties();
const tokens = getTokens(formula);
const { token } = tokens[0];
if (tokenIsRangeType(token)) {
const {
sheet: refSheet,
left: { row: rowStart, column: columnStart },
right: { row: rowEnd, column: columnEnd },
} = token.Range;
if (refSheet !== null) {
const sheetIndex = worksheets.findIndex((s) => s.name === refSheet);
if (sheetIndex >= 0) {
return [sheetIndex, rowStart, columnStart, rowEnd, columnEnd];
}
}
} else if (tokenIsReferenceType(token)) {
const { sheet: refSheet, row, column } = token.Reference;
if (refSheet !== null) {
const sheetIndex = worksheets.findIndex((s) => s.name === refSheet);
if (sheetIndex >= 0) {
return [sheetIndex, row, column, row, column];
}
}
}
return null;
}
export default getFormulaHTML;

View File

@@ -2,12 +2,12 @@ import type { Model } from "@ironcalc/wasm";
import { styled } from "@mui/material";
import { Fx } from "../../icons";
import { theme } from "../../theme";
import { FORMULA_BAR_HEIGHT } from "../constants";
import Editor from "../Editor/Editor";
import {
COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE,
} from "../WorksheetCanvas/constants";
import { FORMULA_BAR_HEIGHT } from "../constants";
import type { WorkbookState } from "../workbookState";
type FormulaBarProps = {

View File

@@ -1,329 +0,0 @@
import type { DefinedName, WorksheetProperties } from "@ironcalc/wasm";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
styled,
} from "@mui/material";
import { t } from "i18next";
import { BookOpen, PackageOpen, Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { theme } from "../../theme";
import NamedRangeActive from "./NamedRangeActive";
import NamedRangeInactive from "./NamedRangeInactive";
export interface NameManagerProperties {
newDefinedName: (
name: string,
scope: number | undefined,
formula: string,
) => void;
updateDefinedName: (
name: string,
scope: number | undefined,
newName: string,
newScope: number | undefined,
newFormula: string,
) => void;
deleteDefinedName: (name: string, scope: number | undefined) => void;
selectedArea: () => string;
worksheets: WorksheetProperties[];
definedNameList: DefinedName[];
}
interface NameManagerDialogProperties {
open: boolean;
onClose: () => void;
model: NameManagerProperties;
}
function NameManagerDialog(properties: NameManagerDialogProperties) {
const { open, model, onClose } = properties;
const {
newDefinedName,
updateDefinedName,
deleteDefinedName,
selectedArea,
worksheets,
definedNameList,
} = model;
// If editingNameIndex is -1, then we are adding a new name
// If editingNameIndex is -2, then we are not editing any name
// If editingNameIndex is a positive number, then we are editing that index
const [editingNameIndex, setEditingNameIndex] = useState(-2);
useEffect(() => {
if (open) {
setEditingNameIndex(-2);
}
}, [open]);
const handleClose = () => {
properties.onClose();
};
return (
<StyledDialog open={open} onClose={onClose} maxWidth={false} scroll="paper">
<StyledDialogTitle>
{t("name_manager_dialog.title")}
<Cross
onClick={handleClose}
title={t("name_manager_dialog.close")}
tabIndex={0}
onKeyDown={(event) => event.key === "Enter" && properties.onClose()}
>
<X />
</Cross>
</StyledDialogTitle>
<StyledDialogContent>
{(definedNameList.length > 0 || editingNameIndex !== -2) && (
<StyledRangesHeader>
<StyledBox>{t("name_manager_dialog.name")}</StyledBox>
<StyledBox>{t("name_manager_dialog.range")}</StyledBox>
<StyledBox>{t("name_manager_dialog.scope")}</StyledBox>
</StyledRangesHeader>
)}
{definedNameList.length === 0 && editingNameIndex === -2 ? (
<EmptyStateMessage>
<IconWrapper>
<PackageOpen />
</IconWrapper>
{t("name_manager_dialog.empty_message1")}
<br />
{t("name_manager_dialog.empty_message2")}
</EmptyStateMessage>
) : (
<NameListWrapper>
{definedNameList.map((definedName, index) => {
const scopeName =
definedName.scope !== undefined
? worksheets[definedName.scope].name
: "[global]";
if (index === editingNameIndex) {
return (
<NamedRangeActive
worksheets={worksheets}
name={definedName.name}
scope={scopeName}
formula={definedName.formula}
key={definedName.name + definedName.scope}
onSave={(
newName,
newScope,
newFormula,
): string | undefined => {
const scope_index = worksheets.findIndex(
(s) => s.name === newScope,
);
const scope = scope_index >= 0 ? scope_index : undefined;
try {
updateDefinedName(
definedName.name,
definedName.scope,
newName,
scope,
newFormula,
);
setEditingNameIndex(-2);
} catch (e) {
return `${e}`;
}
}}
onCancel={() => setEditingNameIndex(-2)}
/>
);
}
return (
<NamedRangeInactive
name={definedName.name}
scope={scopeName}
formula={definedName.formula}
key={definedName.name + definedName.scope}
showOptions={editingNameIndex === -2}
onEdit={() => setEditingNameIndex(index)}
onDelete={() => {
deleteDefinedName(definedName.name, definedName.scope);
}}
/>
);
})}
</NameListWrapper>
)}
{editingNameIndex === -1 && (
<NamedRangeActive
worksheets={worksheets}
name={""}
formula={selectedArea()}
scope={"[global]"}
onSave={(name, scope, formula): string | undefined => {
const scope_index = worksheets.findIndex((s) => s.name === scope);
const scope_value = scope_index > 0 ? scope_index : undefined;
try {
newDefinedName(name, scope_value, formula);
setEditingNameIndex(-2);
} catch (e) {
return `${e}`;
}
}}
onCancel={() => setEditingNameIndex(-2)}
/>
)}
</StyledDialogContent>
<StyledDialogActions>
<Box display="flex" alignItems="center" gap={"8px"}>
<BookOpen color="grey" size={16} />
<UploadFooterLink
href="https://docs.ironcalc.com/web-application/name-manager.html"
target="_blank"
rel="noopener noreferrer"
>
{t("name_manager_dialog.help")}
</UploadFooterLink>
</Box>
<Button
onClick={() => setEditingNameIndex(-1)}
variant="contained"
disableElevation
sx={{ textTransform: "none", minWidth: "fit-content" }}
startIcon={<Plus size={16} />}
disabled={editingNameIndex > -2}
>
{t("name_manager_dialog.new")}
</Button>
</StyledDialogActions>
</StyledDialog>
);
}
const StyledDialog = styled(Dialog)(({ theme }) => ({
"& .MuiPaper-root": {
height: "400px",
minHeight: "200px",
minWidth: "620px",
maxWidth: "620px",
[theme.breakpoints.down("sm")]: {
minWidth: "90%",
},
},
}));
const StyledDialogTitle = styled(DialogTitle)`
display: flex;
align-items: center;
height: 44px;
font-size: 14px;
font-weight: 500;
font-family: Inter;
padding: 0px 12px;
justify-content: space-between;
border-bottom: 1px solid ${theme.palette.grey["300"]};
`;
const Cross = styled("div")`
&:hover {
background-color: ${theme.palette.grey["50"]};
}
display: flex;
border-radius: 4px;
height: 24px;
width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
`;
const NameListWrapper = styled(Stack)`
overflow-y: auto;
`;
const EmptyStateMessage = styled(Box)`
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
height: 100%;
font-size: 12px;
color: ${theme.palette.grey["600"]};
font-family: "Inter";
z-index: 0;
margin: auto 0px;
position: relative;
`;
const IconWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 4px;
background-color: ${theme.palette.grey["100"]};
color: ${theme.palette.grey["600"]};
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
`;
const StyledBox = styled(Box)`
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
padding-left: 8px;
`;
const StyledDialogContent = styled(DialogContent)`
display: flex;
flex-direction: column;
padding: 0px;
`;
const StyledRangesHeader = styled(Stack)(({ theme }) => ({
flexDirection: "row",
minHeight: "32px",
padding: "0px 96px 0px 12px",
gap: "12px",
fontFamily: theme.typography.fontFamily,
fontSize: "12px",
fontWeight: "700",
borderBottom: `1px solid ${theme.palette.info.light}`,
backgroundColor: theme.palette.grey["50"],
color: theme.palette.info.main,
}));
const StyledDialogActions = styled(DialogActions)`
padding: 12px;
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: ${theme.palette.grey["600"]};
border-top: 1px solid ${theme.palette.grey["300"]};
`;
const UploadFooterLink = styled("a")`
font-size: 12px;
font-weight: 400;
font-family: "Inter";
color: ${theme.palette.grey["600"]};
text-decoration: none;
&:hover {
text-decoration: underline;
}
`;
export default NameManagerDialog;

View File

@@ -1,170 +0,0 @@
import type { WorksheetProperties } from "@ironcalc/wasm";
import {
Box,
Divider,
IconButton,
MenuItem,
TextField,
styled,
} from "@mui/material";
import { t } from "i18next";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { theme } from "../../theme";
interface NamedRangeProperties {
worksheets: WorksheetProperties[];
name: string;
scope: string;
formula: string;
onSave: (name: string, scope: string, formula: string) => string | undefined;
onCancel: () => void;
}
function NamedRangeActive(properties: NamedRangeProperties) {
const { worksheets, onSave, onCancel } = properties;
const [name, setName] = useState(properties.name);
const [scope, setScope] = useState(properties.scope);
const [formula, setFormula] = useState(properties.formula);
const [formulaError, setFormulaError] = useState(false);
return (
<>
<StyledBox>
<StyledTextField
id="name"
variant="outlined"
size="small"
margin="none"
fullWidth
error={formulaError}
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => event.stopPropagation()}
/>
<StyledTextField
id="scope"
variant="outlined"
select
size="small"
margin="none"
fullWidth
error={formulaError}
value={scope}
onChange={(event) => {
setScope(event.target.value);
}}
>
<MenuItem value={"[global]"}>
<MenuSpan>{t("name_manager_dialog.workbook")}</MenuSpan>
<MenuSpanGrey>{` ${t("name_manager_dialog.global")}`}</MenuSpanGrey>
</MenuItem>
{worksheets.map((option) => (
<MenuItem key={option.name} value={option.name}>
<MenuSpan>{option.name}</MenuSpan>
</MenuItem>
))}
</StyledTextField>
<StyledTextField
id="formula"
variant="outlined"
size="small"
margin="none"
fullWidth
error={formulaError}
value={formula}
onChange={(event) => setFormula(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => event.stopPropagation()}
/>
<IconsWrapper>
<StyledIconButton
onClick={() => {
const error = onSave(name, scope, formula);
if (error) {
setFormulaError(true);
}
}}
title={t("name_manager_dialog.apply")}
>
<StyledCheck size={16} />
</StyledIconButton>
<StyledIconButton
onClick={onCancel}
title={t("name_manager_dialog.discard")}
>
<X size={16} />
</StyledIconButton>
</IconsWrapper>
</StyledBox>
<Divider />
</>
);
}
const MenuSpan = styled("span")`
font-size: 12px;
font-family: "Inter";
`;
const MenuSpanGrey = styled("span")`
white-space: pre;
font-size: 12px;
font-family: "Inter";
color: ${theme.palette.grey[400]};
`;
const StyledBox = styled(Box)`
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
width: auto;
padding: 10px 20px 10px 12px;
box-shadow: 0 -1px 0 ${theme.palette.grey[300]};
@media (max-width: 600px) {
padding: 12px;
}
`;
const StyledTextField = styled(TextField)(() => ({
"& .MuiInputBase-root": {
height: "36px",
width: "100%",
margin: 0,
fontFamily: "Inter",
fontSize: "12px",
},
"& .MuiInputBase-input": {
padding: "8px",
},
}));
const StyledIconButton = styled(IconButton)(({ theme }) => ({
color: theme.palette.error.main,
borderRadius: "8px",
"&:hover": {
backgroundColor: theme.palette.grey["50"],
},
"&.Mui-disabled": {
opacity: 0.6,
color: theme.palette.error.light,
},
}));
const StyledCheck = styled(Check)(({ theme }) => ({
color: theme.palette.success.main,
}));
const IconsWrapper = styled(Box)({
display: "flex",
});
export default NamedRangeActive;

View File

@@ -1,93 +0,0 @@
import { Box, Divider, IconButton, styled } from "@mui/material";
import { t } from "i18next";
import { PencilLine, Trash2 } from "lucide-react";
interface NamedRangeInactiveProperties {
name: string;
scope: string;
formula: string;
onDelete: () => void;
onEdit: () => void;
showOptions: boolean;
}
function NamedRangeInactive(properties: NamedRangeInactiveProperties) {
const { name, scope, formula, onDelete, onEdit, showOptions } = properties;
const scopeName =
scope === "[global]"
? `${t("name_manager_dialog.workbook")} ${t(
"name_manager_dialog.global",
)}`
: scope;
return (
<>
<WrappedLine>
<StyledDiv>{name}</StyledDiv>
<StyledDiv>{scopeName}</StyledDiv>
<StyledDiv>{formula}</StyledDiv>
<IconsWrapper>
<StyledIconButtonBlack
onClick={onEdit}
disabled={!showOptions}
title={t("name_manager_dialog.edit")}
>
<PencilLine size={16} />
</StyledIconButtonBlack>
<StyledIconButtonRed
onClick={onDelete}
disabled={!showOptions}
title={t("name_manager_dialog.delete")}
>
<Trash2 size={16} />
</StyledIconButtonRed>
</IconsWrapper>
</WrappedLine>
<Divider />
</>
);
}
const StyledIconButtonBlack = styled(IconButton)(({ theme }) => ({
color: theme.palette.common.black,
borderRadius: "8px",
"&:hover": {
backgroundColor: theme.palette.grey["50"],
},
}));
const StyledIconButtonRed = styled(IconButton)(({ theme }) => ({
color: theme.palette.error.main,
borderRadius: "8px",
"&:hover": {
backgroundColor: theme.palette.grey["50"],
},
"&.Mui-disabled": {
opacity: 0.6,
color: theme.palette.error.light,
},
}));
const WrappedLine = styled(Box)({
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "12px",
padding: "12px 20px 12px 12px",
});
const StyledDiv = styled("div")(({ theme }) => ({
fontFamily: theme.typography.fontFamily,
fontSize: "12px",
fontWeight: "400",
color: theme.palette.common.black,
width: "100%",
paddingLeft: "8px",
}));
const IconsWrapper = styled(Box)({
display: "flex",
});
export default NamedRangeInactive;

View File

@@ -1 +0,0 @@
export { default } from "./NameManagerDialog";

View File

@@ -0,0 +1,463 @@
import type { DefinedName, Model } from "@ironcalc/wasm";
import {
Box,
FormControl,
FormHelperText,
MenuItem,
Paper,
Select,
styled,
TextField,
} from "@mui/material";
import { t } from "i18next";
import { Check, MousePointerClick, Tag } from "lucide-react";
import { useEffect, useState } from "react";
import { theme } from "../../../theme";
import { getFullRangeToString } from "../../util";
import { Footer, NewButton } from "./NamedRanges";
export interface SaveError {
nameError: string;
formulaError: string;
}
interface EditNamedRangeProps {
name: string;
scope: string;
formula: string;
model: Model;
onSave: (name: string, scope: string, formula: string) => SaveError;
onCancel: () => void;
editingDefinedName: DefinedName | null;
}
// HACK: We are using the text structure of the server error
// to add an error here. This is wrong for several reasons:
// 1. There is no i18n
// 2. Server error messages could change with no warning
export function formatOnSaveError(error: string): SaveError {
if (error.startsWith("Name: ")) {
return { formulaError: "", nameError: error.slice(6) };
} else if (error.startsWith("Formula: ")) {
return { formulaError: error.slice(9), nameError: "" };
} else if (error.startsWith("Scope: ")) {
return { formulaError: "", nameError: error.slice(7) };
}
// Fallback for other errors
return { formulaError: error, nameError: "" };
}
const EditNamedRange = ({
name: initialName,
scope: initialScope,
formula: initialFormula,
onSave,
onCancel,
editingDefinedName,
model,
}: EditNamedRangeProps) => {
const getDefaultName = () => {
if (initialName) return initialName;
let counter = 1;
let defaultName = `Range${counter}`;
const worksheets = model.getWorksheetsProperties();
const scopeIndex = worksheets.findIndex((s) => s.name === initialScope);
const newScope = scopeIndex >= 0 ? scopeIndex : undefined;
const definedNameList = model.getDefinedNameList();
while (
definedNameList.some(
(dn) => dn.name === defaultName && dn.scope === newScope,
)
) {
counter++;
defaultName = `Range${counter}`;
}
return defaultName;
};
const [name, setName] = useState(getDefaultName());
const [scope, setScope] = useState(initialScope);
const [formula, setFormula] = useState(initialFormula);
const [nameError, setNameError] = useState<string>("");
const [formulaError, setFormulaError] = useState<string>("");
const isSelected = (value: string) => scope === value;
// Validate name (format and duplicates)
useEffect(() => {
const worksheets = model.getWorksheetsProperties();
const scopeIndex = worksheets.findIndex((s) => s.name === scope);
const newScope = scopeIndex >= 0 ? scopeIndex : null;
try {
model.isValidDefinedName(name, newScope, formula);
} catch (e) {
const message = (e as Error).message;
if (editingDefinedName && message.includes("already exists")) {
// Allow the same name if it's the one being edited
setNameError("");
setFormulaError("");
return;
}
const { nameError, formulaError } = formatOnSaveError(message);
setNameError(nameError);
setFormulaError(formulaError);
return;
}
setNameError("");
setFormulaError("");
}, [name, scope, formula, model, editingDefinedName]);
const hasAnyError = nameError !== "" || formulaError !== "";
return (
<Container>
<ContentArea>
<HeaderBox>
<HeaderIcon>
<Tag />
</HeaderIcon>
<HeaderBoxText>
{name || t("name_manager_dialog.new_named_range")}
</HeaderBoxText>
</HeaderBox>
<StyledBox>
<FieldWrapper>
<StyledLabel htmlFor="name">
{t("name_manager_dialog.range_name")}
</StyledLabel>
<FormControl fullWidth size="small" error={!!nameError}>
<StyledTextField
autoFocus={true}
id="name"
variant="outlined"
size="small"
margin="none"
placeholder={t("name_manager_dialog.enter_range_name")}
fullWidth
error={!!nameError}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
{nameError && <StyledErrorText>{nameError}</StyledErrorText>}
</FormControl>
</FieldWrapper>
<FieldWrapper>
<StyledLabel htmlFor="scope">
{t("name_manager_dialog.scope_label")}
</StyledLabel>
<FormControl fullWidth size="small">
<StyledSelect
id="scope"
value={scope}
onChange={(event) => {
setScope(event.target.value as string);
}}
renderValue={(value: unknown) => {
const stringValue = value as string;
return stringValue === "[Global]" ? (
<>
<MenuSpan>{t("name_manager_dialog.workbook")}</MenuSpan>
<MenuSpanGrey>{` ${t(
"name_manager_dialog.global",
)}`}</MenuSpanGrey>
</>
) : (
stringValue
);
}}
MenuProps={{
PaperProps: {
component: StyledMenuPaper,
},
anchorOrigin: {
vertical: "bottom",
horizontal: "center",
},
transformOrigin: {
vertical: "top",
horizontal: "center",
},
marginThreshold: 0,
}}
>
<StyledMenuItem value={"[Global]"}>
{isSelected("[Global]") ? <CheckIcon /> : <IconPlaceholder />}
<MenuSpan $selected={isSelected("[Global]")}>
{t("name_manager_dialog.workbook")}
</MenuSpan>
<MenuSpanGrey>{` ${t(
"name_manager_dialog.global",
)}`}</MenuSpanGrey>
</StyledMenuItem>
{model.getWorksheetsProperties().map((option) => (
<StyledMenuItem key={option.name} value={option.name}>
{isSelected(option.name) ? (
<CheckIcon />
) : (
<IconPlaceholder />
)}
<MenuSpan $selected={isSelected(option.name)}>
{option.name}
</MenuSpan>
</StyledMenuItem>
))}
</StyledSelect>
<StyledHelperText>
{t("name_manager_dialog.scope_helper")}
</StyledHelperText>
</FormControl>
</FieldWrapper>
<FieldWrapper>
<LineWrapper>
<StyledLabel htmlFor="formula">
{t("name_manager_dialog.refers_to")}
</StyledLabel>
<MousePointerClick
size={16}
onClick={() => {
const worksheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const selectedView = model.getSelectedView();
const formula = getFullRangeToString(
selectedView,
worksheetNames,
);
setFormula(formula);
}}
/>
</LineWrapper>
<FormControl fullWidth size="small" error={!!formulaError}>
<StyledTextField
id="formula"
variant="outlined"
size="small"
margin="none"
placeholder={t("name_manager_dialog.enter_formula")}
fullWidth
multiline
rows={3}
error={!!formulaError}
value={formula}
onChange={(e) => {
setFormula(e.target.value);
setFormulaError("");
}}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
{formulaError && (
<StyledErrorText>{formulaError}</StyledErrorText>
)}
</FormControl>
</FieldWrapper>
</StyledBox>
</ContentArea>
<Footer>
<NewButton
variant="contained"
color="secondary"
disableElevation
onClick={onCancel}
>
{t("name_manager_dialog.cancel")}
</NewButton>
<NewButton
variant="contained"
disableElevation
disabled={hasAnyError}
startIcon={<Check size={16} />}
onClick={() => {
const error = onSave(name.trim(), scope, formula);
if (error.nameError) {
setNameError(error.nameError);
}
if (error.formulaError) {
setFormulaError(error.formulaError);
}
}}
>
{t("name_manager_dialog.apply")}
</NewButton>
</Footer>
</Container>
);
};
const LineWrapper = styled("div")({
display: "flex",
alignItems: "center",
gap: "8px",
});
const Container = styled("div")({
height: "100%",
display: "flex",
flexDirection: "column",
});
const ContentArea = styled("div")({
flex: 1,
overflow: "auto",
});
const MenuSpan = styled("span")<{ $selected?: boolean }>`
font-size: 12px;
font-family: "Inter";
font-weight: ${(props) => (props.$selected ? "bold" : "normal")};
`;
const MenuSpanGrey = styled("span")`
white-space: pre;
font-size: 12px;
font-family: "Inter";
color: ${theme.palette.grey[400]};
`;
const CheckIcon = () => (
<Check style={{ width: "16px", height: "16px", marginRight: "8px" }} />
);
const IconPlaceholder = styled("div")`
width: 16px;
height: 16px;
margin-right: 8px;
`;
const HeaderBox = styled(Box)`
font-size: 14px;
font-family: "Inter";
font-weight: 600;
width: auto;
gap: 8px;
padding: 24px 12px;
color: ${theme.palette.text.primary};
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
border-bottom: 1px solid ${theme.palette.grey["200"]};
`;
const HeaderBoxText = styled("span")`
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`;
const HeaderIcon = styled(Box)`
width: 28px;
height: 28px;
border-radius: 4px;
background-color: ${theme.palette.grey["100"]};
display: flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
color: ${theme.palette.grey["600"]};
}
`;
const StyledBox = styled(Box)`
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: auto;
padding: 16px 12px;
@media (max-width: 600px) {
padding: 12px;
}
`;
const StyledTextField = styled(TextField)(() => ({
"& .MuiInputBase-root": {
width: "100%",
margin: 0,
fontFamily: "Inter",
fontSize: "12px",
padding: "8px",
},
"& .MuiInputBase-input": {
padding: "0px",
},
"& .MuiInputBase-inputMultiline": {
padding: "0px",
},
}));
const StyledSelect = styled(Select)(() => ({
fontFamily: "Inter",
fontSize: "12px",
"& .MuiSelect-select": {
padding: "8px",
},
}));
const StyledMenuPaper = styled(Paper)(() => ({
padding: 4,
marginTop: "4px",
"&.MuiPaper-root": {
borderRadius: "8px",
},
"& .MuiList-padding": {
padding: 0,
},
"& .MuiList-root": {
padding: 0,
},
}));
const StyledMenuItem = styled(MenuItem)(() => ({
padding: 8,
borderRadius: 4,
display: "flex",
alignItems: "center",
"&.Mui-selected": {
backgroundColor: "transparent",
"&:hover": {
backgroundColor: theme.palette.grey[50],
},
},
"&:hover": {
backgroundColor: theme.palette.grey[50],
},
}));
const FieldWrapper = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
gap: 6px;
`;
const StyledLabel = styled("label")`
font-size: 12px;
font-family: "Inter";
font-weight: 500;
color: ${theme.palette.text.primary};
display: block;
`;
const StyledHelperText = styled(FormHelperText)(() => ({
fontSize: "12px",
fontFamily: "Inter",
color: theme.palette.grey[500],
margin: 0,
marginTop: "6px",
padding: 0,
lineHeight: 1.4,
}));
const StyledErrorText = styled(StyledHelperText)(() => ({
color: theme.palette.error.main,
}));
export default EditNamedRange;

View File

@@ -0,0 +1,586 @@
import type { DefinedName, Model } from "@ironcalc/wasm";
import { Button, styled, Tooltip } from "@mui/material";
import { t } from "i18next";
import {
ArrowLeft,
BookOpen,
PackageOpen,
PencilLine,
Plus,
Trash2,
X,
} from "lucide-react";
import { useState } from "react";
import { theme } from "../../../theme";
import { parseRangeInSheet } from "../../Editor/util";
import EditNamedRange, {
formatOnSaveError,
type SaveError,
} from "./EditNamedRange";
const normalizeRangeString = (range: string): string => {
return range.trim().replace(/['"]/g, "");
};
interface NamedRangesProps {
onClose: () => void;
model: Model;
getSelectedArea: () => string;
onUpdate: () => void;
}
const NamedRanges = ({
onClose,
getSelectedArea,
model,
onUpdate,
}: NamedRangesProps) => {
const [editingDefinedName, setEditingDefinedName] =
useState<DefinedName | null>(null);
const [isCreatingNew, setIsCreatingNew] = useState(false);
const handleListItemClick = (definedName: DefinedName) => {
setEditingDefinedName(definedName);
setIsCreatingNew(false);
};
const handleNewClick = () => {
setIsCreatingNew(true);
setEditingDefinedName(null);
};
const handleCancel = () => {
setEditingDefinedName(null);
setIsCreatingNew(false);
};
const handleSave = (
name: string,
scope: string,
formula: string,
): SaveError => {
const worksheets = model.getWorksheetsProperties();
if (isCreatingNew) {
const scope_index = worksheets.findIndex((s) => s.name === scope);
const newScope = scope_index >= 0 ? scope_index : null;
try {
model.newDefinedName(name, newScope, formula);
setIsCreatingNew(false);
onUpdate();
return {
formulaError: "",
nameError: "",
};
} catch (e) {
if (e instanceof Error) {
return formatOnSaveError(e.message);
}
return { formulaError: "", nameError: `${e}` };
}
} else {
if (!editingDefinedName)
return {
formulaError: "",
nameError: "",
};
const scope_index = worksheets.findIndex((s) => s.name === scope);
const newScope = scope_index >= 0 ? scope_index : null;
try {
model.updateDefinedName(
editingDefinedName.name,
editingDefinedName.scope ?? null,
name,
newScope,
formula,
);
setEditingDefinedName(null);
onUpdate();
return { formulaError: "", nameError: "" };
} catch (e) {
if (e instanceof Error) {
return formatOnSaveError(e.message);
}
return { formulaError: "", nameError: `${e}` };
}
}
};
// Show edit view if a named range is being edited or created
if (editingDefinedName || isCreatingNew) {
let name = "";
let scopeName = "[Global]";
let formula = "";
if (editingDefinedName) {
name = editingDefinedName.name;
const worksheets = model.getWorksheetsProperties();
scopeName =
editingDefinedName.scope != null
? worksheets[editingDefinedName.scope]?.name || "[unknown]"
: "[Global]";
formula = editingDefinedName.formula;
} else if (isCreatingNew) {
formula = getSelectedArea();
}
const headerTitle = isCreatingNew
? t("name_manager_dialog.add_new_range")
: t("name_manager_dialog.edit_range");
return (
<Container>
<EditHeader>
<Tooltip title={t("name_manager_dialog.back_to_list")}>
<IconButtonWrapper
onClick={handleCancel}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleCancel();
}
}}
aria-label={t("name_manager_dialog.back_to_list")}
tabIndex={0}
>
<ArrowLeft />
</IconButtonWrapper>
</Tooltip>
<EditHeaderTitle>{headerTitle}</EditHeaderTitle>
<Tooltip
title={t("right_drawer.close")}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<IconButtonWrapper
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClose();
}
}}
aria-label={t("right_drawer.close")}
tabIndex={0}
>
<X />
</IconButtonWrapper>
</Tooltip>
</EditHeader>
<Content>
<EditNamedRange
name={name}
scope={scopeName}
formula={formula}
onSave={handleSave}
onCancel={handleCancel}
editingDefinedName={editingDefinedName}
model={model}
/>
</Content>
</Container>
);
}
const currentSelectedArea = getSelectedArea();
const definedNameList = model.getDefinedNameList();
const onNameSelected = (formula: string) => {
const range = parseRangeInSheet(model, formula);
if (range) {
const [sheetIndex, rowStart, columnStart, rowEnd, columnEnd] = range;
model.setSelectedSheet(sheetIndex);
model.setSelectedCell(rowStart, columnStart);
model.setSelectedRange(rowStart, columnStart, rowEnd, columnEnd);
}
onUpdate();
};
return (
<Container>
<Header>
<HeaderTitle>{t("name_manager_dialog.title")}</HeaderTitle>
<Tooltip
title={t("right_drawer.close")}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<IconButtonWrapper
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClose();
}
}}
aria-label={t("right_drawer.close")}
tabIndex={0}
>
<X />
</IconButtonWrapper>
</Tooltip>
</Header>
<Content>
{definedNameList.length === 0 ? (
<EmptyStateMessage>
<IconWrapper>
<PackageOpen />
</IconWrapper>
{t("name_manager_dialog.empty_message1")}
<br />
{t("name_manager_dialog.empty_message2")}
</EmptyStateMessage>
) : (
<ListContainer>
{definedNameList.map((definedName) => {
const worksheets = model.getWorksheetsProperties();
const scopeName =
definedName.scope != null
? worksheets[definedName.scope]?.name || "[Unknown]"
: "[Global]";
const isSelected =
currentSelectedArea !== null &&
normalizeRangeString(definedName.formula) ===
normalizeRangeString(currentSelectedArea);
return (
<ListItem
key={`${definedName.name}-${definedName.scope}`}
tabIndex={0}
$isSelected={isSelected}
onClick={() => {
// select the area corresponding to the defined name
const formula = definedName.formula;
const range = parseRangeInSheet(model, formula);
if (range) {
const [
sheetIndex,
rowStart,
columnStart,
rowEnd,
columnEnd,
] = range;
model.setSelectedSheet(sheetIndex);
model.setSelectedCell(rowStart, columnStart);
model.setSelectedRange(
rowStart,
columnStart,
rowEnd,
columnEnd,
);
}
onUpdate();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNameSelected(definedName.formula);
}
}}
>
<ListItemText>
<NameText>{definedName.name}</NameText>
<ScopeText>{scopeName}</ScopeText>
<FormulaText>{definedName.formula}</FormulaText>
</ListItemText>
<IconsWrapper>
<Tooltip title={t("name_manager_dialog.edit")}>
<IconButton
onClick={(e) => {
e.stopPropagation();
handleListItemClick(definedName);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleListItemClick(definedName);
}
}}
aria-label={t("name_manager_dialog.edit")}
tabIndex={0}
>
<PencilLine size={16} />
</IconButton>
</Tooltip>
<Tooltip title={t("name_manager_dialog.delete")}>
<IconButton
onClick={(e) => {
e.stopPropagation();
model.deleteDefinedName(
definedName.name,
definedName.scope ?? null,
);
onUpdate();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
model.deleteDefinedName(
definedName.name,
definedName.scope ?? null,
);
onUpdate();
}
}}
aria-label={t("name_manager_dialog.delete")}
tabIndex={0}
>
<Trash2 size={16} />
</IconButton>
</Tooltip>
</IconsWrapper>
</ListItem>
);
})}
</ListContainer>
)}
</Content>
<Footer>
<HelpLink
href="https://docs.ironcalc.com/web-application/name-manager.html"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen />
{t("name_manager_dialog.help")}
</HelpLink>
<NewButton
variant="contained"
disableElevation
startIcon={<Plus size={16} />}
onClick={handleNewClick}
>
{t("name_manager_dialog.new")}
</NewButton>
</Footer>
</Container>
);
};
const Container = styled("div")({
height: "100%",
display: "flex",
flexDirection: "column",
});
const Content = styled("div")({
flex: 1,
color: theme.palette.grey[700],
lineHeight: "1.5",
overflow: "auto",
});
const ListContainer = styled("div")({
display: "flex",
flexDirection: "column",
});
const ListItem = styled("div")<{ $isSelected: boolean }>(({ $isSelected }) => ({
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "8px 12px",
minHeight: "40px",
boxSizing: "border-box",
borderBottom: `1px solid ${theme.palette.grey[200]}`,
paddingLeft: $isSelected ? "20px" : "12px",
transition: "all 0.2s ease-in-out",
borderLeft: $isSelected
? `3px solid ${theme.palette.primary.main}`
: "3px solid transparent",
"&:hover": {
backgroundColor: theme.palette.grey[50],
paddingLeft: "20px",
},
}));
const ListItemText = styled("div")({
fontSize: "12px",
color: theme.palette.common.black,
fontFamily: theme.typography.fontFamily,
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "2px",
});
const ScopeText = styled("span")({
fontSize: "12px",
color: theme.palette.common.black,
});
const FormulaText = styled("span")({
fontSize: "12px",
color: theme.palette.grey[600],
});
const NameText = styled("span")({
fontSize: "12px",
color: theme.palette.common.black,
fontWeight: 600,
});
const IconsWrapper = styled("div")({
display: "flex",
alignItems: "center",
gap: "2px",
});
const IconButton = styled("div")({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "24px",
height: "24px",
borderRadius: "4px",
backgroundColor: "transparent",
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.grey[200],
},
});
export const Footer = styled("div")`
padding: 8px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: ${theme.palette.grey["600"]};
border-top: 1px solid ${theme.palette.grey["300"]};
gap: 8px;
`;
const HelpLink = styled("a")`
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 400;
font-family: "Inter";
color: ${theme.palette.grey["600"]};
text-decoration: none;
&:hover {
text-decoration: underline;
}
svg {
width: 16px;
height: 16px;
}
`;
export const NewButton = styled(Button)`
text-transform: none;
min-width: fit-content;
font-size: 12px;
&.MuiButton-colorSecondary {
background-color: ${theme.palette.grey[200]};
color: ${theme.palette.grey[700]};
&:hover {
background-color: ${theme.palette.grey[300]};
}
}
`;
const Header = styled("div")({
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: "0 8px",
borderBottom: `1px solid ${theme.palette.grey[300]}`,
});
const HeaderTitle = styled("div")({
width: "100%",
fontSize: "12px",
});
const EditHeader = styled("div")({
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 8px",
gap: "8px",
borderBottom: `1px solid ${theme.palette.grey[300]}`,
});
const EditHeaderTitle = styled("div")({
flex: 1,
fontSize: "12px",
fontWeight: 500,
});
const IconButtonWrapper = styled("div")`
&:hover {
background-color: ${theme.palette.grey["50"]};
}
display: flex;
border-radius: 4px;
height: 24px;
width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
`;
const EmptyStateMessage = styled("div")`
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
height: 100%;
font-size: 12px;
color: ${theme.palette.grey["600"]};
font-family: "Inter";
z-index: 0;
margin: auto 0px;
position: relative;
`;
const IconWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 4px;
background-color: ${theme.palette.grey["100"]};
color: ${theme.palette.grey["600"]};
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
`;
export default NamedRanges;

View File

@@ -0,0 +1,159 @@
import type { Model } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
import { TOOLBAR_HEIGHT } from "../constants";
import NamedRanges from "./NamedRanges/NamedRanges";
const DEFAULT_DRAWER_WIDTH = 360;
const MIN_DRAWER_WIDTH = 300;
const MAX_DRAWER_WIDTH = 500;
interface RightDrawerProps {
isOpen: boolean;
onClose: () => void;
width: number;
onWidthChange: (width: number) => void;
model: Model;
onUpdate: () => void;
getSelectedArea: () => string;
}
const RightDrawer = ({
isOpen,
onClose,
width,
onWidthChange,
getSelectedArea,
model,
onUpdate,
}: RightDrawerProps) => {
const { t } = useTranslation();
const [drawerWidth, setDrawerWidth] = useState(width);
const [isResizing, setIsResizing] = useState(false);
const resizeHandleRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback((e: ReactMouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
useEffect(() => {
if (!isResizing) {
return;
}
// Prevent text selection during resize
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
const handleMouseMove = (e: MouseEvent) => {
const newWidth = window.innerWidth - e.clientX;
const clampedWidth = Math.max(
MIN_DRAWER_WIDTH,
Math.min(MAX_DRAWER_WIDTH, newWidth),
);
setDrawerWidth(clampedWidth);
onWidthChange(clampedWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}, [isResizing, onWidthChange]);
if (!isOpen) {
return null;
}
return (
<DrawerContainer $drawerWidth={drawerWidth}>
<ResizeHandle
ref={resizeHandleRef}
onMouseDown={handleMouseDown}
$isResizing={isResizing}
aria-label={t("right_drawer.resize_drawer")}
/>
<Divider />
<DrawerContent>
<NamedRanges
onClose={onClose}
model={model}
onUpdate={onUpdate}
getSelectedArea={getSelectedArea}
/>
</DrawerContent>
</DrawerContainer>
);
};
type DrawerContainerProps = {
$drawerWidth: number;
};
const DrawerContainer = styled("div")<DrawerContainerProps>(
({ $drawerWidth }) => ({
position: "absolute",
overflow: "hidden",
backgroundColor: theme.palette.common.white,
right: 0,
top: `${TOOLBAR_HEIGHT}px`,
bottom: 0,
borderLeft: `1px solid ${theme.palette.grey[300]}`,
width: `${$drawerWidth}px`,
display: "flex",
flexDirection: "column",
"@media (max-width: 600px)": {
width: "100%",
borderLeft: "none",
top: "0px",
zIndex: 1000,
},
}),
);
const Divider = styled("div")({
height: "1px",
width: "100%",
backgroundColor: theme.palette.grey[300],
margin: "0",
});
const DrawerContent = styled("div")({
flex: 1,
height: "100%",
});
const ResizeHandle = styled("div")<{ $isResizing: boolean }>(
({ $isResizing }) => ({
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "4px",
cursor: "col-resize",
backgroundColor: $isResizing ? theme.palette.primary.main : "transparent",
zIndex: 10,
"&:hover": {
backgroundColor: theme.palette.primary.main,
opacity: 0.5,
},
transition: $isResizing ? "none" : "background-color 0.2s ease",
}),
);
export default RightDrawer;
export { DEFAULT_DRAWER_WIDTH, MIN_DRAWER_WIDTH, MAX_DRAWER_WIDTH };

View File

@@ -101,26 +101,6 @@ const ButtonGroup = styled.div`
width: 100%;
`;
const StyledButton = styled.button`
cursor: pointer;
color: ${theme.palette.common.white};
background-color: ${theme.palette.primary.main};
padding: 0px 10px;
height: 36px;
border-radius: 4px;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
text-overflow: ellipsis;
transition: background-color 150ms;
text-transform: none;
&:hover {
background-color: ${theme.palette.primary.dark};
}
`;
const DeleteButton = styled(Button)`
background-color: ${theme.palette.error.main};
color: ${theme.palette.common.white};

View File

@@ -1,158 +0,0 @@
import { Dialog, TextField, styled } from "@mui/material";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
interface SheetRenameDialogProps {
open: boolean;
onClose: () => void;
onNameChanged: (name: string) => void;
defaultName: string;
}
const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
const { t } = useTranslation();
const [name, setName] = useState(properties.defaultName);
const handleClose = () => {
properties.onClose();
};
return (
<Dialog open={properties.open} onClose={properties.onClose}>
<StyledDialogTitle>
{t("sheet_rename.title")}
<Cross
onClick={handleClose}
title={t("sheet_rename.close")}
tabIndex={0}
onKeyDown={(event) => event.key === "Enter" && properties.onClose()}
>
<X />
</Cross>
</StyledDialogTitle>
<StyledDialogContent>
<StyledTextField
autoFocus
defaultValue={properties.defaultName}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
properties.onNameChanged(name);
properties.onClose();
} else if (event.key === "Escape") {
properties.onClose();
}
}}
onChange={(event) => {
setName(event.target.value);
}}
spellCheck="false"
onPaste={(event) => event.stopPropagation()}
onCopy={(event) => event.stopPropagation()}
onCut={(event) => event.stopPropagation()}
/>
</StyledDialogContent>
<DialogFooter>
<StyledButton
onClick={() => {
properties.onNameChanged(name);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
properties.onNameChanged(name);
properties.onClose();
}
}}
tabIndex={0}
>
<Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
{t("sheet_rename.rename")}
</StyledButton>
</DialogFooter>
</Dialog>
);
};
const StyledDialogTitle = styled("div")`
display: flex;
align-items: center;
height: 44px;
font-size: 14px;
font-weight: 500;
font-family: Inter;
padding: 0px 12px;
justify-content: space-between;
border-bottom: 1px solid ${theme.palette.grey["300"]};
`;
const Cross = styled("div")`
&:hover {
background-color: ${theme.palette.grey["50"]};
}
display: flex;
border-radius: 4px;
height: 24px;
width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
`;
const StyledDialogContent = styled("div")`
font-size: 12px;
margin: 12px;
`;
const StyledTextField = styled(TextField)`
width: 100%;
border-radius: 4px;
overflow: hidden;
& .MuiInputBase-input {
font-size: 14px;
padding: 10px;
border: 1px solid ${theme.palette.grey["300"]};
border-radius: 4px;
color: ${theme.palette.common.black};
background-color: ${theme.palette.common.white};
}
&:hover .MuiInputBase-input {
border: 1px solid ${theme.palette.grey["500"]};
}
`;
const DialogFooter = styled("div")`
color: #757575;
display: flex;
align-items: center;
border-top: 1px solid ${theme.palette.grey["300"]};
font-family: Inter;
justify-content: flex-end;
padding: 12px;
`;
const StyledButton = styled("div")`
cursor: pointer;
color: #ffffff;
background: #f2994a;
padding: 0px 10px;
height: 36px;
line-height: 36px;
border-radius: 4px;
display: flex;
align-items: center;
font-family: "Inter";
font-size: 14px;
&:hover {
background: #d68742;
}
`;
export default SheetRenameDialog;

View File

@@ -1,5 +1,5 @@
import { Button, Menu, MenuItem, styled } from "@mui/material";
import type { MenuItemProps } from "@mui/material";
import { Button, Input, Menu, MenuItem, styled } from "@mui/material";
import {
ChevronDown,
EyeOff,
@@ -7,14 +7,13 @@ import {
TextCursorInput,
Trash2,
} from "lucide-react";
import { useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
import ColorPicker from "../ColorPicker/ColorPicker";
import { isInReferenceMode } from "../Editor/util";
import type { WorkbookState } from "../workbookState";
import SheetDeleteDialog from "./SheetDeleteDialog";
import SheetRenameDialog from "./SheetRenameDialog";
interface SheetTabProps {
name: string;
@@ -31,24 +30,22 @@ interface SheetTabProps {
function SheetTab(props: SheetTabProps) {
const { name, color, selected, workbookState, onSelected } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorButton = useRef<HTMLDivElement>(null);
const open = Boolean(anchorEl);
const { t } = useTranslation();
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const handleCloseRenameDialog = () => {
setRenameDialogOpen(false);
};
const handleOpenRenameDialog = () => {
setRenameDialogOpen(true);
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
onSelected();
setAnchorEl(event.currentTarget);
};
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -60,18 +57,74 @@ function SheetTab(props: SheetTabProps) {
const handleCloseDeleteDialog = () => {
setDeleteDialogOpen(false);
};
const [isEditing, setIsEditing] = useState(false);
const [editingName, setEditingName] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const [inputWidth, setInputWidth] = useState<number>(0);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
if (!isEditing) {
setEditingName(name);
}
}, [name, isEditing]);
// We want to change the layout only when editingName changes, but the layout is controlled by the hidden measure element (measureRef).
// biome-ignore lint/correctness/useExhaustiveDependencies: false
useLayoutEffect(() => {
if (isEditing && measureRef.current) {
const width = measureRef.current.offsetWidth;
setInputWidth(Math.max(width + 8, 6));
}
}, [editingName, isEditing]);
const handleStartEditing = () => {
setEditingName(name);
setInputWidth(Math.max(name.length * 7 + 8, 6));
setIsEditing(true);
};
const handleSave = () => {
if (editingName.trim() !== "") {
props.onRenamed(editingName.trim());
setIsEditing(false);
} else {
setEditingName(name);
setIsEditing(false);
}
};
const handleCancel = () => {
setEditingName(name);
setIsEditing(false);
};
return (
<>
<TabWrapper
$color={color}
$selected={selected}
onClick={(event: React.MouseEvent) => {
onSelected();
if (!isEditing) {
onSelected();
}
event.stopPropagation();
event.preventDefault();
}}
onDoubleClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
handleStartEditing();
}}
onContextMenu={handleContextMenu}
onPointerDown={(event: React.PointerEvent) => {
// If it is in browse mode stop he event
const cell = workbookState.getEditingCell();
if (cell && isInReferenceMode(cell.text, cell.cursorStart)) {
event.stopPropagation();
@@ -80,10 +133,42 @@ function SheetTab(props: SheetTabProps) {
}}
ref={colorButton}
>
<Name onDoubleClick={handleOpenRenameDialog}>{name}</Name>
<StyledButton onClick={handleOpen}>
<ChevronDown />
</StyledButton>
{isEditing ? (
<>
<HiddenMeasure ref={measureRef}>{editingName || " "}</HiddenMeasure>
<StyledInput
inputRef={inputRef}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
style={{ width: `${inputWidth}px` }}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
e.stopPropagation();
}}
onBlur={() => {
handleSave();
}}
onClick={(e) => e.stopPropagation()}
spellCheck={false}
/>
<StyledButton disableRipple disabled $active={false}>
<ChevronDown />
</StyledButton>
</>
) : (
<>
<Name>{name}</Name>
<StyledButton onClick={handleOpen} disableRipple $active={open}>
<ChevronDown />
</StyledButton>
</>
)}
</TabWrapper>
<StyledMenu
anchorEl={anchorEl}
@@ -100,7 +185,7 @@ function SheetTab(props: SheetTabProps) {
>
<StyledMenuItem
onClick={() => {
handleOpenRenameDialog();
handleStartEditing();
handleClose();
}}
>
@@ -138,15 +223,6 @@ function SheetTab(props: SheetTabProps) {
{t("sheet_tab.delete")}
</DeleteButton>
</StyledMenu>
<SheetRenameDialog
open={renameDialogOpen}
onClose={handleCloseRenameDialog}
defaultName={name}
onNameChanged={(newName) => {
props.onRenamed(newName);
setRenameDialogOpen(false);
}}
/>
<ColorPicker
color={color}
defaultColor="#FFFFFF"
@@ -213,34 +289,41 @@ const TabWrapper = styled("div")<{ $color: string; $selected: boolean }>`
margin-right: 12px;
border-bottom: 3px solid ${(props) => props.$color};
line-height: 37px;
padding: 0px 4px;
padding: 0px 4px 0px 6px;
align-items: center;
cursor: pointer;
min-width: 40px;
font-weight: ${(props) => (props.$selected ? 600 : 400)};
background-color: ${(props) =>
props.$selected ? `${theme.palette.grey[50]}80` : "transparent"};
props.$selected ? `${theme.palette.grey[50]}` : "transparent"};
&:hover {
background-color: ${theme.palette.grey[50]}80;
}
`;
const StyledButton = styled(Button)`
width: 15px;
height: 24px;
const StyledButton = styled(Button)<{ $active: boolean }>`
width: 16px;
height: 16px;
min-width: 0px;
padding: 0px;
color: inherit;
font-weight: inherit;
border-radius: 4px;
flex-shrink: 0;
background-color: ${(props) =>
props.$active ? `${theme.palette.grey[300]}` : "transparent"};
&:hover {
background-color: transparent;
background-color: ${theme.palette.grey[200]};
}
&:active {
background-color: transparent;
background-color: ${theme.palette.grey[300]};
}
&:disabled {
pointer-events: none;
}
svg {
width: 15px;
height: 15px;
transition: transform 0.2s;
}
&:hover svg {
transform: translateY(2px);
width: 14px;
height: 14px;
}
`;
@@ -249,6 +332,51 @@ const Name = styled("div")`
margin-right: 5px;
text-wrap: nowrap;
user-select: none;
width: 100%;
text-align: center;
`;
const HiddenMeasure = styled("span")`
position: absolute;
visibility: hidden;
white-space: pre;
font-size: 12px;
font-family: Inter;
font-weight: inherit;
padding: 0;
margin: 0;
height: 100%;
overflow: hidden;
pointer-events: none;
`;
const StyledInput = styled(Input)`
font-size: 12px;
font-family: Inter;
font-weight: inherit;
min-width: 6px;
margin-right: 2px;
min-height: 100%;
flex-grow: 1;
& .MuiInputBase-input {
font-family: Inter;
background-color: ${theme.palette.common.white};
font-weight: inherit;
padding: 6px 0px;
border: 1px solid ${theme.palette.primary.main};
border-radius: 4px;
color: ${theme.palette.common.black};
text-align: center;
will-change: width;
&:focus {
border-color: ${theme.palette.primary.main};
}
}
&::before,
&::after {
display: none;
}
`;
const MenuDivider = styled("div")`

View File

@@ -1,12 +1,12 @@
import { styled } from "@mui/material";
import { Tooltip } from "@mui/material";
import { Menu, Plus } from "lucide-react";
import { styled, Tooltip } from "@mui/material";
import { EllipsisVertical, Menu, Plus } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IronCalcLogo } from "../../icons";
import { theme } from "../../theme";
import { StyledButton } from "../Toolbar/Toolbar";
import { NAVIGATION_HEIGHT } from "../constants";
import { StyledButton } from "../Toolbar/Toolbar";
import WorkbookSettingsDialog from "../WorkbookSettings/WorkbookSettingsDialog";
import type { WorkbookState } from "../workbookState";
import SheetListMenu from "./SheetListMenu";
import SheetTab from "./SheetTab";
@@ -22,12 +22,16 @@ export interface SheetTabBarProps {
onSheetRenamed: (name: string) => void;
onSheetDeleted: () => void;
onHideSheet: () => void;
onOpenWorkbookSettings: () => void;
initialLocale: string;
initialTimezone: string;
}
function SheetTabBar(props: SheetTabBarProps) {
const { t } = useTranslation();
const { workbookState, onSheetSelected, sheets, selectedIndex } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [workbookSettingsOpen, setWorkbookSettingsOpen] = useState(false);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
@@ -96,6 +100,17 @@ function SheetTabBar(props: SheetTabBarProps) {
<IronCalcLogo />
</LogoLink>
</Tooltip>
<Tooltip title={t("workbook_settings.open_settings")}>
<StyledButton
$pressed={false}
onClick={() => {
setWorkbookSettingsOpen(true);
props.onOpenWorkbookSettings();
}}
>
<EllipsisVertical />
</StyledButton>
</Tooltip>
</RightContainer>
<SheetListMenu
anchorEl={anchorEl}
@@ -108,6 +123,12 @@ function SheetTabBar(props: SheetTabBarProps) {
}}
selectedIndex={selectedIndex}
/>
<WorkbookSettingsDialog
open={workbookSettingsOpen}
onClose={() => setWorkbookSettingsOpen(false)}
initialLocale={props.initialLocale}
initialTimezone={props.initialTimezone}
/>
</Container>
);
}
@@ -138,6 +159,7 @@ const Sheets = styled("div")`
padding-left: 12px;
display: flex;
flex-direction: row;
height: 100%;
`;
const SheetInner = styled("div")`
@@ -170,22 +192,28 @@ const RightContainer = styled("a")`
color: ${theme.palette.primary.main};
height: 100%;
padding: 0px 8px;
@media (max-width: 769px) {
display: none;
}
gap: 4px;
`;
const LogoLink = styled("div")`
display: flex;
align-items: center;
padding: 8px;
padding: 0px 4px;
border-radius: 4px;
max-height: 24px;
min-height: 24px;
cursor: pointer;
svg {
height: 14px;
width: auto;
}
&:hover {
background-color: ${theme.palette.grey["100"]};
transition: "all 0.2s";
outline: 1px solid ${theme.palette.grey["200"]};
}
@media (max-width: 769px) {
display: none;
}
`;

View File

@@ -4,8 +4,8 @@ import type {
HorizontalAlignment,
VerticalAlignment,
} from "@ironcalc/wasm";
import Tooltip from "@mui/material/Tooltip";
import { styled } from "@mui/material/styles";
import Tooltip from "@mui/material/Tooltip";
import type {} from "@mui/system";
import {
AlignCenter,
@@ -45,15 +45,13 @@ import { ArrowMiddleFromLine } from "../../icons";
import { theme } from "../../theme";
import BorderPicker from "../BorderPicker/BorderPicker";
import ColorPicker from "../ColorPicker/ColorPicker";
import { TOOLBAR_HEIGHT } from "../constants";
import FormatMenu from "../FormatMenu/FormatMenu";
import {
NumberFormats,
decreaseDecimalPlaces,
increaseDecimalPlaces,
NumberFormats,
} from "../FormatMenu/formatUtil";
import NameManagerDialog from "../NameManagerDialog";
import type { NameManagerProperties } from "../NameManagerDialog/NameManagerDialog";
import { TOOLBAR_HEIGHT } from "../constants";
type ToolbarProperties = {
canUndo: boolean;
@@ -89,14 +87,13 @@ type ToolbarProperties = {
numFmt: string;
showGridLines: boolean;
onToggleShowGridLines: (show: boolean) => void;
nameManagerProperties: NameManagerProperties;
openDrawer: () => void;
};
function Toolbar(properties: ToolbarProperties) {
const [fontColorPickerOpen, setFontColorPickerOpen] = useState(false);
const [fillColorPickerOpen, setFillColorPickerOpen] = useState(false);
const [borderPickerOpen, setBorderPickerOpen] = useState(false);
const [nameManagerDialogOpen, setNameManagerDialogOpen] = useState(false);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
@@ -517,12 +514,12 @@ function Toolbar(properties: ToolbarProperties) {
{properties.showGridLines ? <Grid2x2Check /> : <Grid2x2X />}
</StyledButton>
</Tooltip>
<Tooltip title={t("toolbar.name_manager")}>
<Tooltip title={t("toolbar.named_ranges")}>
<StyledButton
type="button"
$pressed={false}
onClick={() => {
setNameManagerDialogOpen(true);
properties.openDrawer();
}}
disabled={!canEdit}
>
@@ -587,13 +584,6 @@ function Toolbar(properties: ToolbarProperties) {
anchorEl={borderButton}
open={borderPickerOpen}
/>
<NameManagerDialog
open={nameManagerDialogOpen}
onClose={() => {
setNameManagerDialogOpen(false);
}}
model={properties.nameManagerProperties}
/>
</ToolbarContainer>
{showRightArrow && (
<Tooltip

View File

@@ -6,9 +6,20 @@ import type {
} from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
import { useCallback, useEffect, useRef, useState } from "react";
import {
CLIPBOARD_ID_SESSION_STORAGE_KEY,
getNewClipboardId,
} from "../clipboard";
import { TOOLBAR_HEIGHT } from "../constants";
import FormulaBar from "../FormulaBar/FormulaBar";
import RightDrawer, { DEFAULT_DRAWER_WIDTH } from "../RightDrawer/RightDrawer";
import SheetTabBar from "../SheetTabBar";
import Toolbar from "../Toolbar/Toolbar";
import {
getCellAddress,
getFullRangeToString,
type NavigationKey,
} from "../util";
import Worksheet from "../Worksheet/Worksheet";
import {
COLUMN_WIDTH_SCALE,
@@ -18,15 +29,6 @@ import {
} from "../WorksheetCanvas/constants";
import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import { devicePixelRatio } from "../WorksheetCanvas/worksheetCanvas";
import {
CLIPBOARD_ID_SESSION_STORAGE_KEY,
getNewClipboardId,
} from "../clipboard";
import {
type NavigationKey,
getCellAddress,
getFullRangeToString,
} from "../util";
import type { WorkbookState } from "../workbookState";
import useKeyboardNavigation from "./useKeyboardNavigation";
@@ -41,6 +43,9 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
// This is needed because `model` or `workbookState` can change without React being aware of it
const setRedrawId = useState(0)[1];
const [isDrawerOpen, setDrawerOpen] = useState(false);
const [drawerWidth, setDrawerWidth] = useState(DEFAULT_DRAWER_WIDTH);
const worksheets = model.getWorksheetsProperties();
const info = worksheets.map(
({ name, color, sheet_id, state }: WorksheetProperties) => {
@@ -660,109 +665,109 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
model.setShowGridLines(sheet, show);
setRedrawId((id) => id + 1);
}}
nameManagerProperties={{
newDefinedName: (
name: string,
scope: number | undefined,
formula: string,
) => {
model.newDefinedName(name, scope, formula);
openDrawer={() => {
setDrawerOpen(true);
}}
/>
<WorksheetAreaLeft $drawerWidth={isDrawerOpen ? drawerWidth : 0}>
<FormulaBar
cellAddress={cellAddress()}
formulaValue={formulaValue()}
onChange={() => {
setRedrawId((id) => id + 1);
},
updateDefinedName: (
name: string,
scope: number | undefined,
newName: string,
newScope: number | undefined,
newFormula: string,
) => {
model.updateDefinedName(name, scope, newName, newScope, newFormula);
focusWorkbook();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1);
},
deleteDefinedName: (name: string, scope: number | undefined) => {
model.deleteDefinedName(name, scope);
}}
model={model}
workbookState={workbookState}
/>
<Worksheet
model={model}
workbookState={workbookState}
refresh={(): void => {
setRedrawId((id) => id + 1);
},
selectedArea: () => {
const worksheetNames = worksheets.map((s) => s.name);
const selectedView = model.getSelectedView();
}}
ref={worksheetRef}
/>
return getFullRangeToString(selectedView, worksheetNames);
},
worksheets,
definedNameList: model.getDefinedNameList(),
}}
/>
<FormulaBar
cellAddress={cellAddress()}
formulaValue={formulaValue()}
onChange={() => {
setRedrawId((id) => id + 1);
focusWorkbook();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1);
}}
model={model}
workbookState={workbookState}
/>
<Worksheet
model={model}
workbookState={workbookState}
refresh={(): void => {
setRedrawId((id) => id + 1);
}}
ref={worksheetRef}
/>
<SheetTabBar
sheets={info}
selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}
onSheetSelected={(sheet: number): void => {
if (info[sheet].state !== "visible") {
model.unhideSheet(sheet);
}
model.setSelectedSheet(sheet);
setRedrawId((value) => value + 1);
}}
onAddBlankSheet={(): void => {
model.newSheet();
setRedrawId((value) => value + 1);
}}
onSheetColorChanged={(hex: string): void => {
try {
model.setSheetColor(model.getSelectedSheet(), hex);
<SheetTabBar
sheets={info}
selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}
onSheetSelected={(sheet: number): void => {
if (info[sheet].state !== "visible") {
model.unhideSheet(sheet);
}
model.setSelectedSheet(sheet);
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetRenamed={(name: string): void => {
try {
model.renameSheet(model.getSelectedSheet(), name);
}}
onAddBlankSheet={(): void => {
model.newSheet();
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetColorChanged={(hex: string): void => {
try {
model.setSheetColor(model.getSelectedSheet(), hex);
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetRenamed={(name: string): void => {
try {
model.renameSheet(model.getSelectedSheet(), name);
setRedrawId((value) => value + 1);
} catch (e) {
// TODO: Show a proper modal dialog
alert(`${e}`);
}
}}
onSheetDeleted={(): void => {
const selectedSheet = model.getSelectedSheet();
model.deleteSheet(selectedSheet);
setRedrawId((value) => value + 1);
}}
onHideSheet={(): void => {
const selectedSheet = model.getSelectedSheet();
model.hideSheet(selectedSheet);
setRedrawId((value) => value + 1);
}}
/>
</WorksheetAreaLeft>
<RightDrawer
isOpen={isDrawerOpen}
onClose={() => setDrawerOpen(false)}
width={drawerWidth}
onWidthChange={setDrawerWidth}
model={model}
onUpdate={() => {
setRedrawId((id) => id + 1);
}}
onSheetDeleted={(): void => {
const selectedSheet = model.getSelectedSheet();
model.deleteSheet(selectedSheet);
setRedrawId((value) => value + 1);
}}
onHideSheet={(): void => {
const selectedSheet = model.getSelectedSheet();
model.hideSheet(selectedSheet);
setRedrawId((value) => value + 1);
getSelectedArea={() => {
const worksheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const selectedView = model.getSelectedView();
return getFullRangeToString(selectedView, worksheetNames);
}}
/>
</Container>
);
};
type WorksheetAreaLeftProps = { $drawerWidth: number };
const WorksheetAreaLeft = styled("div")<WorksheetAreaLeftProps>(
({ $drawerWidth }) => ({
position: "absolute",
top: `${TOOLBAR_HEIGHT + 1}px`,
width: `calc(100% - ${$drawerWidth}px)`,
height: `calc(100% - ${TOOLBAR_HEIGHT + 1}px)`,
}),
);
const Container = styled("div")`
display: flex;
flex-direction: column;

View File

@@ -1,5 +1,5 @@
import { type KeyboardEvent, type RefObject, useCallback } from "react";
import { type NavigationKey, isEditingKey, isNavigationKey } from "../util";
import { isEditingKey, isNavigationKey, type NavigationKey } from "../util";
export enum Border {
Top = "top",

View File

@@ -0,0 +1,429 @@
import styled from "@emotion/styled";
import {
Autocomplete,
Box,
Dialog,
FormControl,
MenuItem,
Select,
TextField,
} from "@mui/material";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
type WorkbookSettingsDialogProps = {
open: boolean;
onClose: () => void;
initialLocale: string;
initialTimezone: string;
onSave?: (locale: string, timezone: string) => void;
};
const WorkbookSettingsDialog = (properties: WorkbookSettingsDialogProps) => {
const { t } = useTranslation();
const locales = ["en-US", "en-GB", "de-DE", "fr-FR", "es-ES"];
const timezones = [
"Berlin, Germany (GMT+1)",
"New York, USA (GMT-5)",
"Tokyo, Japan (GMT+9)",
"London, UK (GMT+0)",
"Sydney, Australia (GMT+10)",
];
const [selectedLocale, setSelectedLocale] = useState<string>(
properties.initialLocale && locales.includes(properties.initialLocale)
? properties.initialLocale
: locales[0],
);
const [selectedTimezone, setSelectedTimezone] = useState<string>(
properties.initialTimezone && timezones.includes(properties.initialTimezone)
? properties.initialTimezone
: timezones[0],
);
const handleSave = () => {
if (properties.onSave && selectedLocale && selectedTimezone) {
properties.onSave(selectedLocale, selectedTimezone);
}
properties.onClose();
};
// Ensure selectedLocale is always a valid locale
const validSelectedLocale =
selectedLocale && locales.includes(selectedLocale)
? selectedLocale
: locales[0];
// Ensure selectedTimezone is always a valid timezone
const validSelectedTimezone =
selectedTimezone && timezones.includes(selectedTimezone)
? selectedTimezone
: timezones[0];
return (
<StyledDialog
open={properties.open}
onClose={(_event, reason) => {
if (reason === "backdropClick" || reason === "escapeKeyDown") {
properties.onClose();
}
}}
>
<StyledDialogTitle>
{t("workbook_settings.title")}
<Cross
onClick={properties.onClose}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter") {
properties.onClose();
}
}}
>
<X />
</Cross>
</StyledDialogTitle>
<StyledDialogContent
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<StyledSectionTitle>
{t("workbook_settings.locale_and_timezone.title")}
</StyledSectionTitle>
<FieldWrapper>
<StyledLabel htmlFor="locale">
{t("workbook_settings.locale_and_timezone.locale_label")}
</StyledLabel>
<FormControl fullWidth>
<StyledSelect
id="locale"
value={validSelectedLocale}
onChange={(event) => {
setSelectedLocale(event.target.value as string);
}}
MenuProps={{
PaperProps: {
sx: menuPaperStyles,
},
TransitionProps: {
timeout: 0,
},
}}
>
{locales.map((locale) => (
<StyledMenuItem
key={locale}
value={locale}
$isSelected={locale === selectedLocale}
>
{locale}
</StyledMenuItem>
))}
</StyledSelect>
<HelperBox>
<Row>
{t("workbook_settings.locale_and_timezone.locale_example1")}
<RowValue>1,234.56</RowValue>
</Row>
<Row>
{t("workbook_settings.locale_and_timezone.locale_example2")}
<RowValue>12/31/2025</RowValue>
</Row>
<Row>
{t("workbook_settings.locale_and_timezone.locale_example3")}
<RowValue>11/23/2025 09:21:06 PM</RowValue>
</Row>
<Row>
{t("workbook_settings.locale_and_timezone.locale_example4")}
<RowValue>Monday</RowValue>
</Row>
</HelperBox>
</FormControl>
</FieldWrapper>
<FieldWrapper>
<StyledLabel htmlFor="timezone">
{t("workbook_settings.locale_and_timezone.timezone_label")}
</StyledLabel>
<FormControl fullWidth>
<StyledAutocomplete
id="timezone"
value={validSelectedTimezone}
onChange={(_event, newValue) => {
setSelectedTimezone((newValue as string) || "");
}}
options={timezones}
renderInput={(params) => <TextField {...params} />}
renderOption={(props, option) => (
<StyledMenuItem
{...props}
key={option as string}
$isSelected={option === validSelectedTimezone}
>
{option as string}
</StyledMenuItem>
)}
disableClearable
slotProps={{
paper: {
sx: menuPaperStyles,
},
popper: {
sx: {
"& .MuiAutocomplete-paper": {
transition: "none !important",
},
},
},
popupIndicator: {
disableRipple: true,
},
}}
/>
<HelperBox>
<Row>
{t("workbook_settings.locale_and_timezone.timezone_example1")}
<RowValue>23/11/2025</RowValue>
</Row>
<Row>
{t("workbook_settings.locale_and_timezone.timezone_example2")}
<RowValue>11/23/2025 09:21:06 PM</RowValue>
</Row>
</HelperBox>
</FormControl>
</FieldWrapper>
</StyledDialogContent>
<DialogFooter>
<StyledButton onClick={handleSave} tabIndex={0}>
<Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
{t("num_fmt.save")}
</StyledButton>
</DialogFooter>
</StyledDialog>
);
};
const StyledDialog = styled(Dialog)`
& .MuiPaper-root {
max-width: 320px;
width: 320px;
min-width: 280px;
border-radius: 8px;
padding: 0px;
}
`;
const StyledDialogTitle = styled("div")`
display: flex;
align-items: center;
height: 44px;
font-size: 14px;
font-weight: 500;
font-family: Inter;
padding: 0px 12px;
justify-content: space-between;
border-bottom: 1px solid ${theme.palette.grey["300"]};
`;
const Cross = styled("div")`
&:hover {
background-color: ${theme.palette.grey["50"]};
}
display: flex;
border-radius: 4px;
height: 24px;
width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
`;
const StyledDialogContent = styled("div")`
display: flex;
flex-direction: column;
gap: 12px;
font-size: 12px;
margin: 12px;
`;
const StyledSectionTitle = styled("h1")`
font-size: 14px;
font-weight: 600;
font-family: Inter;
margin: 0px;
color: ${theme.palette.text.primary};
`;
const StyledSelect = styled(Select)`
font-size: 12px;
height: 32px;
& .MuiInputBase-root {
padding: 0px !important;
}
& .MuiInputBase-input {
font-size: 12px;
height: 20px;
padding-right: 0px !important;
margin: 0px;
}
& .MuiSelect-select {
padding: 8px 32px 8px 8px !important;
font-size: 12px;
}
& .MuiSvgIcon-root {
right: 4px !important;
}
`;
const HelperBox = styled("div")`
display: flex;
flex-direction: column;
align-items: start;
justify-content: center;
gap: 2px;
box-sizing: border-box;
border: 1px solid ${theme.palette.grey["300"]};
font-family: Inter;
width: 100%;
height: 100%;
margin-top: 8px;
background-color: ${theme.palette.grey["100"]};
border-radius: 4px;
padding: 8px;
`;
const Row = styled("div")`
display: flex;
flex-direction: row;
gap: 4px;
width: 100%;
justify-content: space-between;
color: ${theme.palette.grey[700]};
`;
const RowValue = styled("span")`
font-size: 12px;
font-family: Inter;
font-weight: normal;
color: ${theme.palette.grey[500]};
`;
const StyledAutocomplete = styled(Autocomplete)`
& .MuiInputBase-root {
padding: 0px !important;
height: 32px;
}
& .MuiInputBase-input {
font-size: 12px;
height: 20px;
padding: 0px;
padding-right: 0px !important;
margin: 0px;
}
& .MuiAutocomplete-popupIndicator:hover {
background-color: transparent !important;
}
& .MuiAutocomplete-popupIndicator {
& .MuiTouchRipple-root {
display: none;
}
}
& .MuiOutlinedInput-root .MuiAutocomplete-endAdornment {
right: 4px;
}
& .MuiOutlinedInput-root .MuiAutocomplete-input {
padding: 8px !important;
}
`;
const menuPaperStyles = {
boxSizing: "border-box",
marginTop: "4px",
padding: "4px",
borderRadius: "8px",
transition: "none !important",
"& .MuiList-padding": {
padding: 0,
},
"& .MuiList-root": {
padding: 0,
},
"& .MuiAutocomplete-noOptions": {
padding: "8px",
fontSize: "12px",
fontFamily: "Inter",
},
"& .MuiMenuItem-root": {
height: "32px !important",
padding: "8px !important",
minHeight: "32px !important",
},
};
const StyledMenuItem = styled(MenuItem)<{ $isSelected?: boolean }>`
padding: 8px !important;
height: 32px !important;
min-height: 32px !important;
border-radius: 4px;
display: flex;
align-items: center;
font-size: 12px;
background-color: ${({ $isSelected }) =>
$isSelected ? theme.palette.grey[50] : "transparent"} !important;
&:hover {
background-color: ${theme.palette.grey[50]} !important;
}
`;
const FieldWrapper = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
gap: 6px;
`;
const StyledLabel = styled("label")`
font-size: 12px;
font-family: "Inter";
font-weight: 500;
color: ${theme.palette.text.primary};
display: block;
`;
const DialogFooter = styled("div")`
color: ${theme.palette.grey[700]};
display: flex;
align-items: center;
border-top: 1px solid ${theme.palette.grey["300"]};
font-family: Inter;
justify-content: flex-end;
padding: 12px;
`;
const StyledButton = styled("div")`
cursor: pointer;
color: ${theme.palette.common.white};
background: ${theme.palette.primary.main};
padding: 0px 10px;
height: 36px;
line-height: 36px;
border-radius: 4px;
display: flex;
align-items: center;
font-family: "Inter";
font-size: 14px;
&:hover {
background: ${theme.palette.primary.dark};
}
`;
export default WorkbookSettingsDialog;

View File

@@ -1,4 +1,4 @@
import { type Model, columnNameFromNumber } from "@ironcalc/wasm";
import { columnNameFromNumber, type Model } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
import {
forwardRef,
@@ -8,22 +8,18 @@ import {
useRef,
useState,
} from "react";
import { FORMULA_BAR_HEIGHT, NAVIGATION_HEIGHT } from "../constants";
import Editor from "../Editor/Editor";
import type { Cell } from "../types";
import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
LAST_ROW,
ROW_HEIGH_SCALE,
outlineBackgroundColor,
outlineColor,
ROW_HEIGH_SCALE,
} from "../WorksheetCanvas/constants";
import WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import {
FORMULA_BAR_HEIGHT,
NAVIGATION_HEIGHT,
TOOLBAR_HEIGHT,
} from "../constants";
import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState";
import CellContextMenu from "./CellContextMenu";
import usePointer from "./usePointer";
@@ -459,7 +455,7 @@ const SheetContainer = styled("div")`
const Wrapper = styled("div")({
position: "absolute",
overflow: "scroll",
top: TOOLBAR_HEIGHT + FORMULA_BAR_HEIGHT + 1,
top: FORMULA_BAR_HEIGHT + 1,
left: 0,
right: 0,
bottom: NAVIGATION_HEIGHT + 1,

View File

@@ -1,13 +1,13 @@
import type { Model } from "@ironcalc/wasm";
import { type PointerEvent, type RefObject, useCallback, useRef } from "react";
import { isInReferenceMode } from "../Editor/util";
import type { Cell } from "../types";
import { rangeToStr } from "../util";
import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import {
headerColumnWidth,
headerRowHeight,
} from "../WorksheetCanvas/worksheetCanvas";
import type { Cell } from "../types";
import { rangeToStr } from "../util";
import type { WorkbookState } from "../workbookState";
interface PointerSettings {

View File

@@ -5,9 +5,6 @@ import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState";
import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
LAST_ROW,
ROW_HEIGH_SCALE,
cellPadding,
defaultTextColor,
gridColor,
@@ -17,7 +14,10 @@ import {
headerSelectedBackground,
headerSelectedColor,
headerTextColor,
LAST_COLUMN,
LAST_ROW,
outlineColor,
ROW_HEIGH_SCALE,
} from "./constants";
import { attachOutlineHandle } from "./outlineHandle";
import { computeWrappedLines, hexToRGBA10Percent } from "./util";

View File

@@ -1,5 +1,5 @@
import { readFile } from "node:fs/promises";
import { Model, initSync } from "@ironcalc/wasm";
import { initSync, Model } from "@ironcalc/wasm";
import { expect, test } from "vitest";
// This is a simple test that showcases how to load the wasm module in the tests

View File

@@ -1,5 +1,5 @@
import { readFile } from "node:fs/promises";
import { type SelectedView, initSync } from "@ironcalc/wasm";
import { initSync, type SelectedView } from "@ironcalc/wasm";
import { expect, test } from "vitest";
import {
decreaseDecimalPlaces,

View File

@@ -1,10 +1,9 @@
import type { Area, Cell } from "./types";
import {
type SelectedView,
columnNameFromNumber,
quoteName,
type SelectedView,
} from "@ironcalc/wasm";
import type { Area, Cell } from "./types";
import { LAST_COLUMN, LAST_ROW } from "./WorksheetCanvas/constants";
/**

View File

@@ -1,3 +1,4 @@
import ArrowMiddleFromLine from "./arrow-middle-from-line.svg?react";
import BorderBottomIcon from "./border-bottom.svg?react";
import BorderCenterHIcon from "./border-center-h.svg?react";
import BorderCenterVIcon from "./border-center-v.svg?react";
@@ -8,21 +9,17 @@ import BorderOuterIcon from "./border-outer.svg?react";
import BorderRightIcon from "./border-right.svg?react";
import BorderStyleIcon from "./border-style.svg?react";
import BorderTopIcon from "./border-top.svg?react";
import ArrowMiddleFromLine from "./arrow-middle-from-line.svg?react";
import DeleteColumnIcon from "./delete-column.svg?react";
import DeleteRowIcon from "./delete-row.svg?react";
import Fx from "./fx.svg?react";
import InsertColumnLeftIcon from "./insert-column-left.svg?react";
import InsertColumnRightIcon from "./insert-column-right.svg?react";
import InsertRowAboveIcon from "./insert-row-above.svg?react";
import InsertRowBelow from "./insert-row-below.svg?react";
import IronCalcIcon from "./ironcalc_icon.svg?react";
import IronCalcIconWhite from "./ironcalc_icon_white.svg?react";
import IronCalcLogo from "./orange+black.svg?react";
import Fx from "./fx.svg?react";
export {
ArrowMiddleFromLine,
BorderBottomIcon,

View File

@@ -21,11 +21,11 @@
"decimal_places_increase": "Increase decimal places",
"decimal_places_decrease": "Decrease decimal places",
"show_hide_grid_lines": "Show/hide grid lines",
"name_manager": "Name manager",
"named_ranges": "Named ranges",
"vertical_align_bottom": "Align bottom",
"vertical_align_middle": " Align middle",
"vertical_align_middle": "Align middle",
"vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG",
"selected_png": "Export selected area as PNG",
"wrap_text": "Wrap text",
"scroll_left": "Scroll left",
"scroll_right": "Scroll right",
@@ -104,15 +104,33 @@
"name": "Name",
"range": "Scope",
"scope": "Range",
"help": "Learn more about Named Ranges",
"help": "About Named Ranges",
"new": "Add new",
"workbook": "Workbook",
"global": "(Global)",
"close": "Close dialog",
"delete": "Delete Range",
"edit": "Edit Range",
"back_to_list": "Back to list",
"add_new_range": "Add a new range",
"edit_range": "Edit range",
"new_named_range": "New Named Range",
"range_name": "Range name",
"enter_range_name": "Enter range name",
"scope_label": "Scope",
"scope_helper": "The scope of the named range determines where it is available.",
"refers_to": "Refers to",
"enter_formula": "Enter formula",
"cancel": "Cancel",
"apply": "Apply changes",
"discard": "Discard changes"
"discard": "Discard changes",
"errors": {
"range_name_required": "Range name is required",
"name_cannot_contain_spaces": "Name cannot contain spaces",
"name_cannot_start_with_number": "Name cannot start with a number",
"name_invalid_characters": "Name contains invalid characters. Use only letters, numbers, underscores, and periods. Must start with a letter or underscore.",
"name_already_exists": "This name already exists in the selected scope"
}
},
"cell_context": {
"insert_row_above": "Insert 1 row above",
@@ -142,5 +160,25 @@
"default": "Default color",
"no_fill": "No fill",
"custom": "Custom"
},
"right_drawer": {
"close": "Close",
"resize_drawer": "Resize drawer"
},
"workbook_settings": {
"open_settings": "Open settings",
"title": "Workbook Settings",
"close": "Close dialog",
"locale_and_timezone": {
"title": "Locale & Timezone",
"locale_label": "Locale",
"locale_example1": "Number",
"locale_example2": "Date",
"locale_example3": "Date and Time",
"locale_example4": "First day of the week",
"timezone_label": "Timezone",
"timezone_example1": "TODAY()",
"timezone_example2": "NOW()"
}
}
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { IronCalc, Model, init } from "../index";
import { IronCalc, init, Model } from "../index";
// export interface IronCalcProps {}

Some files were not shown because too many files have changed in this diff Show More