Compare commits

..

8 Commits

Author SHA1 Message Date
Nicolás Hatcher
6db8c6228e BOB 2024-12-16 18:05:51 +01:00
Daniel
ab3f9c276d FIX: Border picker cosmetics 2024-12-16 17:33:34 +01:00
Nicolás Hatcher
e098105531 FIX: When cut and paste to a different sheet origin is removed
Fixes #193
2024-12-16 08:01:51 +01:00
Daniel
a5919d837f UPDATE: Better active states on Sheet Nav + Double click to rename sheets 2024-12-15 21:29:10 +01:00
Daniel
f214070299 FIX: Several cosmetic changes to make the toolbar look like the figmas 2024-12-15 19:18:51 +01:00
Daniel
0b2de92053 FIX: Made menu on SheetTab consistent with Figmas 2024-12-15 19:15:34 +01:00
Nicolás Hatcher
98dc1f3b06 FIX[WebApp]: Rename navigation => SheetTabBar
Also rename all widgets in that folder to more standard names
2024-12-15 12:12:14 +01:00
Nicolás Hatcher
fb764fed1c FIX[WebApp]: Splits the menu.tsx in two files
This is so that SheetListMenu and SheetRenameDialog are on its own files.

Also renamed isOpen => open and close => onClose
2024-12-15 12:12:14 +01:00
33 changed files with 1028 additions and 650 deletions

View File

@@ -418,6 +418,7 @@ impl Model {
CalcResult::new_error(Error::NIMPL, cell, "Arrays not implemented".to_string())
}
VariableKind(defined_name) => {
println!("{:?}", defined_name);
let parsed_defined_name = self
.parsed_defined_names
.get(&(Some(cell.sheet), defined_name.to_lowercase())) // try getting local defined name
@@ -426,6 +427,7 @@ impl Model {
.get(&(None, defined_name.to_lowercase()))
}); // fallback to global
println!("Parsed: {:?}", defined_name);
if let Some(parsed_defined_name) = parsed_defined_name {
match parsed_defined_name {
ParsedDefinedName::CellReference(reference) => {
@@ -1986,6 +1988,95 @@ impl Model {
.worksheet_mut(sheet)?
.set_row_height(column, height)
}
/// Adds a new defined name
pub fn new_defined_name(
&mut self,
name: &str,
scope: Option<u32>,
formula: &str,
) -> Result<(), String> {
let name_upper = name.to_uppercase();
let defined_names = &self.workbook.defined_names;
// if the defined name already exist return error
for df in defined_names {
if df.name.to_uppercase() == name_upper && df.sheet_id == scope {
return Err("Defined name already exists".to_string());
}
}
self.workbook.defined_names.push(DefinedName {
name: name.to_string(),
formula: formula.to_string(),
sheet_id: scope,
});
self.reset_parsed_structures();
Ok(())
}
/// 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();
let defined_names = &self.workbook.defined_names;
let mut index = None;
for (i, df) in defined_names.iter().enumerate() {
if df.name.to_uppercase() == name_upper && df.sheet_id == scope {
index = Some(i);
}
}
if let Some(i) = index {
self.workbook.defined_names.remove(i);
self.reset_parsed_structures();
Ok(())
} else {
Err("Defined name not found".to_string())
}
}
/// returns the formula for a defined name
pub fn get_defined_name_formula(
&self,
name: &str,
scope: Option<u32>,
) -> Result<String, String> {
let name_upper = name.to_uppercase();
let defined_names = &self.workbook.defined_names;
for df in defined_names {
if df.name.to_uppercase() == name_upper && df.sheet_id == scope {
return Ok(df.formula.clone());
}
}
Err("Defined name not found".to_string())
}
/// update defined name
pub fn update_defined_name(
&mut self,
name: &str,
scope: Option<u32>,
new_name: &str,
new_scope: Option<u32>,
new_formula: &str,
) -> Result<(), String> {
let name_upper = name.to_uppercase();
let defined_names = &self.workbook.defined_names;
let mut index = None;
for (i, df) in defined_names.iter().enumerate() {
if df.name.to_uppercase() == name_upper && df.sheet_id == scope {
index = Some(i);
}
}
if let Some(i) = index {
if let Some(df) = self.workbook.defined_names.get_mut(i) {
df.name = new_name.to_string();
df.sheet_id = new_scope;
df.formula = new_formula.to_string();
self.reset_parsed_structures();
}
Ok(())
} else {
Err("Defined name not found".to_string())
}
}
}
#[cfg(test)]

View File

@@ -3,6 +3,7 @@ mod test_autofill_columns;
mod test_autofill_rows;
mod test_border;
mod test_clear_cells;
mod test_defined_names;
mod test_diff_queue;
mod test_evaluation;
mod test_general;

View File

@@ -0,0 +1,40 @@
#![allow(clippy::unwrap_used)]
use crate::UserModel;
#[test]
fn create_defined_name() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.new_defined_name("myName", None, "$A$1").unwrap();
model.set_user_input(0, 5, 7, "=myName").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 5, 7),
Ok("42".to_string())
);
// rename it
model
.update_defined_name("myName", None, "myName", None, "$A$1*2")
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 5, 7),
Ok("42".to_string())
);
// delete it
model.delete_defined_name("myName", None).unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 5, 7),
Ok("#REF!".to_string())
);
}
#[test]
fn rename_defined_name() {}
#[test]
fn delete_sheet() {}
#[test]
fn change_scope() {}

View File

@@ -99,7 +99,7 @@ fn cut_paste() {
// paste in cell D4 (4, 4)
model
.paste_from_clipboard((1, 1, 2, 2), &copy.data, true)
.paste_from_clipboard(0, (1, 1, 2, 2), &copy.data, true)
.unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string()));
@@ -119,6 +119,26 @@ fn cut_paste() {
assert_eq!(model.get_cell_content(0, 2, 2), Ok("".to_string()));
}
#[test]
fn cut_paste_different_sheet() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_selected_range(1, 1, 1, 1).unwrap();
let copy = model.copy_to_clipboard().unwrap();
model.new_sheet().unwrap();
model.set_selected_sheet(1).unwrap();
model.set_selected_cell(4, 4).unwrap();
// paste in cell D4 (4, 4) of Sheet2
model
.paste_from_clipboard(0, (1, 1, 1, 1), &copy.data, true)
.unwrap();
assert_eq!(model.get_cell_content(1, 4, 4), Ok("42".to_string()));
assert_eq!(model.get_cell_content(0, 1, 1), Ok("".to_string()));
}
#[test]
fn copy_paste_internal() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
@@ -152,7 +172,7 @@ fn copy_paste_internal() {
// paste in cell D4 (4, 4)
model
.paste_from_clipboard((1, 1, 2, 2), &copy.data, false)
.paste_from_clipboard(0, (1, 1, 2, 2), &copy.data, false)
.unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string()));

View File

@@ -13,8 +13,8 @@ use crate::{
},
model::Model,
types::{
Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, Style,
VerticalAlignment,
Alignment, BorderItem, CellType, Col, DefinedName, HorizontalAlignment, SheetProperties,
Style, VerticalAlignment,
},
utils::is_valid_hex_color,
};
@@ -39,6 +39,7 @@ pub struct ClipboardCell {
pub struct Clipboard {
pub(crate) csv: String,
pub(crate) data: ClipboardData,
pub(crate) sheet: u32,
pub(crate) range: (i32, i32, i32, i32),
}
@@ -1520,6 +1521,7 @@ impl UserModel {
Ok(Clipboard {
csv,
data,
sheet,
range: (row_start, column_start, row_end, column_end),
})
}
@@ -1527,6 +1529,7 @@ impl UserModel {
/// Paste text that we copied
pub fn paste_from_clipboard(
&mut self,
source_sheet: u32,
source_range: ClipboardTuple,
clipboard: &ClipboardData,
is_cut: bool,
@@ -1617,17 +1620,17 @@ impl UserModel {
let old_value = self
.model
.workbook
.worksheet(sheet)?
.worksheet(source_sheet)?
.cell(row, column)
.cloned();
diff_list.push(Diff::CellClearContents {
sheet,
sheet: source_sheet,
row,
column,
old_value: Box::new(old_value),
});
self.model.cell_clear_contents(sheet, row, column)?;
self.model.cell_clear_contents(source_sheet, row, column)?;
}
}
}
@@ -1689,6 +1692,66 @@ impl UserModel {
Ok(())
}
/// Returns the list of defined names
pub fn get_defined_name_list(&self) -> Vec<DefinedName> {
self.model.workbook.defined_names.clone()
}
/// Delete an existing defined name
pub fn delete_defined_name(&mut self, name: &str, scope: Option<u32>) -> Result<(), String> {
let old_value = self.model.get_defined_name_formula(name, scope)?;
let diff_list = vec![Diff::DeleteDefinedName {
name: name.to_string(),
scope,
old_value,
}];
self.push_diff_list(diff_list);
self.model.delete_defined_name(name, scope)?;
self.evaluate_if_not_paused();
Ok(())
}
/// Create a new defined name
pub fn new_defined_name(
&mut self,
name: &str,
scope: Option<u32>,
formula: &str,
) -> Result<(), String> {
self.model.new_defined_name(name, scope, formula)?;
let diff_list = vec![Diff::CreateDefinedName {
name: name.to_string(),
scope,
value: formula.to_string(),
}];
self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(())
}
/// Updates a defined name
pub fn update_defined_name(
&mut self,
name: &str,
scope: Option<u32>,
new_name: &str,
new_scope: Option<u32>,
new_formula: &str,
) -> Result<(), String> {
let old_formula = self.model.get_defined_name_formula(name, scope)?;
let diff_list = vec![Diff::UpdateDefinedName {
name: name.to_string(),
scope,
old_formula: old_formula.to_string(),
new_name: new_name.to_string(),
new_scope,
new_formula: new_formula.to_string(),
}];
self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(())
}
// **** Private methods ****** //
fn push_diff_list(&mut self, diff_list: DiffList) {
@@ -1859,6 +1922,20 @@ impl UserModel {
} => {
self.model.set_show_grid_lines(*sheet, *old_value)?;
}
Diff::CreateDefinedName { name, scope, value } => todo!(),
Diff::DeleteDefinedName {
name,
scope,
old_value,
} => todo!(),
Diff::UpdateDefinedName {
name,
scope,
old_formula,
new_name,
new_scope,
new_formula,
} => todo!(),
}
}
if needs_evaluation {
@@ -1986,6 +2063,20 @@ impl UserModel {
} => {
self.model.set_show_grid_lines(*sheet, *new_value)?;
}
Diff::CreateDefinedName { name, scope, value } => todo!(),
Diff::DeleteDefinedName {
name,
scope,
old_value,
} => todo!(),
Diff::UpdateDefinedName {
name,
scope,
old_formula,
new_name,
new_scope,
new_formula,
} => todo!(),
}
}

View File

@@ -108,7 +108,26 @@ pub(crate) enum Diff {
sheet: u32,
old_value: bool,
new_value: bool,
}, // FIXME: we are missing SetViewDiffs
},
CreateDefinedName {
name: String,
scope: Option<u32>,
value: String,
},
DeleteDefinedName {
name: String,
scope: Option<u32>,
old_value: String,
},
UpdateDefinedName {
name: String,
scope: Option<u32>,
old_formula: String,
new_name: String,
new_scope: Option<u32>,
new_formula: String,
},
// FIXME: we are missing SetViewDiffs
}
pub(crate) type DiffList = Vec<Diff>;

View File

@@ -169,20 +169,36 @@ clipboard_types = r"""
paste_from_clipboard = r"""
/**
* @param {number} source_sheet
* @param {any} source_range
* @param {any} clipboard
* @param {boolean} is_cut
*/
pasteFromClipboard(source_range: any, clipboard: any, is_cut: boolean): void;
pasteFromClipboard(source_sheet: number, source_range: any, clipboard: any, is_cut: boolean): void;
"""
paste_from_clipboard_types = r"""
/**
* @param {number} source_sheet
* @param {[number, number, number, number]} source_range
* @param {ClipboardData} clipboard
* @param {boolean} is_cut
*/
pasteFromClipboard(source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void;
pasteFromClipboard(source_sheet: number, source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void;
"""
defined_name_list = r"""
/**
* @returns {any}
*/
getDefinedNameList(): any;
"""
defined_name_list_types = r"""
/**
* @returns {DefinedName[]}
*/
getDefinedNameList(): DefinedName[];
"""
def fix_types(text):
@@ -198,6 +214,7 @@ def fix_types(text):
text = text.replace(paste_csv_string, paste_csv_string_types)
text = text.replace(clipboard, clipboard_types)
text = text.replace(paste_from_clipboard, paste_from_clipboard_types)
text = text.replace(defined_name_list, defined_name_list_types)
with open("types.ts") as f:
types_str = f.read()
header_types = "{}\n\n{}".format(header, types_str)

View File

@@ -1,3 +1,4 @@
use serde::Serialize;
use wasm_bindgen::{
prelude::{wasm_bindgen, JsError},
JsValue,
@@ -29,6 +30,13 @@ pub fn column_name_from_number(column: i32) -> Result<String, JsError> {
}
}
#[derive(Serialize)]
struct DefinedName {
name: String,
scope: Option<u32>,
formula: String,
}
#[wasm_bindgen]
pub struct Model {
model: BaseModel,
@@ -520,6 +528,7 @@ impl Model {
#[wasm_bindgen(js_name = "pasteFromClipboard")]
pub fn paste_from_clipboard(
&mut self,
source_sheet: u32,
source_range: JsValue,
clipboard: JsValue,
is_cut: bool,
@@ -529,7 +538,7 @@ impl Model {
let clipboard: ClipboardData =
serde_wasm_bindgen::from_value(clipboard).map_err(|e| to_js_error(e.to_string()))?;
self.model
.paste_from_clipboard(source_range, &clipboard, is_cut)
.paste_from_clipboard(source_sheet, source_range, &clipboard, is_cut)
.map_err(|e| to_js_error(e.to_string()))
}
@@ -541,4 +550,52 @@ impl Model {
.paste_csv_string(&range, csv)
.map_err(|e| to_js_error(e.to_string()))
}
#[wasm_bindgen(js_name = "getDefinedNameList")]
pub fn get_defined_name_list(&self) -> Result<JsValue, JsError> {
let data: Vec<DefinedName> =
self.model
.get_defined_name_list()
.iter()
.map(|s| DefinedName {
name: s.name.to_string(),
scope: s.sheet_id,
formula: s.formula.to_string(),
}).collect();
// Ok(data)
serde_wasm_bindgen::to_value(&data).map_err(|e| to_js_error(e.to_string()))
}
#[wasm_bindgen(js_name = "newDefinedName")]
pub fn new_defined_name(
&mut self,
name: &str,
scope: Option<u32>,
formula: &str,
) -> Result<(), JsError> {
self.model
.new_defined_name(name, scope, formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[wasm_bindgen(js_name = "updateDefinedName")]
pub fn update_defined_name(
&mut self,
name: &str,
scope: Option<u32>,
new_name: &str,
new_scope: Option<u32>,
new_formula: &str,
) -> Result<(), JsError> {
self.model
.update_defined_name(name, scope, new_name, new_scope, new_formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[wasm_bindgen(js_name = "deleteDefinedName")]
pub fn delete_definedname(&mut self, name: &str, scope: Option<u32>) -> Result<(), JsError> {
self.model
.delete_defined_name(name, scope)
.map_err(|e| to_js_error(e.to_string()))
}
}

View File

@@ -226,4 +226,10 @@ export interface Clipboard {
csv: string;
data: ClipboardData;
range: [number, number, number, number];
}
export interface DefinedName {
name: string;
scope?: number;
formula: string;
}

View File

@@ -58,30 +58,6 @@ export default defineConfig({
text: "Error Types",
link: "/features/error-types",
},
{
text: "Value Types",
link: "/features/value-types",
},
{
text: "Optional arguments",
link: "/features/optional-arguments",
},
{
text: "Units",
link: "/features/units",
},
{
text: "Implicit Intersection",
link: "/features/implicit-intersection",
},
{
text: "Arrays and array formulas",
link: "/features/arrays",
},
{
text: "Dynamic Arrays",
link: "/features/dynamic-arrays",
},
{
text: "Unsupported Features",
link: "/features/unsupported-features",

View File

@@ -1,15 +0,0 @@
---
layout: doc
outline: deep
lang: en-US
---
# Arrays
::: warning
**Note:** This page is in construction 🚧
:::
::: warning
**Note:** This feature is not yet available on IronCalc 🚧
:::

View File

@@ -1,15 +0,0 @@
---
layout: doc
outline: deep
lang: en-US
---
# Dynamic Arrays
::: warning
**Note:** This page is in construction 🚧
:::
::: warning
**Note:** This feature is not yet available on IronCalc 🚧
:::

View File

@@ -10,36 +10,28 @@ lang: en-US
**Note:** This page is in construction 🚧
:::
The result of a formula is sometimes an _error_. In some situations those errors are expected and your formulas might be dealing with them.
The error `#N/A` might signal that there is no data to evaluate the formula yet. Maybe the payrol has not been introduced for that month just yet.
When working with formulas, you may encounter these common errors:
Some other errors like `#SPILL!`, `#CIRC!` or `#ERROR!` signal an error in your spreadsheet logic and must be corrected.
---
The first kind of errors or 'common errors' are found in other spreadsheet engines like Excel while other errrors like `#ERROR!` or `#N/IMPL` are particular to IronCalc.
### **`#ERROR!`**
## Common Errors
**Cause:** General formula issue, like syntax errors or invalid references.
**Fix:** Check the formula for mistakes or invalid cell references.
---
### **`#VALUE!`**
It might be caused by mismatched data types (e.g., text used where numbers are expected):
**Cause:** Mismatched data types (e.g., text used where numbers are expected).
**Fix:** Ensure input types are correct; convert text to numbers if needed.
```
5+"two"
```
The engine doesn't know how to add the number `5` to the string `two` resulting in a `#VALUE!`.
It is an actual error in your spreadsheet. It indicates that the formula isnt working as intended.
---
### **`#DIV/0!`**
Division by zero or an empty cell.
```
=1/0
```
Usually this is an error. However, in cases where a denominator might be blank (e.g., data not yet filled in), this could be expected. Use `IFERROR` or `IF` to handle it.
**Cause:** Division by zero or an empty cell.
**Fix:** Ensure the denominator isnt zero or blank. Use `IF` to handle such cases:
```
=IF(B1=0, "N/A", A1/B1)
@@ -47,42 +39,23 @@ Usually this is an error. However, in cases where a denominator might be blank (
### **`#NAME?`**
Found when a name is not recognized. Maybe a misspeled name for a function. Could be a referenceto defined name that has been deleted.
```
=UNKOWN_FUNCTION(A1)
```
This indicates an error in your spreadsheet logic.
**Cause:** Unrecognized text in the formula (e.g., misspelled function names or undefined named ranges).
**Fix:** Correct spelling or define the missing name.
### **`#REF!`**
Indicates an invalid cell reference, often from deleting cells used in a formula.
They can appear as a result of a computation or in a formula. Examples:
```
=Sheet34!A1
```
If `Sheet34` doesn't exist it will return `#REF!`
This is a genuine error. It indicates that part of your formula references a cell or range that is missing.
**Cause:** Invalid cell reference, often from deleting cells used in a formula.
**Fix:** Update the formula with correct references.
### **`#NUM!`**
Invalid numeric operation (e.g., calculating a square root of a negative number).
Adjust the formula to ensure valid numeric operations.
Sometimes a `#NUM!` might be expected signalling the user that some parameter is out of scope.
**Cause:** Invalid numeric operation (e.g., calculating a square root of a negative number).
**Fix:** Adjust the formula to ensure valid numeric operations.
### **`#N/A`**
A value is not available, often in lookup functions like VLOOKUP.
This is frequnly not an error in your spreadsheetlogic.
You can produce a prettier answer using the [`IFNA`](/functions/information/isna) formula:
**Cause:** A value is not available, often in lookup functions like VLOOKUP.
**Fix:** Ensure the lookup value exists or use IFNA() to handle missing values:
```
=IFNA(VLOOKUP(A1, B1:C10, 2, FALSE), "Not Found")
@@ -90,68 +63,17 @@ You can produce a prettier answer using the [`IFNA`](/functions/information/isna
### **`#NULL!`**
Incorrect range operator in a formula (e.g., missing a colon between cell references).
**Cause:** Incorrect range operator in a formula (e.g., missing a colon between cell references).
**Fix:** Use correct range operators (e.g., A1:A10).
### **`#SPILL!`**
A cell in a formula will overwrite content in other cells.
This cannot happen riht now in IronCalc as formulas don't spill yet.
### **`#CIRC!`**
Circular reference. This is an error is your spreadsheet and must be fixed.
Means that during teh course of a computation a circular dependency was found.
A circular dependency is a dependency of a formula on itself.
For instance in the cell `A1` the formula `=A1*2` is a circular dependency.
Other spreadsheet engines use circular dependencies to do "loop computations", run "sensitivity analysis" or "goal seek".
IronCalc doesn't support any of those at the moment.
## IronCalc specific errors
---
### **`#ERROR!`**
General formula issue, like syntax errors or invalid references.
In general Excel does not let you enter incorrect formulas but IronCalc will.
This will make your workbook imcompatible with Excel
For instace an incomplete formula
```
=A1+
```
### **`#N/IMPL!`**
A particular feature is not yet implemented in IronCalc
Look if there is a Github ticket or contact us via email, Discord or bluesky
## Error propagation
Some errors a created by some formulas. For instance the function `SQRT` can create the error `#NUM!` but can't ceate the error `#DIV/0`.
Once an error is created it is normally _propagated_ by all the formulas. So if cell `C3` evaluates to `#ERROR!` then the formula
`=SQRT(C3)` will return `#ERROR!`.
Not all functions propagate errors in their arguments. For instancethe function `IF(condition, if_true, if_false)` will only propagate an error in the `if_false` argument if the `condition` is `FALSE`. This is called _lazy evaluation_, the function `IF` is _lazy_, it only evaluates the arguments when needed. The opposite of lazy evaulaution is called _eager evaluation_.
Some functions also expect an error as an argument like [`ERROR.TYPE`](/functions/information/error.type) and will not propagate the error.
**Cause:** Circular reference.
**Fix:** Remove the circular reference.
## See also
### **`#####`**
The following functions are convenient when working with errors
- [`ISERR(ref)`](/functions/information/iserr), `TRUE` if `ref` is any error type except the `#N/A` error.
- [`ISERROR(ref)`](/functions/information/iserror), `TRUE` if `ref` is any error.
- [`ISNA(ref)`](/functions/information/isna), `TRUE` if ref is `#N/A`.
- [`ERROR.TYPE`](/functions/information/error.type) returns the numeric code for a given error.
- [`IFERROR(ref, value)`](/functions/logical/iferror) returns `value` if the content of `ref` is an error.
- [`IFNA(ref, value)`](/functions/logical/ifna) return `value` if `ref` is #N/A errors only.
**Cause:** The column isnt wide enough to display the value.
**Fix:** Resize the column width to fit the content.

View File

@@ -1,15 +0,0 @@
---
layout: doc
outline: deep
lang: en-US
---
# Implicit Intersection
::: warning
**Note:** This page is in construction 🚧
:::
::: warning
**Note:** This feature is not yet available on IronCalc 🚧
:::

View File

@@ -1,26 +0,0 @@
---
layout: doc
outline: deep
lang: en-US
---
# Error Types
::: warning
**Note:** This page is in construction 🚧
:::
Some functions have optional arguments. For instance:
XLOOKUP(lookup_value, lookup_array, return_array, if_not_found="#N/A", match_mode=0, search_mode=1)
The three first arguments are mandatory and the last three are optional.
Optional argumenst must have a default value.
In this example if you don't want to specify the if_not_found and match_mode arguments you can leave them out:
XLOOKUP(lookup_value, lookup_array, return_array, , , -2)
That would use the default arguments for if_not_found and match_mode arguments.

View File

@@ -1,13 +0,0 @@
---
layout: doc
outline: deep
lang: en-US
---
# Units
::: warning
**Note:** This page is in construction 🚧
:::
Some IronCalc functions return values that have units like currencies, percentage or dates.

View File

@@ -1,67 +0,0 @@
---
layout: doc
outline: deep
lang: en-US
---
# Value types
::: warning
**Note:** This page is in construction 🚧
:::
In IronCalc a value, a result of a calculation can be one of:
## Numbers
Numbers in IronCalc are [IEEE 754 double precission](https://en.wikipedia.org/wiki/Double-precision_floating-point_format).
Numbers are only displayed up to 15 significant figures. That's why '=0.1+0.2' is actually '0.3'
Also numbers are compared up to 15 significant figures. So `=IF(0.1+0.2=0.3, "Valid", "Invalid")` will return `Valid`.
However `=0.3-0.2-0.1` will not result in `0` in IronCalc.
### Casting into numbers
Strings and booleans are sometimes coverted to numbers
`=1+"2"` => 3
Some functions cast in weird ways:
SUM(1, TRUE) => 2
SUM(1, "1") => 2
But SUM(1, A1) => 1 (where A1 is TRUE or "1")
Sometimes the conversion happens like => "123"+1 is actually 124 and the SQRT("4") is 2 or the SQRT(TRUE) is 1.
Some functions, however are more strict BIN2DEC(TRUE) is #VALUE!
### Dates
On spreadsheets a date is just the number of days since January 1, 1900.
## Strings
### Complex numbers
On IronCal a complex number is just a string like "1+j3".
## Booleans
### Casting from numbers
## Errors
### Casting from strings
"#N/A" => #N/A
## Arrays

View File

@@ -13,84 +13,37 @@ FV can be used to calculate future value over a specified number of compounding
If your interest rate varies between periods, use the [FVSCHEDULE](/functions/financial/fvschedule) function instead of FV.
## Usage
### Syntax
**FV(<span title="Number" style="color:#1E88E5">rate</span>, <span title="Number" style="color:#1E88E5">nper</span>, <span title="Number" style="color:#1E88E5">pmt</span>, <span title="Number" style="color:#1E88E5">pv</span>=0, <span title="Boolean" style="color:#43A047">type</span>=false) => <span title="Number" style="color:#1E88E5">fv</span>**
**FV(rate, nper, pmt, pv, type)**
### Argument descriptions
* *rate*. ([number](/features/value-types)) The fixed percentage interest rate or yield per period.
* *nper*. ([number](/features/value-types)) The number of compounding periods to be taken into account. While this will often be an integer, non-integer values are accepted and processed.
It stands for <u>n</u>umber of <u>per</u>iods.
* *pmt*. ([number](/features/value-types)) The fixed amount paid or deposited each compounding period. Short for <u>p</u>ay<u>m</u>en<u>t</u>
* *pv* ([number](/features/value-types), optional). The present value or starting amount of the asset (default 0). Short for <u>p</u>resent <u>v</u>alue.
* *type* ([boolean](/features/value-types), optional). A logical value indicating whether the payment due dates are at the end (0) of the compounding periods or at the beginning (any non-zero value). The default is 0 when omitted.
### Returned value
The retruned value is a [number](/features/value-types) with [currency units](/features/units)
### Errors
* If any of the arguments is an error returns the error
* If any of the argumets is not a number (or cannot be converted to a number) it returns `#VALUE!`
* Some ranges of the parameters produce `#NUM!` error. For instnace `=FV(-3,1/2,1)`.
* Some ranges of the parameters produce the `#DIV/0!` error. For instance `=FV(-1, -1, 1)`
* *rate*. The fixed percentage interest rate or yield per period.
* *nper*. The number of compounding periods to be taken into account. While this will often be an integer, non-integer values are accepted and processed.
* *pmt*. The fixed amount paid or deposited each compounding period.
* *pv* (optional). The present value or starting amount of the asset (default 0).
* *type* (optional). A logical value indicating whether the payment due dates are at the end (0) of the compounding periods or at the beginning (any non-zero value). The default is 0 when omitted.
### Additional guidance
* Make sure that the *rate* argument specifies the interest rate or yield applicable to the compounding period, based on the value chosen for *nper*.
* The *pmt* and *pv* arguments should be expressed in the same currency unit. The value returned is expressed in the same currency unit.
* To ensure a worthwhile result, one of the *pmt* and *pv* arguments should be non-zero.
* The setting of the *type* argument only affects the calculation for non-zero values of the *pmt* argument.
<!--@include: ../markdown-snippets/error-type-details.txt-->
<!--@include: ../markdown-snippets/error-type-details.md-->
## 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} = -\text{pv} \times (1 + \text{rate})^{\text{nper}} - \dfrac{\text{pmt}\times\big({(1+\text{rate})^\text{nper}-1}\big)}{\text{rate}}$$
* In both cases, in the limmit $\text{rate} = 0$, fv is given by the equation:
$$ \text{fv} = -\text{pv} - (\text{pmt} \times \text{nper}) $$
## Formula derivation
The money you have now might grow in a bank by _[compound interest](https://en.wikipedia.org/wiki/Compound_interest)_. Say you have $100, that is the present value, and your bank gives you 10% interest rate yearly.
At the end of 1 year you will have $110. In general that is $\text{pv}\times (1 + \text{rate})$. At the end of two years (the second _[annuity](https://en.wikipedia.org/wiki/Annuity)_) you will have 10\% more of the $110. That is the _compound_ part. You will have at the end of the second period $121 or in general if you invest an ammount $\text{pv}$ at an interest $\text{rate}$ and wait for $\text{nper}$ periods the future value of this _[lump sum](https://en.wikipedia.org/wiki/Lump_sum)_ will be:
$$\text{fv}_\text{ls} = \text{pv} \times (1 + \text{rate})^\text{nper}$$
Note that the periods may be years, months or anything else.
Now, supose that you also make regular payments of ammount $\text{pmt}$ each period.
To find the future value of these payments, you sum the future value of each payment at the end of the investment horizon.
There are two posibilities here:
* You make the payments at the end of the periods (type 0). This is also called an _ordinary annuity_.
* You make the payments at the beginning of each period (type 1). This is the _annuity due_ case.
To derive the formula for either of them we need to add an geometric progression. To simplify things.
Say we are at the end of period 5 and we are making the payments at the end of the period.
* If *rate* = 0, FV is given by the equation:
$$
\text{pmt}\times (1 + \text{rate})^4+\text{pmt}\times (1 + \text{rate})^3+\text{pmt}\times (1 + \text{rate})^2 +\text{pmt}
FV = -pv - (pmt \times nper)
$$
This is because the first payment has been around for 4 periods, and the second payment has been around for 3 periods...
The general formula for the sum of $\text{nper}$ terms in a geometric progression is given by $a(1 - r^n) / (1 - r)$, where $a = \text{pmt}$ and $r = 1 + \text{rate}$
* If *rate* <> 0 and *type* = 0, FV is given by the equation:
$$ FV = -pv \times (1 + rate)^{nper} - \dfrac{pmt\times\big({(1+rate)^{nper}-1}\big)}{rate}
$$
\text{pmt}\times\dfrac{ (1+\text{rate})^{\text{nper}}-1}{\text{rate}}
* If *rate* <> 0 and *type* <> 0, FV is given by the equation:
$$ FV = -pv \times (1 + rate)^{nper} - \dfrac{pmt\times\big({(1+rate)^{nper}-1}\big) \times(1+rate)}{rate}
$$
## Examples
[See this example in IronCalc](https://app.ironcalc.com/?example=fv).
## 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.
* [Investorpedia](https://www.investopedia.com/terms/f/futurevalue.asp) has a nice article on the future value.
* 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.

View File

@@ -0,0 +1,127 @@
import type { Model } from "@ironcalc/wasm";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
styled,
} from "@mui/material";
import { t } from "i18next";
import { BookOpen, X } from "lucide-react";
import { useState } from "react";
import NamedRange from "./NamedRange";
import type { NamedRangeObject } from "./NamedRange";
type NameManagerDialogProperties = {
onClose: () => void;
onSave: () => void;
open: boolean;
model: Model;
};
function NameManagerDialog(props: NameManagerDialogProperties) {
const handleClose = () => {
props.onClose();
};
// update child component values in model
const handleSave = () => {
props.onSave(); // => onNamedRangesUpdate from toolbar
props.onClose();
};
//! Why are fields editable only while clicking them?
// update child component values in UI
const handleChange = (id: string, field: string, value: string) => {
console.log("change:", id, field, value);
// previous array elements, plus updated value
// setNamedRangesLocal((prev) =>
// prev.map((namedRange) =>
// namedRange.id === id ? { ...namedRange, [field]: value } : namedRange,
// ),
// );
};
const nameList = props.model.getDefinedNameList();
return (
<StyledDialog
open={props.open}
onClose={props.onClose}
maxWidth="md"
fullWidth
scroll="paper"
>
<StyledDialogTitle>
Named Ranges
<IconButton onClick={handleClose}>
<X size={16} />
</IconButton>
</StyledDialogTitle>
<DialogContent dividers>
{nameList.map((e) => (
<NamedRange
worksheets={props.model.getWorksheetsProperties()}
name={e.name}
scope={e.scope}
range={e.formula}
key={`${e.name}-${e.scope}`}
onChange={handleChange}
model={props.model}
/>
))}
</DialogContent>
<StyledDialogActions>
<Box display="flex" alignItems="center">
<BookOpen
color="grey"
style={{ width: 16, height: 16, marginLeft: 12, marginRight: 8 }}
/>
<span style={{ fontSize: "12px", fontFamily: "Inter" }}>
{t("name_manager_dialog.help")}
</span>
</Box>
<Box display="flex" gap="10px">
<Button onClick={handleClose} variant="contained" color="info">
Cancel
</Button>
<Button onClick={handleSave} variant="contained">
Save changes
</Button>
</Box>
</StyledDialogActions>
</StyledDialog>
);
}
const StyledDialog = styled(Dialog)(() => ({
"& .MuiPaper-root": {
maxHeight: "60%",
minHeight: "40%",
},
}));
// font-weight: 600 is too bold compared to design, should be between 500 & 600
const StyledDialogTitle = styled(DialogTitle)`
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
`;
const StyledDialogActions = styled(DialogActions)`
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #757575;
`;
export default NameManagerDialog;

View File

@@ -0,0 +1,91 @@
import type { Model, WorksheetProperties } from "@ironcalc/wasm";
import { Box, IconButton, MenuItem, TextField, styled } from "@mui/material";
import { Trash2 } from "lucide-react";
export type NamedRangeObject = {
id: string;
name: string;
scope: string;
range: string;
};
type NamedRangeProperties = {
name: string;
scope: string;
range: string;
model: Model;
worksheets: WorksheetProperties[];
// update namedRange in model
onChange: (field: string, value: string) => void;
};
function NamedRange(props: NamedRangeProperties) {
// define onChange in parent for updating the model and values
const handleDelete = () => {
// update model
console.log("deleted named range");
};
return (
<StyledBox>
<TextField
variant="outlined"
size="small"
margin="normal"
fullWidth
value={props.name}
onChange={(event) =>
props.onChange("name", event.target.value)
}
onKeyDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => event.stopPropagation()}
/>
<TextField
variant="outlined"
select
defaultValue="Workbook"
size="small"
margin="normal"
fullWidth
value={props.scope}
onChange={(event) =>
props.onChange("scope", event.target.value)
}
>
{props.worksheets.map((option) => (
<MenuItem key={option.sheet_id} value={option.name}>
{option.name}
</MenuItem>
))}
</TextField>
<TextField
variant="outlined"
size="small"
margin="normal"
fullWidth
value={props.range}
onChange={(event) =>
props.onChange("range", event.target.value)
}
onKeyDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => event.stopPropagation()}
/>
{/* remove round hover animation */}
<IconButton onClick={handleDelete}>
<Trash2 size={16} absoluteStrokeWidth />
</IconButton>
</StyledBox>
);
}
const StyledBox = styled(Box)`
display: flex;
gap: 15px;
`;
export default NamedRange;

View File

@@ -0,0 +1,99 @@
import { styled } from "@mui/material";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { Check } from "lucide-react";
import type { SheetOptions } from "./types";
function isWhiteColor(color: string): boolean {
return ["#FFF", "#FFFFFF"].includes(color);
}
interface SheetListMenuProps {
open: boolean;
onClose: () => void;
anchorEl: HTMLButtonElement | null;
onSheetSelected: (index: number) => void;
sheetOptionsList: SheetOptions[];
selectedIndex: number;
}
const SheetListMenu = (properties: SheetListMenuProps) => {
const {
open,
onClose,
anchorEl,
onSheetSelected,
sheetOptionsList,
selectedIndex,
} = properties;
const hasColors = sheetOptionsList.some((tab) => !isWhiteColor(tab.color));
return (
<StyledMenu
open={open}
onClose={onClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: 6,
}}
>
{sheetOptionsList.map((tab, index) => (
<StyledMenuItem
key={tab.sheetId}
onClick={() => onSheetSelected(index)}
>
{index === selectedIndex ? (
<Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
) : (
<div
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
)}
{hasColors && <ItemColor style={{ backgroundColor: tab.color }} />}
<ItemName
style={{ fontWeight: index === selectedIndex ? "bold" : "normal" }}
>
{tab.name}
</ItemName>
</StyledMenuItem>
))}
</StyledMenu>
);
};
const StyledMenu = styled(Menu)({
"& .MuiPaper-root": {
borderRadius: 8,
padding: 4,
},
"& .MuiList-padding": {
padding: 0,
},
});
const StyledMenuItem = styled(MenuItem)({
padding: 8,
borderRadius: 4,
});
const ItemColor = styled("div")`
width: 12px;
height: 12px;
border-radius: 4px;
margin-right: 8px;
`;
const ItemName = styled("div")`
font-size: 12px;
color: #333;
`;
export default SheetListMenu;

View File

@@ -1,31 +1,23 @@
import { Dialog, TextField, styled } from "@mui/material";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { Check } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
import type { SheetOptions } from "./types";
function isWhiteColor(color: string): boolean {
return ["#FFF", "#FFFFFF"].includes(color);
}
interface SheetRenameDialogProps {
isOpen: boolean;
close: () => void;
open: boolean;
onClose: () => void;
onNameChanged: (name: string) => void;
defaultName: string;
}
export const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
const { t } = useTranslation();
const [name, setName] = useState(properties.defaultName);
const handleClose = () => {
properties.close();
properties.onClose();
};
return (
<Dialog open={properties.isOpen} onClose={properties.close}>
<Dialog open={properties.open} onClose={properties.onClose}>
<StyledDialogTitle>
{t("sheet_rename.title")}
<Cross onClick={handleClose} onKeyDown={() => {}}>
@@ -61,7 +53,7 @@ export const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
event.stopPropagation();
if (event.key === "Enter") {
properties.onNameChanged(name);
properties.close();
properties.onClose();
}
}}
onChange={(event) => {
@@ -84,94 +76,6 @@ export const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
);
};
interface SheetListMenuProps {
isOpen: boolean;
close: () => void;
anchorEl: HTMLButtonElement | null;
onSheetSelected: (index: number) => void;
sheetOptionsList: SheetOptions[];
selectedIndex: number;
}
const SheetListMenu = (properties: SheetListMenuProps) => {
const {
isOpen,
close,
anchorEl,
onSheetSelected,
sheetOptionsList,
selectedIndex,
} = properties;
const hasColors = sheetOptionsList.some((tab) => !isWhiteColor(tab.color));
return (
<StyledMenu
open={isOpen}
onClose={close}
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: 6,
}}
>
{sheetOptionsList.map((tab, index) => (
<StyledMenuItem
key={tab.sheetId}
onClick={() => onSheetSelected(index)}
>
{index === selectedIndex ? (
<Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
) : (
<div
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
)}
{hasColors && <ItemColor style={{ backgroundColor: tab.color }} />}
<ItemName
style={{ fontWeight: index === selectedIndex ? "bold" : "normal" }}
>
{tab.name}
</ItemName>
</StyledMenuItem>
))}
</StyledMenu>
);
};
const StyledMenu = styled(Menu)({
"& .MuiPaper-root": {
borderRadius: 8,
padding: 4,
},
"& .MuiList-padding": {
padding: 0,
},
});
const StyledMenuItem = styled(MenuItem)({
padding: 8,
borderRadius: 4,
});
const ItemColor = styled("div")`
width: 12px;
height: 12px;
border-radius: 4px;
margin-right: 8px;
`;
const ItemName = styled("div")`
font-size: 12px;
color: #333;
`;
const StyledDialogTitle = styled("div")`
display: flex;
align-items: center;
@@ -246,4 +150,4 @@ const StyledButton = styled("div")`
}
`;
export default SheetListMenu;
export default SheetRenameDialog;

View File

@@ -1,12 +1,13 @@
import { Button, Menu, MenuItem, styled } from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react";
import { theme } from "../../theme";
import ColorPicker from "../colorPicker";
import { isInReferenceMode } from "../editor/util";
import type { WorkbookState } from "../workbookState";
import { SheetRenameDialog } from "./menus";
import SheetRenameDialog from "./SheetRenameDialog";
interface SheetProps {
interface SheetTabProps {
name: string;
color: string;
selected: boolean;
@@ -17,7 +18,7 @@ interface SheetProps {
workbookState: WorkbookState;
}
function Sheet(props: SheetProps) {
function SheetTab(props: SheetTabProps) {
const { name, color, selected, workbookState, onSelected } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
@@ -38,8 +39,9 @@ function Sheet(props: SheetProps) {
};
return (
<>
<Wrapper
style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }}
<TabWrapper
$color={color}
$selected={selected}
onClick={(event) => {
onSelected();
event.stopPropagation();
@@ -55,11 +57,11 @@ function Sheet(props: SheetProps) {
}}
ref={colorButton}
>
<Name>{name}</Name>
<Name onDoubleClick={handleOpenRenameDialog}>{name}</Name>
<StyledButton onClick={handleOpen}>
<ChevronDown />
</StyledButton>
</Wrapper>
</TabWrapper>
<StyledMenu
anchorEl={anchorEl}
open={open}
@@ -100,8 +102,8 @@ function Sheet(props: SheetProps) {
</StyledMenuItem>
</StyledMenu>
<SheetRenameDialog
isOpen={renameDialogOpen}
close={handleCloseRenameDialog}
open={renameDialogOpen}
onClose={handleCloseRenameDialog}
defaultName={name}
onNameChanged={(newName) => {
props.onRenamed(newName);
@@ -124,10 +126,39 @@ function Sheet(props: SheetProps) {
);
}
const StyledMenu = styled(Menu)``;
const StyledMenu = styled(Menu)`
& .MuiPaper-root {
border-radius: 8px;
padding: 4px 0px;
margin-left: -4px;
}
& .MuiList-root {
padding: 0;
}
`;
const StyledMenuItem = styled(MenuItem)`
display: flex;
justify-content: space-between;
font-size: 12px;
width: calc(100% - 8px);
margin: 0px 4px;
border-radius: 4px;
padding: 8px;
height: 32px;
`;
const TabWrapper = styled("div")<{ $color: string; $selected: boolean }>`
display: flex;
margin-right: 12px;
border-bottom: 3px solid ${(props) => props.$color};
line-height: 37px;
padding: 0px 4px;
align-items: center;
cursor: pointer;
font-weight: ${(props) => (props.$selected ? 600 : 400)};
background-color: ${(props) =>
props.$selected ? `${theme.palette.grey[50]}80` : "transparent"};
`;
const StyledButton = styled(Button)`
@@ -137,26 +168,27 @@ const StyledButton = styled(Button)`
padding: 0px;
color: inherit;
font-weight: inherit;
&:hover {
background-color: transparent;
}
&:active {
background-color: transparent;
}
svg {
width: 15px;
height: 15px;
transition: transform 0.2s;
}
&:hover svg {
transform: translateY(2px);
}
`;
const Wrapper = styled("div")`
display: flex;
margin-left: 20px;
border-bottom: 3px solid;
border-top: 3px solid white;
line-height: 34px;
align-items: center;
cursor: pointer;
`;
const Name = styled("div")`
font-size: 12px;
margin-right: 5px;
text-wrap: nowrap;
user-select: none;
`;
export default Sheet;
export default SheetTab;

View File

@@ -6,11 +6,11 @@ import { theme } from "../../theme";
import { NAVIGATION_HEIGHT } from "../constants";
import { StyledButton } from "../toolbar";
import type { WorkbookState } from "../workbookState";
import SheetListMenu from "./menus";
import Sheet from "./sheet";
import SheetListMenu from "./SheetListMenu";
import SheetTab from "./SheetTab";
import type { SheetOptions } from "./types";
export interface NavigationProps {
export interface SheetTabBarProps {
sheets: SheetOptions[];
selectedIndex: number;
workbookState: WorkbookState;
@@ -21,7 +21,7 @@ export interface NavigationProps {
onSheetDeleted: () => void;
}
function Navigation(props: NavigationProps) {
function SheetTabBar(props: SheetTabBarProps) {
const { t } = useTranslation();
const { workbookState, onSheetSelected, sheets, selectedIndex } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
@@ -35,24 +35,27 @@ function Navigation(props: NavigationProps) {
return (
<Container>
<StyledButton
title={t("navigation.add_sheet")}
$pressed={false}
onClick={props.onAddBlankSheet}
>
<Plus />
</StyledButton>
<StyledButton
onClick={handleClick}
title={t("navigation.sheet_list")}
$pressed={false}
>
<Menu />
</StyledButton>
<LeftButtonsContainer>
<StyledButton
title={t("navigation.add_sheet")}
$pressed={false}
onClick={props.onAddBlankSheet}
>
<Plus />
</StyledButton>
<StyledButton
onClick={handleClick}
title={t("navigation.sheet_list")}
$pressed={false}
>
<Menu />
</StyledButton>
</LeftButtonsContainer>
<VerticalDivider />
<Sheets>
<SheetInner>
{sheets.map((tab, index) => (
<Sheet
<SheetTab
key={tab.sheetId}
name={tab.name}
color={tab.color}
@@ -77,8 +80,8 @@ function Navigation(props: NavigationProps) {
</Advert>
<SheetListMenu
anchorEl={anchorEl}
isOpen={open}
close={handleClose}
open={open}
onClose={handleClose}
sheetOptionsList={sheets}
onSheetSelected={(index) => {
onSheetSelected(index);
@@ -92,6 +95,8 @@ function Navigation(props: NavigationProps) {
// Note I have to specify the font-family in every component that can be considered stand-alone
const Container = styled("div")`
display: flex;
flex-direction: row;
position: absolute;
bottom: 0px;
left: 0px;
@@ -99,10 +104,10 @@ const Container = styled("div")`
display: flex;
height: ${NAVIGATION_HEIGHT}px;
align-items: center;
padding-left: 12px;
padding: 0px 12px;
font-family: Inter;
background-color: #fff;
border-top: 1px solid #e0e0e0;
background-color: ${theme.palette.common.white};
border-top: 1px solid ${theme.palette.grey["300"]};
`;
const Sheets = styled("div")`
@@ -110,6 +115,9 @@ const Sheets = styled("div")`
overflow: hidden;
overflow-x: auto;
scrollbar-width: none;
padding-left: 12px;
display: flex;
flex-direction: row;
`;
const SheetInner = styled("div")`
@@ -119,8 +127,8 @@ const SheetInner = styled("div")`
const Advert = styled("a")`
display: flex;
align-items: center;
color: #f2994a;
padding: 0px 12px;
color: ${theme.palette.primary.main};
padding: 0px 0px 0px 12px;
font-size: 12px;
text-decoration: none;
border-left: 1px solid ${theme.palette.grey["300"]};
@@ -133,4 +141,19 @@ const Advert = styled("a")`
}
`;
export default Navigation;
const LeftButtonsContainer = styled("div")`
display: flex;
flex-direction: row;
gap: 4px;
padding-right: 12px;
`;
const VerticalDivider = styled("div")`
height: 100%;
width: 0px;
@media (max-width: 769px) {
border-right: 1px solid ${theme.palette.grey["200"]};
}
`;
export default SheetTabBar;

View File

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

View File

@@ -38,7 +38,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
const { t } = useTranslation();
const [borderSelected, setBorderSelected] = useState<BorderType | null>(null);
const [borderColor, setBorderColor] = useState("#000000");
const [borderColor, setBorderColor] = useState(theme.palette.common.white);
const [borderStyle, setBorderStyle] = useState(BorderStyle.Thin);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const [stylePickerOpen, setStylePickerOpen] = useState(false);
@@ -62,7 +62,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: We reset the styles, every time we open (or close) the widget
useEffect(() => {
setBorderSelected(null);
setBorderColor("#000000");
setBorderColor(theme.palette.common.white);
setBorderStyle(BorderStyle.Thin);
}, [properties.open]);
@@ -240,31 +240,21 @@ const BorderPicker = (properties: BorderPickerProps) => {
</Borders>
<Divider />
<Styles>
<ButtonWrapper onClick={() => setColorPickerOpen(true)}>
<Button
type="button"
$pressed={false}
disabled={false}
ref={borderColorButton}
title={t("toolbar.borders.color")}
>
<PencilLine />
</Button>
<ButtonWrapper
onClick={() => setColorPickerOpen(true)}
ref={borderColorButton}
>
<PencilLine />
<div style={{ flexGrow: 2 }}>Border color</div>
<ChevronRightStyled />
</ButtonWrapper>
<ButtonWrapper
onClick={() => setStylePickerOpen(true)}
ref={borderStyleButton}
>
<Button
type="button"
$pressed={false}
disabled={false}
title={t("toolbar.borders.style")}
>
<BorderStyleIcon />
</Button>
<BorderStyleIcon />
<div style={{ flexGrow: 2 }}>Border style</div>
<ChevronRightStyled />
</ButtonWrapper>
@@ -281,6 +271,14 @@ const BorderPicker = (properties: BorderPickerProps) => {
}}
anchorEl={borderColorButton}
open={colorPickerOpen}
anchorOrigin={{
vertical: "top", // Keep vertical alignment at the top
horizontal: "right", // Set horizontal alignment to right
}}
transformOrigin={{
vertical: "top", // Keep vertical alignment at the top
horizontal: "left", // Set horizontal alignment to left
}}
/>
<StyledPopover
open={stylePickerOpen}
@@ -288,8 +286,10 @@ const BorderPicker = (properties: BorderPickerProps) => {
setStylePickerOpen(false);
}}
anchorEl={borderStyleButton.current}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: 38, horizontal: -6 }}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<BorderStyleDialog>
<LineWrapper
@@ -336,12 +336,12 @@ const LineWrapper = styled("div")<LineWrapperProperties>`
align-items: center;
background-color: ${({ $checked }): string => {
if ($checked) {
return "#EEEEEE;";
return theme.palette.grey["200"];
}
return "inherit;";
}};
&:hover {
border: 1px solid #eeeeee;
border: 1px solid ${theme.palette.grey["200"]};
}
padding: 8px;
cursor: pointer;
@@ -351,52 +351,59 @@ const LineWrapper = styled("div")<LineWrapperProperties>`
const SolidLine = styled("div")`
width: 68px;
border-top: 1px solid #333333;
border-top: 1px solid ${theme.palette.grey["900"]};
`;
const MediumLine = styled("div")`
width: 68px;
border-top: 2px solid #333333;
border-top: 2px solid ${theme.palette.grey["900"]};
`;
const ThickLine = styled("div")`
width: 68px;
border-top: 3px solid #333333;
border-top: 1px solid ${theme.palette.grey["900"]};
`;
const Divider = styled("div")`
display: inline-flex;
heigh: 1px;
border-bottom: 1px solid #eee;
margin-left: 0px;
margin-right: 0px;
width: 100%;
margin: auto;
border-top: 1px solid ${theme.palette.grey["200"]};
`;
const Borders = styled("div")`
display: flex;
flex-direction: column;
padding-bottom: 4px;
gap: 4px;
padding: 4px;
`;
const Styles = styled("div")`
display: flex;
flex-direction: column;
padding: 4px;
`;
const Line = styled("div")`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
`;
const ButtonWrapper = styled("div")`
display: flex;
flex-direction: row;
align-items: center;
border-radius: 4px;
gap: 8px;
&:hover {
background-color: #eee;
border-top-color: ${(): string => theme.palette.grey["400"]};
background-color: ${theme.palette.grey["200"]};
border-top-color: ${(): string => theme.palette.grey["200"]};
}
cursor: pointer;
padding: 8px;
svg {
width: 16px;
height: 16px;
}
`;
const BorderStyleDialog = styled("div")`
@@ -409,7 +416,7 @@ const BorderStyleDialog = styled("div")`
const StyledPopover = styled(Popover)`
.MuiPopover-paper {
border-radius: 10px;
border-radius: 8px;
border: 0px solid ${({ theme }): string => theme.palette.background.default};
box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
}
@@ -425,7 +432,6 @@ const StyledPopover = styled(Popover)`
const BorderPickerDialog = styled("div")`
background: ${({ theme }): string => theme.palette.background.default};
padding: 4px;
display: flex;
flex-direction: column;
`;
@@ -444,10 +450,8 @@ const Button = styled("button")<TypeButtonProperties>(
alignItems: "center",
justifyContent: "center",
// fontSize: "26px",
border: "0px solid #fff",
border: `0px solid ${theme.palette.common.white}`,
borderRadius: "4px",
marginRight: "5px",
transition: "all 0.2s",
cursor: "pointer",
padding: "0px",
};
@@ -460,13 +464,15 @@ const Button = styled("button")<TypeButtonProperties>(
}
return {
...result,
borderTop: $underlinedColor ? "3px solid #FFF" : "none",
borderTop: $underlinedColor
? `3px solid ${theme.palette.common.white}`
: "none",
borderBottom: $underlinedColor ? `3px solid ${$underlinedColor}` : "none",
color: "#21243A",
color: `${theme.palette.grey["900"]}`,
backgroundColor: $pressed ? theme.palette.grey["200"] : "inherit",
"&:hover": {
backgroundColor: "#F1F2F8",
borderTopColor: "#F1F2F8",
outline: `1px solid ${theme.palette.grey["200"]}`,
borderTopColor: theme.palette.grey["200"],
},
svg: {
width: "16px",

View File

@@ -1,2 +0,0 @@
export { default } from "./navigation";
export type { NavigationProps } from "./navigation";

View File

@@ -1,6 +1,7 @@
import type {
BorderOptions,
HorizontalAlignment,
Model,
VerticalAlignment,
} from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
@@ -18,10 +19,11 @@ import {
Grid2x2X,
Italic,
PaintBucket,
Paintbrush2,
PaintRoller,
Percent,
Redo2,
Strikethrough,
Tags,
Type,
Underline,
Undo2,
@@ -34,6 +36,7 @@ import {
DecimalPlacesIncreaseIcon,
} from "../icons";
import { theme } from "../theme";
import NameManagerDialog from "./NameManagerDialog";
import BorderPicker from "./borderPicker";
import ColorPicker from "./colorPicker";
import { TOOLBAR_HEIGHT } from "./constants";
@@ -60,6 +63,7 @@ type ToolbarProperties = {
onFillColorPicked: (hex: string) => void;
onNumberFormatPicked: (numberFmt: string) => void;
onBorderChanged: (border: BorderOptions) => void;
onNamedRangesUpdate: () => void;
fillColor: string;
fontColor: string;
bold: boolean;
@@ -72,12 +76,14 @@ type ToolbarProperties = {
numFmt: string;
showGridLines: boolean;
onToggleShowGridLines: (show: boolean) => void;
model: Model;
};
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 fontColorButton = useRef(null);
const fillColorButton = useRef(null);
@@ -114,7 +120,7 @@ function Toolbar(properties: ToolbarProperties) {
onClick={properties.onCopyStyles}
title={t("toolbar.copy_styles")}
>
<Paintbrush2 />
<PaintRoller />
</StyledButton>
<Divider />
<StyledButton
@@ -183,8 +189,7 @@ function Toolbar(properties: ToolbarProperties) {
title={t("toolbar.format_number")}
sx={{
width: "40px", // Keep in sync with anchorOrigin in FormatMenu above
fontSize: "13px",
fontWeight: 400,
padding: "0px 4px",
}}
>
{"123"}
@@ -341,6 +346,18 @@ function Toolbar(properties: ToolbarProperties) {
>
{properties.showGridLines ? <Grid2x2Check /> : <Grid2x2X />}
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
onClick={() => {
setNameManagerDialogOpen(true);
}}
disabled={!canEdit}
title={t("toolbar.name_manager")}
>
<Tags />
</StyledButton>
<ColorPicker
color={properties.fontColor}
@@ -376,6 +393,18 @@ function Toolbar(properties: ToolbarProperties) {
anchorEl={borderButton}
open={borderPickerOpen}
/>
<NameManagerDialog
open={nameManagerDialogOpen}
onClose={() => {
setNameManagerDialogOpen(false);
}}
onSave={() => {
console.log(
"update NamedRanges in model => properties.onNamedRangesUpdate",
);
}}
model={properties.model}
/>
</ToolbarContainer>
);
}
@@ -391,7 +420,9 @@ const ToolbarContainer = styled("div")`
font-family: Inter;
border-radius: 4px 4px 0px 0px;
overflow-x: auto;
padding-left: 11px;
padding: 0px 12px;
gap: 4px;
scrollbar-width: none;
`;
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
@@ -399,15 +430,16 @@ export const StyledButton = styled("button")<TypeButtonProperties>(
({ disabled, $pressed, $underlinedColor }) => {
const result = {
width: "24px",
minWidth: "24px",
height: "24px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: "26px",
border: "0px solid #fff",
borderRadius: "2px",
marginRight: "5px",
fontSize: "12px",
border: `0px solid ${theme.palette.common.white}`,
borderRadius: "4px",
transition: "all 0.2s",
outline: `1px solid ${theme.palette.common.white}`,
cursor: "pointer",
backgroundColor: "white",
padding: "0px",
@@ -419,19 +451,28 @@ export const StyledButton = styled("button")<TypeButtonProperties>(
if (disabled) {
return {
...result,
color: theme.palette.grey["600"],
color: theme.palette.grey["400"],
cursor: "default",
};
}
return {
...result,
borderTop: $underlinedColor ? "3px solid #FFF" : "none",
borderTop: $underlinedColor
? `3px solid ${theme.palette.common.white}`
: "none",
borderBottom: $underlinedColor ? `3px solid ${$underlinedColor}` : "none",
color: "#21243A",
backgroundColor: $pressed ? "#EEE" : "#FFF",
color: theme.palette.grey["900"],
backgroundColor: $pressed
? theme.palette.grey["300"]
: theme.palette.common.white,
"&:hover": {
backgroundColor: "#F1F2F8",
borderTopColor: "#F1F2F8",
transition: "all 0.2s",
outline: `1px solid ${theme.palette.grey["200"]}`,
borderTopColor: theme.palette.common.white,
},
"&:active": {
backgroundColor: theme.palette.grey["300"],
outline: `1px solid ${theme.palette.grey["300"]}`,
},
};
},
@@ -439,10 +480,9 @@ export const StyledButton = styled("button")<TypeButtonProperties>(
const Divider = styled("div")({
width: "0px",
height: "10px",
borderLeft: "1px solid #E0E0E0",
marginLeft: "5px",
marginRight: "10px",
height: "12px",
borderLeft: `1px solid ${theme.palette.grey["300"]}`,
margin: "0px 12px",
});
export default Toolbar;

View File

@@ -6,6 +6,7 @@ import type {
} from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
import { useCallback, useEffect, useRef, useState } from "react";
import SheetTabBar from "./SheetTabBar/SheetTabBar";
import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
@@ -16,7 +17,6 @@ import {
getNewClipboardId,
} from "./clipboard";
import FormulaBar from "./formulabar";
import Navigation from "./navigation/navigation";
import Toolbar from "./toolbar";
import useKeyboardNavigation from "./useKeyboardNavigation";
import { type NavigationKey, getCellAddress } from "./util";
@@ -113,6 +113,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
updateRangeStyle("num_fmt", numberFmt);
};
const onNamedRangesUpdate = () => {
// update named ranges in model
}
const onCopyStyles = () => {
const {
sheet,
@@ -390,7 +394,12 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}
data.set(Number.parseInt(row, 10), rowMap);
}
model.pasteFromClipboard(source.area, data, source.type === "cut");
model.pasteFromClipboard(
source.sheet,
source.area,
data,
source.type === "cut",
);
setRedrawId((id) => id + 1);
} else if (mimeType === "text/plain") {
const {
@@ -416,6 +425,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}}
onCopy={(event: React.ClipboardEvent) => {
const data = model.copyToClipboard();
const sheet = model.getSelectedSheet();
// '2024-10-18T14:07:37.599Z'
let clipboardId = sessionStorage.getItem(
@@ -443,6 +453,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
type: "copy",
area: data.range,
sheetData,
sheet,
clipboardId,
});
event.clipboardData.setData("text/plain", data.csv);
@@ -452,6 +463,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}}
onCut={(event: React.ClipboardEvent) => {
const data = model.copyToClipboard();
const sheet = model.getSelectedSheet();
// '2024-10-18T14:07:37.599Z'
let clipboardId = sessionStorage.getItem(
@@ -479,6 +491,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
type: "cut",
area: data.range,
sheetData,
sheet,
clipboardId,
});
event.clipboardData.setData("text/plain", data.csv);
@@ -550,6 +563,8 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
model.setShowGridLines(sheet, show);
setRedrawId((id) => id + 1);
}}
onNamedRangesUpdate={onNamedRangesUpdate}
model={model}
/>
<FormulaBar
cellAddress={cellAddress()}
@@ -572,7 +587,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}}
/>
<Navigation
<SheetTabBar
sheets={info}
selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}

View File

@@ -1,7 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="2" x2="16" y2="2" stroke="#000"/>
<!-- Dashes and gaps of the same size -->
<line x1="0" y1="8" x2="16" y2="8" stroke-dasharray="2.28 2.28" stroke="#000"/>
<!-- Dashes and gaps of different sizes -->
<line x1="0" y1="14" x2="16" y2="14" stroke-dasharray="1 2" stroke="#000"/>
<path d="M3 8H2M14 8H13M7 8H5M11 8H9M14 4H2M2.01 12H2M4.01 12H4M6.01 12H6M8.01 12H8M10.01 12H10M12.01 12H12M14.01 12H14" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 290 B

View File

@@ -1,77 +1,81 @@
{
"toolbar": {
"redo": "Redo",
"undo": "Undo",
"copy_styles": "Copy styles",
"euro": "Format as Euro",
"percentage": "Format as Percentage",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"strike_through": "Strikethrough",
"align_left": "Align left",
"align_right": "Align right",
"align_center": "Align center",
"format_number": "Format number",
"font_color": "Font color",
"fill_color": "Fill color",
"decimal_places_increase": "Increase decimal places",
"decimal_places_decrease": "Decrease decimal places",
"show_hide_grid_lines": "Show/hide grid lines",
"vertical_align_bottom": "Align bottom",
"vertical_align_middle": " Align middle",
"vertical_align_top": "Align top",
"format_menu": {
"auto": "Auto",
"number": "Number",
"percentage": "Percentage",
"currency_eur": "Euro (EUR)",
"currency_usd": "Dollar (USD)",
"currency_gbp": "British Pound (GBD)",
"date_short": "Short date",
"date_long": "Long date",
"custom": "Custom",
"number_example": "1,000.00",
"percentage_example": "10%",
"currency_eur_example": "",
"currency_usd_example": "$",
"currency_gbp_example": "£",
"date_short_example": "09/24/2024",
"date_long_example": "Tuesday, September 24, 2024"
},
"borders": {
"title": "Borders",
"all": "All borders",
"inner": "Inner borders",
"outer": "Outer borders",
"top": "Top borders",
"bottom": "Bottom borders",
"clear": "Clear borders",
"left": "Left borders",
"right": "Right borders",
"horizontal": "Horizontal borders",
"vertical": "Vertical borders",
"color": "Border color",
"style": "Border style"
}
},
"num_fmt": {
"title": "Custom number format",
"label": "Number format",
"save": "Save"
},
"sheet_rename": {
"rename": "Save",
"label": "New name",
"title": "Rename Sheet"
},
"formula_input": {
"update": "Update",
"label": "Formula",
"title": "Update formula"
},
"navigation": {
"add_sheet": "Add sheet",
"sheet_list": "Sheet list"
}
"toolbar": {
"redo": "Redo",
"undo": "Undo",
"copy_styles": "Copy styles",
"euro": "Format as Euro",
"percentage": "Format as Percentage",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"strike_through": "Strikethrough",
"align_left": "Align left",
"align_right": "Align right",
"align_center": "Align center",
"format_number": "Format number",
"font_color": "Font color",
"fill_color": "Fill color",
"decimal_places_increase": "Increase decimal places",
"decimal_places_decrease": "Decrease decimal places",
"show_hide_grid_lines": "Show/hide grid lines",
"name_manager": "Name manager",
"vertical_align_bottom": "Align bottom",
"vertical_align_middle": " Align middle",
"vertical_align_top": "Align top",
"format_menu": {
"auto": "Auto",
"number": "Number",
"percentage": "Percentage",
"currency_eur": "Euro (EUR)",
"currency_usd": "Dollar (USD)",
"currency_gbp": "British Pound (GBD)",
"date_short": "Short date",
"date_long": "Long date",
"custom": "Custom",
"number_example": "1,000.00",
"percentage_example": "10%",
"currency_eur_example": "",
"currency_usd_example": "$",
"currency_gbp_example": "£",
"date_short_example": "09/24/2024",
"date_long_example": "Tuesday, September 24, 2024"
},
"borders": {
"title": "Borders",
"all": "All borders",
"inner": "Inner borders",
"outer": "Outer borders",
"top": "Top borders",
"bottom": "Bottom borders",
"clear": "Clear borders",
"left": "Left borders",
"right": "Right borders",
"horizontal": "Horizontal borders",
"vertical": "Vertical borders",
"color": "Border color",
"style": "Border style"
}
},
"num_fmt": {
"title": "Custom number format",
"label": "Number format",
"save": "Save"
},
"sheet_rename": {
"rename": "Save",
"label": "New name",
"title": "Rename Sheet"
},
"formula_input": {
"update": "Update",
"label": "Formula",
"title": "Update formula"
},
"navigation": {
"add_sheet": "Add sheet",
"sheet_list": "Sheet list"
},
"name_manager_dialog": {
"help": "Learn more about Named Ranges"
}
}