Compare commits
17 Commits
feature/ni
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b0dbc598f | ||
|
|
8844b80c51 | ||
|
|
0f8f345aae | ||
|
|
3191e12b93 | ||
|
|
61cecb7af5 | ||
|
|
fdeae2c771 | ||
|
|
3e9c69f122 | ||
|
|
c1c43143cc | ||
|
|
763b43a590 | ||
|
|
8dbfe07392 | ||
|
|
e39bfe912a | ||
|
|
9bbf94e033 | ||
|
|
0194912845 | ||
|
|
1d4d84bb57 | ||
|
|
e841c17aca | ||
|
|
f2c43f2070 | ||
|
|
32b1f8ef4e |
@@ -717,7 +717,7 @@ impl Parser {
|
|||||||
return Node::ParseErrorKind {
|
return Node::ParseErrorKind {
|
||||||
formula: self.lexer.get_formula(),
|
formula: self.lexer.get_formula(),
|
||||||
position: 0,
|
position: 0,
|
||||||
message: "sheet not found".to_string(),
|
message: format!("sheet not found: {}", context.sheet),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -850,7 +850,7 @@ impl Parser {
|
|||||||
return Node::ParseErrorKind {
|
return Node::ParseErrorKind {
|
||||||
formula: self.lexer.get_formula(),
|
formula: self.lexer.get_formula(),
|
||||||
position: 0,
|
position: 0,
|
||||||
message: "sheet not found".to_string(),
|
message: format!("sheet not found: {}", context.sheet),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -878,7 +878,7 @@ impl Parser {
|
|||||||
return Node::ParseErrorKind {
|
return Node::ParseErrorKind {
|
||||||
formula: self.lexer.get_formula(),
|
formula: self.lexer.get_formula(),
|
||||||
position: 0,
|
position: 0,
|
||||||
message: "sheet not found".to_string(),
|
message: format!("table sheet not found: {}", table.sheet_name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ impl Model {
|
|||||||
}
|
}
|
||||||
// None of the cases matched so we return the default
|
// None of the cases matched so we return the default
|
||||||
// If there is an even number of args is the last one otherwise is #N/A
|
// If there is an even number of args is the last one otherwise is #N/A
|
||||||
if args_count % 2 == 0 {
|
if args_count.is_multiple_of(2) {
|
||||||
return self.evaluate_node_in_context(&args[args_count - 1], cell);
|
return self.evaluate_node_in_context(&args[args_count - 1], cell);
|
||||||
}
|
}
|
||||||
CalcResult::Error {
|
CalcResult::Error {
|
||||||
@@ -262,7 +262,7 @@ impl Model {
|
|||||||
if args_count < 2 {
|
if args_count < 2 {
|
||||||
return CalcResult::new_args_number_error(cell);
|
return CalcResult::new_args_number_error(cell);
|
||||||
}
|
}
|
||||||
if args_count % 2 != 0 {
|
if !args_count.is_multiple_of(2) {
|
||||||
// Missing value for last condition
|
// Missing value for last condition
|
||||||
return CalcResult::new_args_number_error(cell);
|
return CalcResult::new_args_number_error(cell);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ impl Model {
|
|||||||
// FIXME: This function shares a lot of code with apply_ifs. Can we merge them?
|
// FIXME: This function shares a lot of code with apply_ifs. Can we merge them?
|
||||||
pub(crate) fn fn_countifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
|
pub(crate) fn fn_countifs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
|
||||||
let args_count = args.len();
|
let args_count = args.len();
|
||||||
if args_count < 2 || args_count % 2 == 1 {
|
if args_count < 2 || !args_count.is_multiple_of(2) {
|
||||||
return CalcResult::new_args_number_error(cell);
|
return CalcResult::new_args_number_error(cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +476,7 @@ impl Model {
|
|||||||
F: FnMut(f64),
|
F: FnMut(f64),
|
||||||
{
|
{
|
||||||
let args_count = args.len();
|
let args_count = args.len();
|
||||||
if args_count < 3 || args_count % 2 == 0 {
|
if args_count < 3 || args_count.is_multiple_of(2) {
|
||||||
return Err(CalcResult::new_args_number_error(cell));
|
return Err(CalcResult::new_args_number_error(cell));
|
||||||
}
|
}
|
||||||
let arg_0 = self.evaluate_node_in_context(&args[0], cell);
|
let arg_0 = self.evaluate_node_in_context(&args[0], cell);
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
Example usage:
|
Example usage:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { Model } from '@ironcalc/wasm';
|
import { Model } from '@ironcalc/nodejs';
|
||||||
|
|
||||||
const model = new Model("Workbook1", "en", "UTC");
|
const model = new Model("Workbook1", "en", "UTC");
|
||||||
|
|
||||||
model.setUserInput(0, 1, 1, "=1+1");
|
model.setUserInput(0, 1, 1, "=1+1");
|
||||||
const result1 = model.getFormattedCellValue(0, 1, 1);
|
|
||||||
|
|
||||||
console.log('Cell value', result1);
|
const result1 = model.getFormattedCellValue(0, 1, 1);
|
||||||
|
console.log('Cell value', result1); // "#ERROR"
|
||||||
|
|
||||||
|
model.evaluate();
|
||||||
|
|
||||||
|
const resultAfterEvaluate = model.getFormattedCellValue(0, 1, 1);
|
||||||
|
console.log('Cell value', resultAfterEvaluate); // 2
|
||||||
|
|
||||||
let result2 = model.getCellStyle(0, 1, 1);
|
let result2 = model.getCellStyle(0, 1, 1);
|
||||||
console.log('Cell style', result2);
|
console.log('Cell style', result2);
|
||||||
|
|||||||
@@ -67,3 +67,7 @@ Using IronCalc, a complex number is a string of the form "1+j3".
|
|||||||
"#N/A" => #N/A
|
"#N/A" => #N/A
|
||||||
|
|
||||||
## Arrays
|
## Arrays
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
A reference is a pointer to a single cell or a range of cells. The reference can either be entered manually, for example "A4", or as the result of a calculation, such as the OFFSET Function or the INDIRECT Function. A reference can also be built, for example with the Colon (\:) Operator.
|
||||||
BIN
docs/src/functions/images/hyperbolicarccosine-curve.png
Normal file
BIN
docs/src/functions/images/hyperbolicarccosine-curve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/src/functions/images/hyperbolicarcsine-curve.png
Normal file
BIN
docs/src/functions/images/hyperbolicarcsine-curve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
docs/src/functions/images/hyperbolicarctangent-curve.png
Normal file
BIN
docs/src/functions/images/hyperbolicarctangent-curve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -16,7 +16,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
|
|||||||
| CHOOSE | <Badge type="tip" text="Available" /> | – |
|
| CHOOSE | <Badge type="tip" text="Available" /> | – |
|
||||||
| CHOOSECOLS | <Badge type="info" text="Not implemented yet" /> | – |
|
| CHOOSECOLS | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| CHOOSEROWS | <Badge type="info" text="Not implemented yet" /> | – |
|
| CHOOSEROWS | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| COLUMN | <Badge type="tip" text="Available" /> | – |
|
| COLUMN | <Badge type="tip" text="Available" /> | [COLUMN](lookup_and_reference/column) |
|
||||||
| COLUMNS | <Badge type="tip" text="Available" /> | – |
|
| COLUMNS | <Badge type="tip" text="Available" /> | – |
|
||||||
| DROP | <Badge type="info" text="Not implemented yet" /> | – |
|
| DROP | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| EXPAND | <Badge type="info" text="Not implemented yet" /> | – |
|
| EXPAND | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
|
|||||||
@@ -4,8 +4,28 @@ outline: deep
|
|||||||
lang: en-US
|
lang: en-US
|
||||||
---
|
---
|
||||||
|
|
||||||
# COLUMN
|
# COLUMN function
|
||||||
|
## Overview
|
||||||
::: warning
|
The COLUMN Function in IronCalc is a lookup & reference formula that is used to query and return the column number of a referenced Column or Cell.
|
||||||
🚧 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).
|
## Usage
|
||||||
:::
|
### Syntax
|
||||||
|
**COLUMN(<span title="Reference" style="color:#1E88E5">reference</span>) => <span title="Number" style="color:#1E88E5">column</span>**
|
||||||
|
### Argument descriptions
|
||||||
|
* *reference* ([cell](/features/value-types#references), [optional](/features/optional-arguments.md)). The number of the cell you wish to reference the column number of.
|
||||||
|
### Additional guidance
|
||||||
|
* When referencing a range of cells, only the column number of the left most cell will be returned.
|
||||||
|
* You are also able to reference complete columns instead of individual cells.
|
||||||
|
### Returned value
|
||||||
|
COLUMN returns the [number](/features/value-types#numbers) of the specific cell or column which is being referenced.
|
||||||
|
### Error conditions
|
||||||
|
* IronCalc currently does not support the referencing of cells with names.
|
||||||
|
## Details
|
||||||
|
The COLUMN Function can only be used to display the correlating number of a single column within a Sheet. If you wish to show the number of columns used within a specific range, you can use the COLUMNS Function.
|
||||||
|
## Examples
|
||||||
|
### No Cell Reference
|
||||||
|
When no cell reference is made, the formula uses **=COLUMN()**. This will then output the column number of the cell where the formula is placed.<br><br>For example, if the formula is placed in cell A1, then "1" will be displayed.
|
||||||
|
### With Cell Reference
|
||||||
|
When a cell reference is made, the formula uses **=COLUMN([Referenced Cell])**. This will then output the column number of the referenced cell, regardless of where the formula is placed in the sheet.<br><br>For example, if the cell B1 is the referenced cell, "2" will be the output of the formula no matter where it is placed in the sheet.<br><br>**Note:** references do not always have to be specific cells, you can also reference complete columns. For example, **=COLUMN(B:B)** would also result in an output of "2".
|
||||||
|
### Range References
|
||||||
|
The COLUMN function can also be used to reference a range of Cells or Columns. In this case only the most left-hand column will be the resulting output.<br><br>For example, **=COLUMN(A1:J1)** will result in the ouput of "1".
|
||||||
|
## Links
|
||||||
@@ -13,16 +13,16 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
|
|||||||
| --------------- | ---------------------------------------------- | ------------- |
|
| --------------- | ---------------------------------------------- | ------------- |
|
||||||
| ABS | <Badge type="tip" text="Available" /> | – |
|
| ABS | <Badge type="tip" text="Available" /> | – |
|
||||||
| ACOS | <Badge type="tip" text="Available" /> | [ACOS](math_and_trigonometry/acos) |
|
| ACOS | <Badge type="tip" text="Available" /> | [ACOS](math_and_trigonometry/acos) |
|
||||||
| ACOSH | <Badge type="tip" text="Available" /> | – |
|
| ACOSH | <Badge type="tip" text="Available" /> | [ACOSH](math_and_trigonometry/acosh) |
|
||||||
| ACOT | <Badge type="info" text="Not implemented yet" /> | – |
|
| ACOT | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| ACOTH | <Badge type="info" text="Not implemented yet" /> | – |
|
| ACOTH | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| AGGREGATE | <Badge type="info" text="Not implemented yet" /> | – |
|
| AGGREGATE | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| ARABIC | <Badge type="info" text="Not implemented yet" /> | – |
|
| ARABIC | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| ASIN | <Badge type="tip" text="Available" /> | [ASIN](math_and_trigonometry/asin) |
|
| ASIN | <Badge type="tip" text="Available" /> | [ASIN](math_and_trigonometry/asin) |
|
||||||
| ASINH | <Badge type="tip" text="Available" /> | – |
|
| ASINH | <Badge type="tip" text="Available" /> | [ASINH](math_and_trigonometry/asinh) |
|
||||||
| ATAN | <Badge type="tip" text="Available" /> | [ATAN](math_and_trigonometry/atan) |
|
| ATAN | <Badge type="tip" text="Available" /> | [ATAN](math_and_trigonometry/atan) |
|
||||||
| ATAN2 | <Badge type="tip" text="Available" /> | – |
|
| ATAN2 | <Badge type="tip" text="Available" /> | [ATAN2](math_and_trigonometry/atan2) |
|
||||||
| ATANH | <Badge type="tip" text="Available" /> | – |
|
| ATANH | <Badge type="tip" text="Available" /> | [ATANH](math_and_trigonometry/atanh) |
|
||||||
| BASE | <Badge type="info" text="Not implemented yet" /> | – |
|
| BASE | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| CEILING | <Badge type="info" text="Not implemented yet" /> | – |
|
| CEILING | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| CEILING.MATH | <Badge type="info" text="Not implemented yet" /> | – |
|
| CEILING.MATH | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
@@ -49,9 +49,9 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
|
|||||||
| ISO.CEILING | <Badge type="info" text="Not implemented yet" /> | – |
|
| ISO.CEILING | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| LCM | <Badge type="info" text="Not implemented yet" /> | – |
|
| LCM | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| LET | <Badge type="info" text="Not implemented yet" /> | – |
|
| LET | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| LN | <Badge type="info" text="Not implemented yet" /> | – |
|
| LN | <Badge type="info" text="Available" /> | – |
|
||||||
| LOG | <Badge type="info" text="Not implemented yet" /> | – |
|
| LOG | <Badge type="info" text="Available" /> | – |
|
||||||
| LOG10 | <Badge type="info" text="Not implemented yet" /> | – |
|
| LOG10 | <Badge type="info" text="Available" /> | – |
|
||||||
| MDETERM | <Badge type="info" text="Not implemented yet" /> | – |
|
| MDETERM | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| MINVERSE | <Badge type="info" text="Not implemented yet" /> | – |
|
| MINVERSE | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| MMULT | <Badge type="info" text="Not implemented yet" /> | – |
|
| MMULT | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
@@ -80,11 +80,11 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
|
|||||||
| SIN | <Badge type="tip" text="Available" /> | [SIN](math_and_trigonometry/sin) |
|
| SIN | <Badge type="tip" text="Available" /> | [SIN](math_and_trigonometry/sin) |
|
||||||
| SINH | <Badge type="tip" text="Available" /> | [SINH](math_and_trigonometry/sinh) |
|
| SINH | <Badge type="tip" text="Available" /> | [SINH](math_and_trigonometry/sinh) |
|
||||||
| SQRT | <Badge type="tip" text="Available" /> | – |
|
| SQRT | <Badge type="tip" text="Available" /> | – |
|
||||||
| SQRTPI | <Badge type="info" text="Not implemented yet" /> | – |
|
| SQRTPI | <Badge type="info" text="Available" /> | – |
|
||||||
| SUBTOTAL | <Badge type="info" text="Not implemented yet" /> | – |
|
| SUBTOTAL | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| SUM | <Badge type="tip" text="Available" /> | – |
|
| SUM | <Badge type="tip" text="Available" /> | – |
|
||||||
| SUMIF | <Badge type="tip" text="Available" /> | – |
|
| SUMIF | <Badge type="tip" text="Available" /> | – |
|
||||||
| SUMIFS | <Badge type="info" text="Not implemented yet" /> | – |
|
| SUMIFS | <Badge type="info" text="Available" /> | – |
|
||||||
| SUMPRODUCT | <Badge type="info" text="Not implemented yet" /> | – |
|
| SUMPRODUCT | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| SUMSQ | <Badge type="info" text="Not implemented yet" /> | – |
|
| SUMSQ | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| SUMX2MY2 | <Badge type="info" text="Not implemented yet" /> | – |
|
| SUMX2MY2 | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
|
|||||||
@@ -3,9 +3,40 @@ layout: doc
|
|||||||
outline: deep
|
outline: deep
|
||||||
lang: en-US
|
lang: en-US
|
||||||
---
|
---
|
||||||
|
# ACOSH function
|
||||||
|
## Overview
|
||||||
|
ACOSH is a function of the Math and Trigonometry category that calculates the inverse hyperbolic cosine (hyperbolic arccosine) of a number, returning a non-negative value in the range [0, +∞).
|
||||||
|
## Usage
|
||||||
|
### Syntax
|
||||||
|
**ACOSH(<span title="Number" style="color:#1E88E5">number</span>) => <span title="Number" style="color:#1E88E5">acosh</span>**
|
||||||
|
### Argument descriptions
|
||||||
|
* *number* ([number](/features/value-types#numbers), required). The value whose hyperbolic arccosine is to be calculated. The value must be greater than or equal to 1.
|
||||||
|
|
||||||
# ACOSH
|
### Additional guidance
|
||||||
|
The hyperbolic arccosine function is defined as:
|
||||||
|
|
||||||
::: 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).
|
\operatorname{acosh}(x) = \ln(x + \sqrt{x^2 - 1})
|
||||||
:::
|
$$
|
||||||
|
|
||||||
|
### Returned value
|
||||||
|
ACOSH returns a [number](/features/value-types#numbers) in the range [0, +∞) that is the hyperbolic arccosine of the specified value, expressed in radians.
|
||||||
|
### Error conditions
|
||||||
|
* In common with many other IronCalc functions, ACOSH propagates errors that are found in its argument.
|
||||||
|
* If no argument, or more than one argument, is supplied, then ACOSH 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 ACOSH returns the [`#VALUE!`](/features/error-types.md#value) error.
|
||||||
|
* If the value of the *number* argument is less than 1, then ACOSH returns the [`#NUM!`](/features/error-types.md#num) error.
|
||||||
|
<!--@include: ../markdown-snippets/error-type-details.txt-->
|
||||||
|
## Details
|
||||||
|
* The ACOSH function utilizes the *acosh()* method provided by the [Rust Standard Library](https://doc.rust-lang.org/std/).
|
||||||
|
* The figure below illustrates the output of the ACOSH function for values $x \geq 1$ in the range [0, +∞).
|
||||||
|
<center><img src="/functions/images/hyperbolicarccosine-curve.png" width="350" alt="Graph showing acosh(x) for x ≥ 1."></center>
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
[See some examples in IronCalc](https://app.ironcalc.com/?example=acosh).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
* For more information about inverse hyperbolic functions, visit Wikipedia's [Inverse hyperbolic functions](https://en.wikipedia.org/wiki/Inverse_hyperbolic_functions) page.
|
||||||
|
* See also IronCalc's [COSH](/functions/math_and_trigonometry/cosh), [ASINH](/functions/math_and_trigonometry/asinh) and [ATANH](/functions/math_and_trigonometry/atanh) functions.
|
||||||
|
* Visit Microsoft Excel's [ACOSH function](https://support.microsoft.com/en-us/office/acosh-function-e3992cc1-103f-4e72-9f04-624b9ef5ebfe) page.
|
||||||
|
* Both [Google Sheets](https://support.google.com/docs/answer/3093391) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/ACOSH) provide versions of the ACOSH function.
|
||||||
@@ -4,8 +4,36 @@ outline: deep
|
|||||||
lang: en-US
|
lang: en-US
|
||||||
---
|
---
|
||||||
|
|
||||||
# ASINH
|
# ASINH function
|
||||||
|
## Overview
|
||||||
|
ASINH is a function of the Math and Trigonometry category that calculates the inverse hyperbolic sine (hyperbolic arcsine) of a number, returning the hyperbolic angle expressed in radians.
|
||||||
|
## Usage
|
||||||
|
### Syntax
|
||||||
|
**ASINH(<span title="Number" style="color:#1E88E5">number</span>) => <span title="Number" style="color:#1E88E5">asinh</span>**
|
||||||
|
### Argument descriptions
|
||||||
|
* *number* ([number](/features/value-types#numbers), required). The value whose inverse hyperbolic sine is to be calculated.
|
||||||
|
### Additional guidance
|
||||||
|
The hyperbolic arcsine function is defined as:
|
||||||
|
$$
|
||||||
|
\operatorname{asinh}(x) = \ln\!\left(x + \sqrt{x^2 + 1}\,\right)
|
||||||
|
$$
|
||||||
|
### Returned value
|
||||||
|
ASINH returns a real [number](/features/value-types#numbers) in the range (-∞, +∞) that is the hyperbolic arcsine of the specified value, expressed in radians.
|
||||||
|
### Error conditions
|
||||||
|
* In common with many other IronCalc functions, ASINH propagates errors that are found in its argument.
|
||||||
|
* If no argument, or more than one argument, is supplied, then ASINH 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 ASINH returns the [`#VALUE!`](/features/error-types.md#value) error.
|
||||||
|
<!--@include: ../markdown-snippets/error-type-details.txt-->
|
||||||
|
## Details
|
||||||
|
* The ASINH function utilizes the *asinh()* method provided by the [Rust Standard Library](https://doc.rust-lang.org/std/).
|
||||||
|
* The figure below illustrates the output of the ASINH function.
|
||||||
|
<center><img src="/functions/images/hyperbolicarcsine-curve.png" width="350" alt="Graph showing asinh(x)."></center>
|
||||||
|
|
||||||
::: warning
|
## Examples
|
||||||
🚧 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).
|
[See some examples in IronCalc](https://app.ironcalc.com/?example=asinh).
|
||||||
:::
|
|
||||||
|
## Links
|
||||||
|
* For more information about inverse hyperbolic functions, visit Wikipedia's [Inverse hyperbolic functions](https://en.wikipedia.org/wiki/Inverse_hyperbolic_functions) page.
|
||||||
|
* See also IronCalc's [SINH](/functions/math_and_trigonometry/sinh), [ACOSH](/functions/math_and_trigonometry/acosh) and [ATANH](/functions/math_and_trigonometry/atanh) functions.
|
||||||
|
* Visit Microsoft Excel's [ASINH function](https://support.microsoft.com/de-de/office/asinh-function-62b4f5b6-d9cc-4c17-9d04-aa5371806c74) page.
|
||||||
|
* Both [Google Sheets](https://support.google.com/docs/answer/3093393) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/ASINH) provide versions of the ASINH function.
|
||||||
@@ -4,8 +4,34 @@ outline: deep
|
|||||||
lang: en-US
|
lang: en-US
|
||||||
---
|
---
|
||||||
|
|
||||||
# ATAN2
|
# ATAN2 function
|
||||||
|
## Overview
|
||||||
|
ATAN2 is a function of the Math and Trigonometry category that calculates the inverse tangent (arctangent) for the specified *x* and *y* coordinates. The arctangent returns the angle defined by the x-axis and a line defined by the origin and a point with coordinates (x,y). The returned angle is expressed in radians, in the range (-$\pi$, +$\pi$].
|
||||||
|
## Usage
|
||||||
|
### Syntax
|
||||||
|
**ATAN2(<span title="Number" style="color:#1E88E5">x,y</span>) => <span title="Number" style="color:#1E88E5">atan2</span>**
|
||||||
|
### Argument descriptions
|
||||||
|
* *x* ([number](/features/value-types#numbers), required). Value of the x coordinate.
|
||||||
|
* *y* ([number](/features/value-types#numbers), required). Value of the y coordinate.
|
||||||
|
### Additional guidance
|
||||||
|
If the returned value is positive, it represents a counterclockwise angle from the x-axis, while a negative value represents a clockwise angle.
|
||||||
|
ATAN2(x,y) is equivalent to ATAN(y/x), with the difference that the x argument in ATAN2 can be 0.
|
||||||
|
### Returned value
|
||||||
|
ATAN2 returns a number in radians in the range (-$\pi$, +$\pi$] that is the inverse tangent for the specified x and y coordinates.
|
||||||
|
### Error conditions
|
||||||
|
* In common with many other IronCalc functions, ATAN2 propagates errors that are found in its argument.
|
||||||
|
* If no argument, or arguments other than 2, are supplied, then ATAN2 returns the [`#ERROR!`](/features/error-types.md#error) error.
|
||||||
|
* If the value of either the *x* or *y* argument is not (or cannot be converted to) a [number](/features/value-types#numbers), then ATAN2 returns the [`#VALUE!`](/features/error-types.md#value) error.
|
||||||
|
* If both *x* and *y* are equal to 0, ATAN2 returns a [`#DIV/0!`](/features/error-types.md#div-0) error.
|
||||||
|
<!--@include: ../markdown-snippets/error-type-details.txt-->
|
||||||
|
## Details
|
||||||
|
* The ATAN2 function utilizes the *atan2()* method provided by the [Rust Standard Library](https://doc.rust-lang.org/std/).
|
||||||
|
## Examples
|
||||||
|
[See some examples in IronCalc](https://app.ironcalc.com/?example=atan2).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
* For more information about inverse trigonometric functions, visit Wikipedia's [Inverse trigonometric functions](https://en.wikipedia.org/wiki/Inverse_trigonometric_functions) page.
|
||||||
|
* See also IronCalc's [ATAN](/functions/math_and_trigonometry/atan), [TAN](/functions/math_and_trigonometry/tan) and [ASIN](/functions/math_and_trigonometry/asin) functions.
|
||||||
|
* Visit Microsoft Excel's [ATAN2 function](https://support.microsoft.com/en-us/office/atan2-function-51123ced-348c-416a-b2e2-833f7868569f) page.
|
||||||
|
* Both [Google Sheets](https://support.google.com/docs/answer/3093468) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/ATAN2) provide versions of the ATAN2 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).
|
|
||||||
:::
|
|
||||||
@@ -4,8 +4,37 @@ outline: deep
|
|||||||
lang: en-US
|
lang: en-US
|
||||||
---
|
---
|
||||||
|
|
||||||
# ATANH
|
# ATANH function
|
||||||
|
## Overview
|
||||||
|
ATANH is a function of the Math and Trigonometry category that calculates the inverse hyperbolic tangent (hyperbolic arctangent) of a number in the range (-1, +1), returning the hyperbolic angle expressed in radians.
|
||||||
|
## Usage
|
||||||
|
### Syntax
|
||||||
|
**ATANH(<span title="Number" style="color:#1E88E5">number</span>) => <span title="Number" style="color:#1E88E5">atanh</span>**
|
||||||
|
### Argument descriptions
|
||||||
|
* *number* ([number](/features/value-types#numbers), required). The value whose inverse hyperbolic tangent is to be calculated, in the range (-1,+1).
|
||||||
|
### Additional guidance
|
||||||
|
The hyperbolic arctangent function is defined as:
|
||||||
|
$$
|
||||||
|
\operatorname{atanh}(x) = \tfrac{1}{2}\,\ln\!\left(\dfrac{1+x}{1-x}\right),\quad |x| < 1
|
||||||
|
$$
|
||||||
|
### Returned value
|
||||||
|
ATANH returns a real [number](/features/value-types#numbers) in the range (-∞, +∞) that is the hyperbolic arctangent of the specified value, expressed in radians.
|
||||||
|
### Error conditions
|
||||||
|
* In common with many other IronCalc functions, ATANH propagates errors that are found in its argument.
|
||||||
|
* If no argument, or more than one argument, is supplied, then ATANH 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 ATANH returns the [`#VALUE!`](/features/error-types.md#value) error.
|
||||||
|
* If the value of the *number* argument lies outside the domain (-1, +1), then ATANH returns the [`#NUM!`](/features/error-types.md#num) error.
|
||||||
|
<!--@include: ../markdown-snippets/error-type-details.txt-->
|
||||||
|
## Details
|
||||||
|
* The ATANH function utilizes the *atanh()* method provided by the [Rust Standard Library](https://doc.rust-lang.org/std/).
|
||||||
|
* The figure below illustrates the output of the ATANH function.
|
||||||
|
<center><img src="/functions/images/hyperbolicarctangent-curve.png" width="350" alt="Graph showing atanh(x)."></center>
|
||||||
|
|
||||||
::: warning
|
## Examples
|
||||||
🚧 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).
|
[See some examples in IronCalc](https://app.ironcalc.com/?example=atanh).
|
||||||
:::
|
|
||||||
|
## Links
|
||||||
|
* For more information about inverse hyperbolic functions, visit Wikipedia's [Inverse hyperbolic functions](https://en.wikipedia.org/wiki/Inverse_hyperbolic_functions) page.
|
||||||
|
* See also IronCalc's [ASINH](/functions/math_and_trigonometry/asinh), [ACOSH](/functions/math_and_trigonometry/acosh) and [TANH](/functions/math_and_trigonometry/tanh) functions.
|
||||||
|
* Visit Microsoft Excel's [ATANH function](https://support.microsoft.com/de-de/office/atanh-function-453534d1-76a5-4f17-8c04-c3f2feee0dd5) page.
|
||||||
|
* Both [Google Sheets](https://support.google.com/docs/answer/3093397) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/ATANH) provide versions of the ATANH function.
|
||||||
@@ -7,6 +7,5 @@ lang: en-US
|
|||||||
# LN
|
# LN
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
🚧 This function is not yet available in IronCalc.
|
🚧 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).
|
||||||
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
|
|
||||||
:::
|
:::
|
||||||
@@ -7,6 +7,5 @@ lang: en-US
|
|||||||
# LOG
|
# LOG
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
🚧 This function is not yet available in IronCalc.
|
🚧 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).
|
||||||
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
|
|
||||||
:::
|
:::
|
||||||
@@ -7,6 +7,5 @@ lang: en-US
|
|||||||
# LOG10
|
# LOG10
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
🚧 This function is not yet available in IronCalc.
|
🚧 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).
|
||||||
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
|
|
||||||
:::
|
:::
|
||||||
@@ -2,6 +2,7 @@ import "./App.css";
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FileBar } from "./components/FileBar";
|
import { FileBar } from "./components/FileBar";
|
||||||
|
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
|
||||||
import {
|
import {
|
||||||
get_documentation_model,
|
get_documentation_model,
|
||||||
get_model,
|
get_model,
|
||||||
@@ -9,7 +10,10 @@ import {
|
|||||||
} from "./components/rpc";
|
} from "./components/rpc";
|
||||||
import {
|
import {
|
||||||
createNewModel,
|
createNewModel,
|
||||||
|
deleteModelByUuid,
|
||||||
deleteSelectedModel,
|
deleteSelectedModel,
|
||||||
|
// getModelsMetadata,
|
||||||
|
// getSelectedUuid,
|
||||||
loadModelFromStorageOrCreate,
|
loadModelFromStorageOrCreate,
|
||||||
saveModelToStorage,
|
saveModelToStorage,
|
||||||
saveSelectedModelInStorage,
|
saveSelectedModelInStorage,
|
||||||
@@ -21,6 +25,7 @@ import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook";
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [model, setModel] = useState<Model | null>(null);
|
const [model, setModel] = useState<Model | null>(null);
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function start() {
|
async function start() {
|
||||||
@@ -79,48 +84,80 @@ function App() {
|
|||||||
// We could use context for model, but the problem is that it should initialized to null.
|
// We could use context for model, but the problem is that it should initialized to null.
|
||||||
// Passing the property down makes sure it is always defined.
|
// Passing the property down makes sure it is always defined.
|
||||||
|
|
||||||
|
// Handlers for model changes that also update our models state
|
||||||
|
const handleNewModel = () => {
|
||||||
|
const newModel = createNewModel();
|
||||||
|
setModel(newModel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetModel = (uuid: string) => {
|
||||||
|
const newModel = selectModelFromStorage(uuid);
|
||||||
|
if (newModel) {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteModel = () => {
|
||||||
|
const newModel = deleteSelectedModel();
|
||||||
|
if (newModel) {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteModelByUuid = (uuid: string) => {
|
||||||
|
const newModel = deleteModelByUuid(uuid);
|
||||||
|
if (newModel) {
|
||||||
|
setModel(newModel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<AppContainer>
|
||||||
<FileBar
|
<LeftDrawer
|
||||||
model={model}
|
open={isDrawerOpen}
|
||||||
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
|
onClose={() => setIsDrawerOpen(false)}
|
||||||
const blob = await uploadFile(arrayBuffer, fileName);
|
newModel={handleNewModel}
|
||||||
|
setModel={handleSetModel}
|
||||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
onDelete={handleDeleteModelByUuid}
|
||||||
const newModel = Model.from_bytes(bytes);
|
|
||||||
saveModelToStorage(newModel);
|
|
||||||
|
|
||||||
setModel(newModel);
|
|
||||||
}}
|
|
||||||
newModel={() => {
|
|
||||||
setModel(createNewModel());
|
|
||||||
}}
|
|
||||||
setModel={(uuid: string) => {
|
|
||||||
const newModel = selectModelFromStorage(uuid);
|
|
||||||
if (newModel) {
|
|
||||||
setModel(newModel);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
const newModel = deleteSelectedModel();
|
|
||||||
if (newModel) {
|
|
||||||
setModel(newModel);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<IronCalc model={model} />
|
|
||||||
</Wrapper>
|
<MainContent isDrawerOpen={isDrawerOpen}>
|
||||||
|
<FileBar
|
||||||
|
model={model}
|
||||||
|
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
|
||||||
|
const blob = await uploadFile(arrayBuffer, fileName);
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
const newModel = Model.from_bytes(bytes);
|
||||||
|
saveModelToStorage(newModel);
|
||||||
|
setModel(newModel);
|
||||||
|
}}
|
||||||
|
newModel={handleNewModel}
|
||||||
|
setModel={handleSetModel}
|
||||||
|
onDelete={handleDeleteModel}
|
||||||
|
isDrawerOpen={isDrawerOpen}
|
||||||
|
setIsDrawerOpen={setIsDrawerOpen}
|
||||||
|
/>
|
||||||
|
<IronCalc model={model} />
|
||||||
|
</MainContent>
|
||||||
|
</AppContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Wrapper = styled("div")`
|
const AppContainer = styled("div")`
|
||||||
margin: 0px;
|
display: flex;
|
||||||
padding: 0px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MainContent = styled("div")<{ isDrawerOpen: boolean }>`
|
||||||
|
margin-left: ${({ isDrawerOpen }) => (isDrawerOpen ? "0px" : "-264px")};
|
||||||
|
transition: margin-left 0.3s ease;
|
||||||
|
width: ${({ isDrawerOpen }) =>
|
||||||
|
isDrawerOpen ? "calc(100% - 264px)" : "100%"};
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: absolute;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Loading = styled("div")`
|
const Loading = styled("div")`
|
||||||
|
|||||||
@@ -12,19 +12,9 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
|
|||||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.getElementById("root");
|
|
||||||
if (root) {
|
|
||||||
root.style.filter = "blur(2px)";
|
|
||||||
}
|
|
||||||
if (deleteButtonRef.current) {
|
if (deleteButtonRef.current) {
|
||||||
deleteButtonRef.current.focus();
|
deleteButtonRef.current.focus();
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
const root = document.getElementById("root");
|
|
||||||
if (root) {
|
|
||||||
root.style.filter = "none";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import type { Model } from "@ironcalc/workbook";
|
import type { Model } from "@ironcalc/workbook";
|
||||||
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
|
import { Button, IconButton } from "@mui/material";
|
||||||
|
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { FileMenu } from "./FileMenu";
|
import { DesktopMenu, MobileMenu } from "./FileMenu";
|
||||||
import { HelpMenu } from "./HelpMenu";
|
|
||||||
import { ShareButton } from "./ShareButton";
|
import { ShareButton } from "./ShareButton";
|
||||||
import ShareWorkbookDialog from "./ShareWorkbookDialog";
|
import ShareWorkbookDialog from "./ShareWorkbookDialog";
|
||||||
import { WorkbookTitle } from "./WorkbookTitle";
|
import { WorkbookTitle } from "./WorkbookTitle";
|
||||||
@@ -30,6 +30,8 @@ export function FileBar(properties: {
|
|||||||
setModel: (key: string) => void;
|
setModel: (key: string) => void;
|
||||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
isDrawerOpen: boolean;
|
||||||
|
setIsDrawerOpen: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const spacerRef = useRef<HTMLDivElement>(null);
|
const spacerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,24 +47,49 @@ export function FileBar(properties: {
|
|||||||
}
|
}
|
||||||
}, [width]);
|
}, [width]);
|
||||||
|
|
||||||
|
// Common handler functions for both menu types
|
||||||
|
const handleDownload = async () => {
|
||||||
|
const model = properties.model;
|
||||||
|
const bytes = model.toBytes();
|
||||||
|
const fileName = model.getName();
|
||||||
|
await downloadModel(bytes, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileBarWrapper>
|
<FileBarWrapper>
|
||||||
<StyledDesktopLogo />
|
<DrawerButton
|
||||||
<StyledIronCalcIcon />
|
$isDrawerOpen={properties.isDrawerOpen}
|
||||||
<Divider />
|
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
|
||||||
<FileMenu
|
disableRipple
|
||||||
newModel={properties.newModel}
|
title="Toggle sidebar"
|
||||||
setModel={properties.setModel}
|
>
|
||||||
onModelUpload={properties.onModelUpload}
|
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||||
onDownload={async () => {
|
</DrawerButton>
|
||||||
const model = properties.model;
|
<DesktopButtonsWrapper>
|
||||||
const bytes = model.toBytes();
|
<DesktopMenu
|
||||||
const fileName = model.getName();
|
newModel={properties.newModel}
|
||||||
await downloadModel(bytes, fileName);
|
setModel={properties.setModel}
|
||||||
}}
|
onModelUpload={properties.onModelUpload}
|
||||||
onDelete={properties.onDelete}
|
onDownload={handleDownload}
|
||||||
/>
|
onDelete={properties.onDelete}
|
||||||
<HelpMenu />
|
/>
|
||||||
|
<FileBarButton
|
||||||
|
disableRipple
|
||||||
|
onClick={() => window.open("https://docs.ironcalc.com", "_blank")}
|
||||||
|
>
|
||||||
|
Help
|
||||||
|
</FileBarButton>
|
||||||
|
</DesktopButtonsWrapper>
|
||||||
|
<MobileButtonsWrapper>
|
||||||
|
<MobileMenu
|
||||||
|
newModel={properties.newModel}
|
||||||
|
setModel={properties.setModel}
|
||||||
|
onModelUpload={properties.onModelUpload}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onDelete={properties.onDelete}
|
||||||
|
/>
|
||||||
|
</MobileButtonsWrapper>
|
||||||
|
<Spacer ref={spacerRef} />
|
||||||
<WorkbookTitleWrapper>
|
<WorkbookTitleWrapper>
|
||||||
<WorkbookTitle
|
<WorkbookTitle
|
||||||
name={properties.model.getName()}
|
name={properties.model.getName()}
|
||||||
@@ -88,12 +115,8 @@ export function FileBar(properties: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We want the workbook title to be exactly an the center of the page,
|
|
||||||
// so we need an absolute position
|
|
||||||
const WorkbookTitleWrapper = styled("div")`
|
const WorkbookTitleWrapper = styled("div")`
|
||||||
position: absolute;
|
position: relative;
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// The "Spacer" component occupies as much space as possible between the menu and the share button
|
// The "Spacer" component occupies as much space as possible between the menu and the share button
|
||||||
@@ -101,38 +124,79 @@ const Spacer = styled("div")`
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledDesktopLogo = styled(IronCalcLogo)`
|
const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>`
|
||||||
width: 120px;
|
margin-left: 8px;
|
||||||
margin-left: 12px;
|
height: 32px;
|
||||||
@media (max-width: 769px) {
|
width: 32px;
|
||||||
display: none;
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")};
|
||||||
|
svg {
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke: #757575;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
`;
|
&:hover {
|
||||||
|
background-color: #f2f2f2;
|
||||||
const StyledIronCalcIcon = styled(IronCalcIcon)`
|
}
|
||||||
width: 36px;
|
&:active {
|
||||||
margin-left: 10px;
|
background-color: #e0e0e0;
|
||||||
@media (min-width: 769px) {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
const Divider = styled("div")`
|
|
||||||
margin: 0px 8px 0px 16px;
|
|
||||||
height: 12px;
|
|
||||||
border-left: 1px solid #e0e0e0;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// The container must be relative positioned so we can position the title absolutely
|
// The container must be relative positioned so we can position the title absolutely
|
||||||
const FileBarWrapper = styled("div")`
|
const FileBarWrapper = styled("div")`
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
min-height: 60px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DesktopButtonsWrapper = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MobileButtonsWrapper = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
@media (min-width: 601px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FileBarButton = styled(Button)`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
height: 32px;
|
||||||
|
width: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
min-width: 0px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: #333333;
|
||||||
|
&:hover {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DialogContainer = styled("div")`
|
const DialogContainer = styled("div")`
|
||||||
|
|||||||
@@ -1,93 +1,165 @@
|
|||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import { Menu, MenuItem, Modal } from "@mui/material";
|
import { Button, IconButton, Menu, MenuItem, Modal } from "@mui/material";
|
||||||
import { Check, FileDown, FileUp, Plus, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
EllipsisVertical,
|
||||||
|
FileDown,
|
||||||
|
FileUp,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
|
import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
|
||||||
import UploadFileDialog from "./UploadFileDialog";
|
import UploadFileDialog from "./UploadFileDialog";
|
||||||
import { getModelsMetadata, getSelectedUuid } from "./storage";
|
import { getModelsMetadata, getSelectedUuid } from "./storage";
|
||||||
|
|
||||||
|
export function DesktopMenu(props: {
|
||||||
|
newModel: () => void;
|
||||||
|
setModel: (key: string) => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
|
||||||
|
const anchorElement = useRef<HTMLButtonElement>(
|
||||||
|
null as unknown as HTMLButtonElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FileBarButton
|
||||||
|
onClick={(): void => setFileMenuOpen(!isFileMenuOpen)}
|
||||||
|
ref={anchorElement}
|
||||||
|
disableRipple
|
||||||
|
isOpen={isFileMenuOpen}
|
||||||
|
>
|
||||||
|
File
|
||||||
|
</FileBarButton>
|
||||||
|
<FileMenu
|
||||||
|
newModel={props.newModel}
|
||||||
|
setModel={props.setModel}
|
||||||
|
onDownload={props.onDownload}
|
||||||
|
onModelUpload={props.onModelUpload}
|
||||||
|
onDelete={props.onDelete}
|
||||||
|
isFileMenuOpen={isFileMenuOpen}
|
||||||
|
setFileMenuOpen={setFileMenuOpen}
|
||||||
|
setMobileMenuOpen={() => {}}
|
||||||
|
anchorElement={anchorElement}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileMenu(props: {
|
||||||
|
newModel: () => void;
|
||||||
|
setModel: (key: string) => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const [isMobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
|
||||||
|
const anchorElement = useRef<HTMLButtonElement>(
|
||||||
|
null as unknown as HTMLButtonElement,
|
||||||
|
);
|
||||||
|
const [fileMenuAnchorEl, setFileMenuAnchorEl] = useState<HTMLElement | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuButton
|
||||||
|
onClick={(): void => setMobileMenuOpen(true)}
|
||||||
|
ref={anchorElement}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</MenuButton>
|
||||||
|
<StyledMenu
|
||||||
|
open={isMobileMenuOpen}
|
||||||
|
onClose={(): void => setMobileMenuOpen(false)}
|
||||||
|
anchorEl={anchorElement.current}
|
||||||
|
>
|
||||||
|
<MenuItemWrapper
|
||||||
|
onClick={(event) => {
|
||||||
|
setFileMenuOpen(true);
|
||||||
|
setFileMenuAnchorEl(event.currentTarget);
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<MenuItemText>File</MenuItemText>
|
||||||
|
<ChevronRight />
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItemWrapper
|
||||||
|
onClick={() => {
|
||||||
|
window.open("https://docs.ironcalc.com", "_blank");
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<MenuItemText>Help</MenuItemText>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
</StyledMenu>
|
||||||
|
<FileMenu
|
||||||
|
newModel={props.newModel}
|
||||||
|
setModel={props.setModel}
|
||||||
|
onDownload={props.onDownload}
|
||||||
|
onModelUpload={props.onModelUpload}
|
||||||
|
onDelete={props.onDelete}
|
||||||
|
isFileMenuOpen={isFileMenuOpen}
|
||||||
|
setFileMenuOpen={setFileMenuOpen}
|
||||||
|
setMobileMenuOpen={setMobileMenuOpen}
|
||||||
|
anchorElement={anchorElement}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FileMenu(props: {
|
export function FileMenu(props: {
|
||||||
newModel: () => void;
|
newModel: () => void;
|
||||||
setModel: (key: string) => void;
|
setModel: (key: string) => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
isFileMenuOpen: boolean;
|
||||||
|
setFileMenuOpen: (open: boolean) => void;
|
||||||
|
setMobileMenuOpen: (open: boolean) => void;
|
||||||
|
anchorElement: React.RefObject<HTMLButtonElement>;
|
||||||
}) {
|
}) {
|
||||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
|
||||||
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
|
||||||
const anchorElement = useRef<HTMLButtonElement>(null);
|
|
||||||
const models = getModelsMetadata();
|
const models = getModelsMetadata();
|
||||||
const uuids = Object.keys(models);
|
|
||||||
const selectedUuid = getSelectedUuid();
|
const selectedUuid = getSelectedUuid();
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
const elements = [];
|
|
||||||
for (const uuid of uuids) {
|
|
||||||
elements.push(
|
|
||||||
<MenuItemWrapper
|
|
||||||
key={uuid}
|
|
||||||
onClick={() => {
|
|
||||||
props.setModel(uuid);
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckIndicator>
|
|
||||||
{uuid === selectedUuid ? (
|
|
||||||
<StyledIcon>
|
|
||||||
<Check />
|
|
||||||
</StyledIcon>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</CheckIndicator>
|
|
||||||
<MenuItemText
|
|
||||||
style={{
|
|
||||||
maxWidth: "240px",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{models[uuid]}
|
|
||||||
</MenuItemText>
|
|
||||||
</MenuItemWrapper>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FileMenuWrapper
|
<StyledMenu
|
||||||
type="button"
|
open={props.isFileMenuOpen}
|
||||||
id="file-menu-button"
|
onClose={(): void => props.setFileMenuOpen(false)}
|
||||||
onClick={(): void => setMenuOpen(true)}
|
anchorEl={props.anchorElement.current}
|
||||||
ref={anchorElement}
|
anchorOrigin={{
|
||||||
$isActive={isMenuOpen}
|
vertical: "bottom",
|
||||||
aria-haspopup="true"
|
horizontal: "left",
|
||||||
>
|
|
||||||
File
|
|
||||||
</FileMenuWrapper>
|
|
||||||
<Menu
|
|
||||||
open={isMenuOpen}
|
|
||||||
onClose={(): void => setMenuOpen(false)}
|
|
||||||
anchorEl={anchorElement.current}
|
|
||||||
autoFocus={false}
|
|
||||||
disableRestoreFocus={true}
|
|
||||||
sx={{
|
|
||||||
"& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" },
|
|
||||||
"& .MuiList-root": { padding: "0" },
|
|
||||||
transform: "translate(-4px, 4px)",
|
|
||||||
}}
|
}}
|
||||||
slotProps={{
|
transformOrigin={{
|
||||||
list: {
|
vertical: "top",
|
||||||
"aria-labelledby": "file-menu-button",
|
horizontal: "left",
|
||||||
tabIndex: -1,
|
}}
|
||||||
},
|
// To prevent closing parent menu when interacting with submenu
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (!isImportMenuOpen && !isDeleteDialogOpen) {
|
||||||
|
props.setFileMenuOpen(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.newModel();
|
props.newModel();
|
||||||
setMenuOpen(false);
|
props.setFileMenuOpen(false);
|
||||||
|
props.setMobileMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
|
disableRipple
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<StyledIcon>
|
||||||
<Plus />
|
<Plus />
|
||||||
@@ -97,34 +169,41 @@ export function FileMenu(props: {
|
|||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setImportMenuOpen(true);
|
setImportMenuOpen(true);
|
||||||
setMenuOpen(false);
|
props.setFileMenuOpen(false);
|
||||||
|
props.setMobileMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
|
disableRipple
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<StyledIcon>
|
||||||
<FileUp />
|
<FileUp />
|
||||||
</StyledIcon>
|
</StyledIcon>
|
||||||
<MenuItemText>Import</MenuItemText>
|
<MenuItemText>Import</MenuItemText>
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuItemWrapper onClick={props.onDownload}>
|
<MenuItemWrapper
|
||||||
<StyledIcon>
|
onClick={() => {
|
||||||
<FileDown />
|
props.onDownload();
|
||||||
</StyledIcon>
|
props.setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<StyledFileDown />
|
||||||
<MenuItemText>Download (.xlsx)</MenuItemText>
|
<MenuItemText>Download (.xlsx)</MenuItemText>
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
|
<MenuDivider />
|
||||||
<MenuItemWrapper
|
<MenuItemWrapper
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
setMenuOpen(false);
|
props.setFileMenuOpen(false);
|
||||||
|
props.setMobileMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
|
disableRipple
|
||||||
>
|
>
|
||||||
<StyledIcon>
|
<StyledIcon>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
</StyledIcon>
|
</StyledIcon>
|
||||||
<MenuItemText>Delete workbook</MenuItemText>
|
<MenuItemText>Delete workbook</MenuItemText>
|
||||||
</MenuItemWrapper>
|
</MenuItemWrapper>
|
||||||
<MenuDivider />
|
</StyledMenu>
|
||||||
{elements}
|
|
||||||
</Menu>
|
|
||||||
<Modal
|
<Modal
|
||||||
open={isImportMenuOpen}
|
open={isImportMenuOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -149,7 +228,7 @@ export function FileMenu(props: {
|
|||||||
<DeleteWorkbookDialog
|
<DeleteWorkbookDialog
|
||||||
onClose={() => setDeleteDialogOpen(false)}
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
onConfirm={props.onDelete}
|
onConfirm={props.onDelete}
|
||||||
workbookName={selectedUuid ? models[selectedUuid] : ""}
|
workbookName={selectedUuid ? models[selectedUuid]?.name || "" : ""}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
@@ -167,7 +246,55 @@ const StyledIcon = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MenuDivider = styled.div`
|
const MenuButton = styled(IconButton)`
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
svg {
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke: #757575;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FileBarButton = styled(Button)<{ isOpen: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
height: 32px;
|
||||||
|
width: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
min-width: 0px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: #333333;
|
||||||
|
background-color: ${({ isOpen }) => (isOpen ? "#f2f2f2" : "none")};
|
||||||
|
&:hover {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledFileDown = styled(FileDown)`
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #333333;
|
||||||
|
padding-right: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
const MenuDivider = styled("div")`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -178,6 +305,7 @@ const MenuDivider = styled.div`
|
|||||||
const MenuItemText = styled.div`
|
const MenuItemText = styled.div`
|
||||||
color: #000;
|
color: #000;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
flex-grow: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MenuItemWrapper = styled(MenuItem)`
|
const MenuItemWrapper = styled(MenuItem)`
|
||||||
@@ -190,26 +318,19 @@ const MenuItemWrapper = styled(MenuItem)`
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
`;
|
min-height: 32px;
|
||||||
|
svg {
|
||||||
const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
|
width: 16px;
|
||||||
display: flex;
|
height: 16px;
|
||||||
align-items: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: Inter;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
&:hover {
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CheckIndicator = styled.span`
|
const StyledMenu = styled(Menu)`
|
||||||
display: flex;
|
.MuiPaper-root {
|
||||||
justify-content: center;
|
border-radius: 8px;
|
||||||
min-width: 26px;
|
padding: 4px 0px;
|
||||||
|
},
|
||||||
|
.MuiList-root {
|
||||||
|
padding: 0;
|
||||||
|
},
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import WorkbookList from "./WorkbookList";
|
||||||
|
|
||||||
|
interface DrawerContentProps {
|
||||||
|
setModel: (key: string) => void;
|
||||||
|
onDelete: (uuid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent(props: DrawerContentProps) {
|
||||||
|
const { setModel, onDelete } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentContainer>
|
||||||
|
<WorkbookList setModel={setModel} onDelete={onDelete} />
|
||||||
|
</ContentContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentContainer = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: scroll;
|
||||||
|
font-size: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default DrawerContent;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
|
||||||
|
function DrawerFooter() {
|
||||||
|
return (
|
||||||
|
<StyledDrawerFooter>
|
||||||
|
<FooterLink
|
||||||
|
href="https://docs.ironcalc.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<OpenBookIcon>
|
||||||
|
<BookOpen />
|
||||||
|
</OpenBookIcon>
|
||||||
|
<FooterLinkText>Documentation</FooterLinkText>
|
||||||
|
</FooterLink>
|
||||||
|
</StyledDrawerFooter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledDrawerFooter = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-height: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FooterLink = styled("a")`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 172px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 4px 8px 8px;
|
||||||
|
transition: gap 0.5s;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const OpenBookIcon = styled("div")`
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
svg {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
stroke: #9e9e9e;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FooterLinkText = styled("div")`
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default DrawerFooter;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import { IronCalcLogo } from "@ironcalc/workbook";
|
||||||
|
import { IconButton } from "@mui/material";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface DrawerHeaderProps {
|
||||||
|
onNewModel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ onNewModel }: DrawerHeaderProps) {
|
||||||
|
return (
|
||||||
|
<HeaderContainer>
|
||||||
|
<StyledDesktopLogo />
|
||||||
|
<AddButton onClick={onNewModel} title="New workbook">
|
||||||
|
<PlusIcon />
|
||||||
|
</AddButton>
|
||||||
|
</HeaderContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderContainer = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 8px 12px 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-height: 60px;
|
||||||
|
min-height: 60px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledDesktopLogo = styled(IronCalcLogo)`
|
||||||
|
width: 120px;
|
||||||
|
height: 28px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AddButton = styled(IconButton)`
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #333333;
|
||||||
|
stroke-width: 2px;
|
||||||
|
&:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlusIcon = styled(Plus)`
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default DrawerHeader;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import { Drawer } from "@mui/material";
|
||||||
|
import DrawerContent from "./DrawerContent";
|
||||||
|
import DrawerFooter from "./DrawerFooter";
|
||||||
|
import DrawerHeader from "./DrawerHeader";
|
||||||
|
|
||||||
|
interface LeftDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
newModel: () => void;
|
||||||
|
setModel: (key: string) => void;
|
||||||
|
onDelete: (uuid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeftDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
newModel,
|
||||||
|
setModel,
|
||||||
|
onDelete,
|
||||||
|
}: LeftDrawerProps) {
|
||||||
|
return (
|
||||||
|
<DrawerWrapper
|
||||||
|
variant="persistent"
|
||||||
|
anchor="left"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<DrawerHeader onNewModel={newModel} />
|
||||||
|
<DrawerContent setModel={setModel} onDelete={onDelete} />
|
||||||
|
|
||||||
|
<DrawerFooter />
|
||||||
|
</DrawerWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DrawerWrapper = styled(Drawer)`
|
||||||
|
width: 264px;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
|
||||||
|
.MuiDrawer-paper {
|
||||||
|
width: 264px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default LeftDrawer;
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||||
|
import {
|
||||||
|
EllipsisVertical,
|
||||||
|
FileDown,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import DeleteWorkbookDialog from "../DeleteWorkbookDialog";
|
||||||
|
import { downloadModel } from "../rpc";
|
||||||
|
import {
|
||||||
|
getModelsMetadata,
|
||||||
|
getSelectedUuid,
|
||||||
|
selectModelFromStorage,
|
||||||
|
} from "../storage";
|
||||||
|
|
||||||
|
interface WorkbookListProps {
|
||||||
|
setModel: (key: string) => void;
|
||||||
|
onDelete: (uuid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkbookList({ setModel, onDelete }: WorkbookListProps) {
|
||||||
|
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const [selectedWorkbookUuid, setSelectedWorkbookUuid] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [workbookToDelete, setWorkbookToDelete] = useState<string | null>(null);
|
||||||
|
const [intendedSelection, setIntendedSelection] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedUuid = getSelectedUuid();
|
||||||
|
|
||||||
|
// Clear intended selection when selectedUuid changes from outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (intendedSelection && selectedUuid === intendedSelection) {
|
||||||
|
setIntendedSelection(null);
|
||||||
|
}
|
||||||
|
}, [selectedUuid, intendedSelection]);
|
||||||
|
|
||||||
|
const handleMenuOpen = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
uuid: string,
|
||||||
|
) => {
|
||||||
|
console.log("Menu open", uuid);
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedWorkbookUuid(uuid);
|
||||||
|
setMenuAnchorEl(event.currentTarget);
|
||||||
|
setIntendedSelection(uuid);
|
||||||
|
setModel(uuid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
console.log(
|
||||||
|
"Menu closing, selectedWorkbookUuid:",
|
||||||
|
selectedWorkbookUuid,
|
||||||
|
"intendedSelection:",
|
||||||
|
intendedSelection,
|
||||||
|
);
|
||||||
|
setMenuAnchorEl(null);
|
||||||
|
// If we have an intended selection, make sure it's still selected
|
||||||
|
if (intendedSelection && intendedSelection !== selectedUuid) {
|
||||||
|
console.log("Re-selecting intended workbook:", intendedSelection);
|
||||||
|
setModel(intendedSelection);
|
||||||
|
}
|
||||||
|
// Don't reset selectedWorkbookUuid here - we want to keep track of which workbook was selected
|
||||||
|
// The selectedWorkbookUuid will be used for download/delete operations
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (uuid: string) => {
|
||||||
|
console.log("Delete workbook:", uuid);
|
||||||
|
setWorkbookToDelete(uuid);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
setIntendedSelection(null);
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (workbookToDelete) {
|
||||||
|
onDelete(workbookToDelete);
|
||||||
|
setWorkbookToDelete(null);
|
||||||
|
}
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setWorkbookToDelete(null);
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (uuid: string) => {
|
||||||
|
try {
|
||||||
|
const model = selectModelFromStorage(uuid);
|
||||||
|
if (model) {
|
||||||
|
const bytes = model.toBytes();
|
||||||
|
const fileName = model.getName();
|
||||||
|
await downloadModel(bytes, fileName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to download workbook:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group workbooks by creation date
|
||||||
|
const groupWorkbooks = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const millisecondsInDay = 24 * 60 * 60 * 1000;
|
||||||
|
const millisecondsIn30Days = 30 * millisecondsInDay;
|
||||||
|
|
||||||
|
const modelsCreatedToday = [];
|
||||||
|
const modelsCreatedThisMonth = [];
|
||||||
|
const olderModels = [];
|
||||||
|
const modelsMetadata = getModelsMetadata();
|
||||||
|
|
||||||
|
for (const uuid in modelsMetadata) {
|
||||||
|
const createdAt = modelsMetadata[uuid].createdAt;
|
||||||
|
const age = now - createdAt;
|
||||||
|
|
||||||
|
if (age < millisecondsInDay) {
|
||||||
|
modelsCreatedToday.push(uuid);
|
||||||
|
} else if (age < millisecondsIn30Days) {
|
||||||
|
modelsCreatedThisMonth.push(uuid);
|
||||||
|
} else {
|
||||||
|
olderModels.push(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each group by creation timestamp (newest first)
|
||||||
|
const sortByNewest = (uuids: string[]) =>
|
||||||
|
uuids.sort(
|
||||||
|
(a, b) => modelsMetadata[b].createdAt - modelsMetadata[a].createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelsCreatedToday: sortByNewest(modelsCreatedToday),
|
||||||
|
modelsCreatedThisMonth: sortByNewest(modelsCreatedThisMonth),
|
||||||
|
olderModels: sortByNewest(olderModels),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { modelsCreatedToday, modelsCreatedThisMonth, olderModels } =
|
||||||
|
groupWorkbooks();
|
||||||
|
|
||||||
|
const renderWorkbookItem = (uuid: string) => {
|
||||||
|
const isMenuOpen = menuAnchorEl !== null && selectedWorkbookUuid === uuid;
|
||||||
|
const isAnyMenuOpen = menuAnchorEl !== null;
|
||||||
|
const models = getModelsMetadata();
|
||||||
|
return (
|
||||||
|
<WorkbookListItem
|
||||||
|
key={uuid}
|
||||||
|
onClick={() => {
|
||||||
|
// Prevent clicking on list items when any menu is open
|
||||||
|
if (isAnyMenuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModel(uuid);
|
||||||
|
}}
|
||||||
|
selected={uuid === selectedUuid}
|
||||||
|
disableRipple
|
||||||
|
style={{ pointerEvents: isAnyMenuOpen ? "none" : "auto" }}
|
||||||
|
>
|
||||||
|
<StorageIndicator>
|
||||||
|
<FileSpreadsheet />
|
||||||
|
</StorageIndicator>
|
||||||
|
<WorkbookListText>{models[uuid].name}</WorkbookListText>
|
||||||
|
<EllipsisButton
|
||||||
|
onClick={(e) => handleMenuOpen(e, uuid)}
|
||||||
|
isOpen={isMenuOpen}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</EllipsisButton>
|
||||||
|
</WorkbookListItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = (title: string, uuids: string[]) => {
|
||||||
|
if (uuids.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContainer key={title}>
|
||||||
|
<SectionTitle>{title}</SectionTitle>
|
||||||
|
{uuids.map(renderWorkbookItem)}
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const models = getModelsMetadata();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderSection("Today", modelsCreatedToday)}
|
||||||
|
{renderSection("Last 30 Days", modelsCreatedThisMonth)}
|
||||||
|
{renderSection("Older", olderModels)}
|
||||||
|
|
||||||
|
<StyledMenu
|
||||||
|
anchorEl={menuAnchorEl}
|
||||||
|
open={Boolean(menuAnchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
MenuListProps={{
|
||||||
|
dense: true,
|
||||||
|
}}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "right",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItemWrapper
|
||||||
|
onClick={() => {
|
||||||
|
console.log(
|
||||||
|
"Download clicked, selectedWorkbookUuid:",
|
||||||
|
selectedWorkbookUuid,
|
||||||
|
);
|
||||||
|
if (selectedWorkbookUuid) {
|
||||||
|
handleDownload(selectedWorkbookUuid);
|
||||||
|
}
|
||||||
|
setIntendedSelection(null);
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<FileDown />
|
||||||
|
Download (.xlsx)
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItemWrapper
|
||||||
|
selected={false}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedWorkbookUuid) {
|
||||||
|
handleDeleteClick(selectedWorkbookUuid);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Delete workbook
|
||||||
|
</MenuItemWrapper>
|
||||||
|
</StyledMenu>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onClose={handleDeleteCancel}
|
||||||
|
aria-labelledby="delete-dialog-title"
|
||||||
|
aria-describedby="delete-dialog-description"
|
||||||
|
>
|
||||||
|
<DeleteWorkbookDialog
|
||||||
|
onClose={handleDeleteCancel}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
workbookName={workbookToDelete ? models[workbookToDelete].name : ""}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StorageIndicator = styled("div")`
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
svg {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
stroke: #9e9e9e;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EllipsisButton = styled("button")<{ isOpen: boolean }>`
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #333333;
|
||||||
|
stroke-width: 2px;
|
||||||
|
background-color: ${({ isOpen }) => (isOpen ? "#E0E0E0" : "none")};
|
||||||
|
opacity: ${({ isOpen }) => (isOpen ? "1" : "0.5")};
|
||||||
|
transition: opacity 0.3s, background-color 0.3s;
|
||||||
|
&:hover {
|
||||||
|
background: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background: #bdbdbd;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WorkbookListItem = styled(MenuItem)<{ selected: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 172px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 4px 8px 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
transition: gap 0.5s;
|
||||||
|
background-color: ${({ selected }) =>
|
||||||
|
selected ? "#e0e0e0 !important" : "transparent"};
|
||||||
|
|
||||||
|
/* Prevent hover effects when menu is open */
|
||||||
|
&:hover {
|
||||||
|
background-color: ${({ selected }) =>
|
||||||
|
selected ? "#e0e0e0 !important" : "transparent"};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WorkbookListText = styled("div")`
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMenu = styled(Menu)`
|
||||||
|
.MuiPaper-root {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 0px;
|
||||||
|
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.01);
|
||||||
|
},
|
||||||
|
.MuiList-root {
|
||||||
|
padding: 0;
|
||||||
|
},
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MenuDivider = styled("div")`
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-top: 1px solid #eeeeee;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MenuItemWrapper = styled(MenuItem)`
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
min-width: 140px;
|
||||||
|
margin: 0px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
height: 32px;
|
||||||
|
gap: 8px;
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionContainer = styled("div")`
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled("div")`
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9e9e9e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default WorkbookList;
|
||||||
@@ -72,10 +72,10 @@ export function WorkbookTitle(properties: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled("div")`
|
const Container = styled("div")`
|
||||||
text-align: center;
|
text-align: left;
|
||||||
padding: 8px;
|
padding: 6px 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-family: Inter;
|
font-family: Inter;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ const TitleInput = styled("input")`
|
|||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
&:focus {
|
&:focus {
|
||||||
border: 1px solid grey;
|
outline: 1px solid grey;
|
||||||
}
|
}
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { base64ToBytes, bytesToBase64 } from "./util";
|
|||||||
|
|
||||||
const MAX_WORKBOOKS = 50;
|
const MAX_WORKBOOKS = 50;
|
||||||
|
|
||||||
type ModelsMetadata = Record<string, string>;
|
type ModelsMetadata = Record<string, { name: string; createdAt: number }>;
|
||||||
|
|
||||||
export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
||||||
const uuid = localStorage.getItem("selected");
|
const uuid = localStorage.getItem("selected");
|
||||||
@@ -12,7 +12,11 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) {
|
|||||||
if (modelsJson) {
|
if (modelsJson) {
|
||||||
try {
|
try {
|
||||||
const models = JSON.parse(modelsJson);
|
const models = JSON.parse(modelsJson);
|
||||||
models[uuid] = newName;
|
if (models[uuid]) {
|
||||||
|
models[uuid].name = newName;
|
||||||
|
} else {
|
||||||
|
models[uuid] = { name: newName, createdAt: Date.now() };
|
||||||
|
}
|
||||||
localStorage.setItem("models", JSON.stringify(models));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed saving new name");
|
console.warn("Failed saving new name");
|
||||||
@@ -28,7 +32,26 @@ export function getModelsMetadata(): ModelsMetadata {
|
|||||||
if (!modelsJson) {
|
if (!modelsJson) {
|
||||||
modelsJson = "{}";
|
modelsJson = "{}";
|
||||||
}
|
}
|
||||||
return JSON.parse(modelsJson);
|
const models = JSON.parse(modelsJson);
|
||||||
|
|
||||||
|
// Migrate old format to new format
|
||||||
|
const migratedModels: ModelsMetadata = {};
|
||||||
|
for (const [uuid, value] of Object.entries(models)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// Old format: just the name string
|
||||||
|
migratedModels[uuid] = { name: value, createdAt: Date.now() };
|
||||||
|
} else if (typeof value === "object" && value !== null && "name" in value) {
|
||||||
|
// New format: object with name and createdAt
|
||||||
|
migratedModels[uuid] = value as { name: string; createdAt: number };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save migrated data back to localStorage
|
||||||
|
if (JSON.stringify(models) !== JSON.stringify(migratedModels)) {
|
||||||
|
localStorage.setItem("models", JSON.stringify(migratedModels));
|
||||||
|
}
|
||||||
|
|
||||||
|
return migratedModels;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick a different name Workbook{N} where N = 1, 2, 3
|
// Pick a different name Workbook{N} where N = 1, 2, 3
|
||||||
@@ -48,14 +71,14 @@ function getNewName(existingNames: string[]): string {
|
|||||||
|
|
||||||
export function createNewModel(): Model {
|
export function createNewModel(): Model {
|
||||||
const models = getModelsMetadata();
|
const models = getModelsMetadata();
|
||||||
const name = getNewName(Object.values(models));
|
const name = getNewName(Object.values(models).map((m) => m.name));
|
||||||
|
|
||||||
const model = new Model(name, "en", "UTC");
|
const model = new Model(name, "en", "UTC");
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
localStorage.setItem("selected", uuid);
|
localStorage.setItem("selected", uuid);
|
||||||
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
|
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
|
||||||
|
|
||||||
models[uuid] = name;
|
models[uuid] = { name, createdAt: Date.now() };
|
||||||
localStorage.setItem("models", JSON.stringify(models));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
@@ -95,7 +118,7 @@ export function saveModelToStorage(model: Model) {
|
|||||||
modelsJson = "{}";
|
modelsJson = "{}";
|
||||||
}
|
}
|
||||||
const models = JSON.parse(modelsJson);
|
const models = JSON.parse(modelsJson);
|
||||||
models[uuid] = model.getName();
|
models[uuid] = { name: model.getName(), createdAt: Date.now() };
|
||||||
localStorage.setItem("models", JSON.stringify(models));
|
localStorage.setItem("models", JSON.stringify(models));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,3 +150,37 @@ export function deleteSelectedModel(): Model | null {
|
|||||||
}
|
}
|
||||||
return selectModelFromStorage(uuids[0]);
|
return selectModelFromStorage(uuids[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteModelByUuid(uuid: string): Model | null {
|
||||||
|
localStorage.removeItem(uuid);
|
||||||
|
const metadata = getModelsMetadata();
|
||||||
|
delete metadata[uuid];
|
||||||
|
localStorage.setItem("models", JSON.stringify(metadata));
|
||||||
|
|
||||||
|
// If this was the selected model, we need to select a different one
|
||||||
|
const selectedUuid = localStorage.getItem("selected");
|
||||||
|
if (selectedUuid === uuid) {
|
||||||
|
const uuids = Object.keys(metadata);
|
||||||
|
if (uuids.length === 0) {
|
||||||
|
return createNewModel();
|
||||||
|
}
|
||||||
|
// Find the newest workbook by creation timestamp
|
||||||
|
const newestUuid = uuids.reduce((newest, current) => {
|
||||||
|
const newestTime = metadata[newest]?.createdAt || 0;
|
||||||
|
const currentTime = metadata[current]?.createdAt || 0;
|
||||||
|
return currentTime > newestTime ? current : newest;
|
||||||
|
});
|
||||||
|
return selectModelFromStorage(newestUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it wasn't the selected model, return the currently selected model
|
||||||
|
if (selectedUuid) {
|
||||||
|
const modelBytesString = localStorage.getItem(selectedUuid);
|
||||||
|
if (modelBytesString) {
|
||||||
|
return Model.from_bytes(base64ToBytes(modelBytesString));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to creating a new model if no valid selected model
|
||||||
|
return createNewModel();
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! This is primary for QA internal testing and will be superseded by an official
|
//! This is primary for QA internal testing and will be superseded by an official
|
||||||
//! IronCalc CLI.
|
//! IronCalc CLI.
|
||||||
//!
|
//!
|
||||||
//! Usage: test file.xlsx
|
//! Usage: test file.xlsx [output.icalc]
|
||||||
|
|
||||||
use std::path;
|
use std::path;
|
||||||
|
|
||||||
@@ -15,15 +15,20 @@ use ironcalc::{export::save_to_icalc, import::load_from_xlsx};
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<_> = std::env::args().collect();
|
let args: Vec<_> = std::env::args().collect();
|
||||||
if args.len() != 2 {
|
if args.len() != 2 && args.len() != 3 {
|
||||||
panic!("Usage: {} <file.xlsx>", args[0]);
|
panic!("Usage: {} <file.xlsx> [output.icalc]", args[0]);
|
||||||
}
|
}
|
||||||
// first test the file
|
// first test the file
|
||||||
let file_name = &args[1];
|
let file_name = &args[1];
|
||||||
|
|
||||||
let file_path = path::Path::new(file_name);
|
let output_file_name = if args.len() == 3 {
|
||||||
let base_name = file_path.file_stem().unwrap().to_str().unwrap();
|
&args[2]
|
||||||
let output_file_name = &format!("{base_name}.ic");
|
} else {
|
||||||
|
let file_path = path::Path::new(file_name);
|
||||||
|
let base_name = file_path.file_stem().unwrap().to_str().unwrap();
|
||||||
|
&format!("{base_name}.ic")
|
||||||
|
};
|
||||||
|
|
||||||
let model = load_from_xlsx(file_name, "en", "UTC").unwrap();
|
let model = load_from_xlsx(file_name, "en", "UTC").unwrap();
|
||||||
save_to_icalc(&model, output_file_name).unwrap();
|
save_to_icalc(&model, output_file_name).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,30 +264,29 @@ enum ParseReferenceError {
|
|||||||
// There is a similar named function in ironcalc_base. We probably should fix both at the same time.
|
// There is a similar named function in ironcalc_base. We probably should fix both at the same time.
|
||||||
// NB: Maybe use regexes for this?
|
// NB: Maybe use regexes for this?
|
||||||
fn parse_reference(s: &str) -> Result<CellReferenceRC, ParseReferenceError> {
|
fn parse_reference(s: &str) -> Result<CellReferenceRC, ParseReferenceError> {
|
||||||
let bytes = s.as_bytes();
|
|
||||||
let mut sheet_name = "".to_string();
|
let mut sheet_name = "".to_string();
|
||||||
let mut column = "".to_string();
|
let mut column = "".to_string();
|
||||||
let mut row = "".to_string();
|
let mut row = "".to_string();
|
||||||
let mut state = "sheet"; // "sheet", "col", "row"
|
let mut state = "sheet"; // "sheet", "col", "row"
|
||||||
for &byte in bytes {
|
for ch in s.chars() {
|
||||||
match state {
|
match state {
|
||||||
"sheet" => {
|
"sheet" => {
|
||||||
if byte == b'!' {
|
if ch == '!' {
|
||||||
state = "col"
|
state = "col"
|
||||||
} else {
|
} else {
|
||||||
sheet_name.push(byte as char);
|
sheet_name.push(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"col" => {
|
"col" => {
|
||||||
if byte.is_ascii_alphabetic() {
|
if ch.is_ascii_alphabetic() {
|
||||||
column.push(byte as char);
|
column.push(ch);
|
||||||
} else {
|
} else {
|
||||||
state = "row";
|
state = "row";
|
||||||
row.push(byte as char);
|
row.push(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
row.push(byte as char);
|
row.push(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,3 +1121,16 @@ pub(super) fn load_sheets<R: Read + std::io::Seek>(
|
|||||||
}
|
}
|
||||||
Ok((sheets, selected_sheet))
|
Ok((sheets, selected_sheet))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::import::worksheets::parse_reference;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_reference_works() {
|
||||||
|
let cell_reference = parse_reference("📈 Overview!B2");
|
||||||
|
assert!(cell_reference.is_ok());
|
||||||
|
let cell_reference = cell_reference.unwrap();
|
||||||
|
assert_eq!(cell_reference.sheet, "📈 Overview");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
BIN
xlsx/tests/templates/mortgage_calculator.xlsx
Normal file
BIN
xlsx/tests/templates/mortgage_calculator.xlsx
Normal file
Binary file not shown.
BIN
xlsx/tests/templates/travel_expenses_tracker.xlsx
Normal file
BIN
xlsx/tests/templates/travel_expenses_tracker.xlsx
Normal file
Binary file not shown.
@@ -472,6 +472,45 @@ fn test_exporting_merged_cells() {
|
|||||||
fs::remove_file(temp_file_name).unwrap();
|
fs::remove_file(temp_file_name).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_templates_xlsx() {
|
||||||
|
let mut entries = fs::read_dir("tests/templates/")
|
||||||
|
.unwrap()
|
||||||
|
.map(|res| res.map(|e| e.path()))
|
||||||
|
.collect::<Result<Vec<_>, io::Error>>()
|
||||||
|
.unwrap();
|
||||||
|
entries.sort();
|
||||||
|
let temp_folder = env::temp_dir();
|
||||||
|
let path = format!("{}", Uuid::new_v4());
|
||||||
|
let dir = temp_folder.join(path);
|
||||||
|
fs::create_dir(&dir).unwrap();
|
||||||
|
let mut is_error = false;
|
||||||
|
for file_path in entries {
|
||||||
|
let file_name_str = file_path.file_name().unwrap().to_str().unwrap();
|
||||||
|
let file_path_str = file_path.to_str().unwrap();
|
||||||
|
println!("Testing file: {file_path_str}");
|
||||||
|
if file_name_str.ends_with(".xlsx") && !file_name_str.starts_with('~') {
|
||||||
|
if let Err(message) = test_file(file_path_str) {
|
||||||
|
println!("Error with file: '{file_path_str}'");
|
||||||
|
println!("{message}");
|
||||||
|
is_error = true;
|
||||||
|
}
|
||||||
|
let t = test_load_and_saving(file_path_str, &dir);
|
||||||
|
if t.is_err() {
|
||||||
|
println!("Error while load and saving file: {file_path_str}");
|
||||||
|
is_error = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(&dir).unwrap();
|
||||||
|
assert!(
|
||||||
|
!is_error,
|
||||||
|
"Models were evaluated inconsistently with XLSX data."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_documentation_xlsx() {
|
fn test_documentation_xlsx() {
|
||||||
let mut entries = fs::read_dir("tests/docs/")
|
let mut entries = fs::read_dir("tests/docs/")
|
||||||
|
|||||||
Reference in New Issue
Block a user