Compare commits

..

1 Commits

Author SHA1 Message Date
varuntumbe
e7858f7aa9 adding merge cell logic processing
formatting commit

addressing testcase failures

adding one more scenario to case

adding one more scenario to case

Adding update and unmerge functions for merge cell handling

adding one more case to testcase

adding testcases to base code

adding testcase for import/export

adding documentation to some of the PUB function

fixing warnings and test warnings

adding merge cell part cell update restriction to public sytle set fns

addressing reviwers comment : Changed Mergedcell structure and its side effercts

reverting it back to non pub.

renaming update_merge_cells to just merge_cells in model

renaming *unmerge_merged_cell* to *unmerge_cells*

addressing other reviewer's comment + cosmetica naming adjustments

cosmetic changes
2024-11-29 23:13:10 +01:00
86 changed files with 1103 additions and 1601 deletions

View File

@@ -1,7 +1,10 @@
name: Coverage
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
coverage:

View File

@@ -12,7 +12,6 @@
- Fixed several issues with pasting content
- Fixed several issues with borders
- Fixed bug where columns and rows could be resized to negative width and height, respectively
## [0.2.0] - 2024-11-06 (The HN release)

View File

@@ -10,7 +10,6 @@ format:
.PHONY: tests
tests: lint
cargo test
make remove-artifacts
# Regretabbly we need to build the wasm twice, once for the nodejs tests
# and a second one for the vitest.

View File

@@ -176,3 +176,18 @@ impl Cell {
}
}
}
// Implementing methods for MergedCells struct
impl MergedCells {
pub fn is_cell_part_of_merged_cells(&self, row: i32, col: i32) -> bool {
// This is merge Mother cell so do not include this cell as part of Merged Cells
if row == self.0 && col == self.1 {
return false;
}
let result: bool = (row >= self.0 && row <= self.2) && (col >= self.1 && col <= self.3);
result
}
}

View File

@@ -6,7 +6,7 @@ pub(crate) const DEFAULT_COLUMN_WIDTH: f64 = 125.0;
pub(crate) const DEFAULT_ROW_HEIGHT: f64 = 28.0;
pub(crate) const COLUMN_WIDTH_FACTOR: f64 = 12.0;
pub(crate) const ROW_HEIGHT_FACTOR: f64 = 2.0;
pub(crate) const DEFAULT_WINDOW_HEIGHT: i64 = 600;
pub(crate) const DEFAULT_WINDOW_HEIGH: i64 = 600;
pub(crate) const DEFAULT_WINDOW_WIDTH: i64 = 800;
pub(crate) const LAST_COLUMN: i32 = 16_384;

View File

@@ -24,25 +24,6 @@ fn test_get_tokens() {
assert_eq!(l.end, 10);
}
#[test]
fn get_tokens_unicode() {
let formula = "'🇵🇭 Philippines'!A1";
let t = get_tokens(formula);
assert_eq!(t.len(), 1);
let expected = TokenType::Reference {
sheet: Some("🇵🇭 Philippines".to_string()),
row: 1,
column: 1,
absolute_column: false,
absolute_row: false,
};
let l = t.first().expect("expected token");
assert_eq!(l.token, expected);
assert_eq!(l.start, 0);
assert_eq!(l.end, 19);
}
#[test]
fn test_simple_tokens() {
assert_eq!(

View File

@@ -49,7 +49,15 @@ pub mod stringify;
pub mod walk;
#[cfg(test)]
mod tests;
mod test;
#[cfg(test)]
mod test_ranges;
#[cfg(test)]
mod test_move_formula;
#[cfg(test)]
mod test_tables;
pub(crate) fn parse_range(formula: &str) -> Result<(i32, i32, i32, i32), String> {
let mut lexer = lexer::Lexer::new(
@@ -514,6 +522,20 @@ impl Parser {
let mut absolute_row1 = left.absolute_row;
let mut absolute_row2 = right.absolute_row;
if self.lexer.is_a1_mode() {
if !left.absolute_row {
row1 -= context.row
};
if !left.absolute_column {
column1 -= context.column
};
if !right.absolute_row {
row2 -= context.row
};
if !right.absolute_column {
column2 -= context.column
};
}
if row1 > row2 {
(row2, row1) = (row1, row2);
(absolute_row2, absolute_row1) = (absolute_row1, absolute_row2);
@@ -522,22 +544,6 @@ impl Parser {
(column2, column1) = (column1, column2);
(absolute_column2, absolute_column1) = (absolute_column1, absolute_column2);
}
if self.lexer.is_a1_mode() {
if !absolute_row1 {
row1 -= context.row
};
if !absolute_column1 {
column1 -= context.column
};
if !absolute_row2 {
row2 -= context.row
};
if !absolute_column2 {
column2 -= context.column
};
}
match sheet_index {
Some(index) => Node::RangeKind {
sheet_name: sheet,

View File

@@ -456,65 +456,11 @@ fn stringify(
};
format!("{}{}{}", x, kind, y)
}
OpPowerKind { left, right } => {
let x = match **left {
BooleanKind(_)
| NumberKind(_)
| StringKind(_)
| ReferenceKind { .. }
| RangeKind { .. }
| WrongReferenceKind { .. }
| VariableKind(_)
| WrongRangeKind { .. } => {
stringify(left, context, displace_data, use_original_name)
}
OpRangeKind { .. }
| OpConcatenateKind { .. }
| OpProductKind { .. }
| OpPowerKind { .. }
| FunctionKind { .. }
| InvalidFunctionKind { .. }
| ArrayKind(_)
| UnaryKind { .. }
| ErrorKind(_)
| ParseErrorKind { .. }
| OpSumKind { .. }
| CompareKind { .. }
| EmptyArgKind => format!(
"({})",
stringify(left, context, displace_data, use_original_name)
),
};
let y = match **right {
BooleanKind(_)
| NumberKind(_)
| StringKind(_)
| ReferenceKind { .. }
| RangeKind { .. }
| WrongReferenceKind { .. }
| VariableKind(_)
| WrongRangeKind { .. } => {
stringify(right, context, displace_data, use_original_name)
}
OpRangeKind { .. }
| OpConcatenateKind { .. }
| OpProductKind { .. }
| OpPowerKind { .. }
| FunctionKind { .. }
| InvalidFunctionKind { .. }
| ArrayKind(_)
| UnaryKind { .. }
| ErrorKind(_)
| ParseErrorKind { .. }
| OpSumKind { .. }
| CompareKind { .. }
| EmptyArgKind => format!(
"({})",
stringify(right, context, displace_data, use_original_name)
),
};
format!("{}^{}", x, y)
}
OpPowerKind { left, right } => format!(
"{}^{}",
stringify(left, context, displace_data, use_original_name),
stringify(right, context, displace_data, use_original_name)
),
InvalidFunctionKind { name, args } => {
format_function(name, args, context, displace_data, use_original_name)
}

View File

@@ -3,11 +3,17 @@
use std::collections::HashMap;
use crate::expressions::lexer::LexerMode;
use crate::expressions::parser::stringify::{
to_rc_format, to_string, to_string_displaced, DisplaceData,
use crate::expressions::parser::stringify::DisplaceData;
use super::super::types::CellReferenceRC;
use super::Parser;
use super::{
super::parser::{
stringify::{to_rc_format, to_string},
Node,
},
stringify::to_string_displaced,
};
use crate::expressions::parser::{Node, Parser};
use crate::expressions::types::CellReferenceRC;
struct Formula<'a> {
initial: &'a str,

View File

@@ -1,8 +1,10 @@
use std::collections::HashMap;
use crate::expressions::parser::move_formula::{move_formula, MoveContext};
use crate::expressions::parser::Parser;
use crate::expressions::types::{Area, CellReferenceRC};
use crate::expressions::types::Area;
use super::super::types::CellReferenceRC;
use super::Parser;
#[test]
fn test_move_formula() {

View File

@@ -2,9 +2,9 @@ use std::collections::HashMap;
use crate::expressions::lexer::LexerMode;
use crate::expressions::parser::stringify::{to_rc_format, to_string};
use crate::expressions::parser::Parser;
use crate::expressions::types::CellReferenceRC;
use super::super::parser::stringify::{to_rc_format, to_string};
use super::super::types::CellReferenceRC;
use super::Parser;
struct Formula<'a> {
formula_a1: &'a str,

View File

@@ -6,8 +6,8 @@ use crate::expressions::parser::stringify::to_string;
use crate::expressions::utils::{number_to_column, parse_reference_a1};
use crate::types::{Table, TableColumn, TableStyleInfo};
use crate::expressions::parser::Parser;
use crate::expressions::types::CellReferenceRC;
use super::super::types::CellReferenceRC;
use super::Parser;
fn create_test_table(
table_name: &str,

View File

@@ -1,6 +0,0 @@
mod test_general;
mod test_issue_155;
mod test_move_formula;
mod test_ranges;
mod test_stringify;
mod test_tables;

View File

@@ -1,69 +0,0 @@
#![allow(clippy::panic)]
use std::collections::HashMap;
use crate::expressions::parser::stringify::to_string;
use crate::expressions::parser::Parser;
use crate::expressions::types::CellReferenceRC;
#[test]
fn issue_155_parser() {
let worksheets = vec!["Sheet1".to_string()];
let mut parser = Parser::new(worksheets, HashMap::new());
// Reference cell is Sheet1!A1
let cell_reference = CellReferenceRC {
sheet: "Sheet1".to_string(),
row: 2,
column: 2,
};
let t = parser.parse("A$1:A2", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "A$1:A2");
}
#[test]
fn issue_155_parser_case_2() {
let worksheets = vec!["Sheet1".to_string()];
let mut parser = Parser::new(worksheets, HashMap::new());
// Reference cell is Sheet1!A1
let cell_reference = CellReferenceRC {
sheet: "Sheet1".to_string(),
row: 20,
column: 20,
};
let t = parser.parse("C$1:D2", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "C$1:D2");
}
#[test]
fn issue_155_parser_only_row() {
let worksheets = vec!["Sheet1".to_string()];
let mut parser = Parser::new(worksheets, HashMap::new());
// Reference cell is Sheet1!A1
let cell_reference = CellReferenceRC {
sheet: "Sheet1".to_string(),
row: 20,
column: 20,
};
// This is tricky, I am not sure what to do in these cases
let t = parser.parse("A$2:B1", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "A1:B$2");
}
#[test]
fn issue_155_parser_only_column() {
let worksheets = vec!["Sheet1".to_string()];
let mut parser = Parser::new(worksheets, HashMap::new());
// Reference cell is Sheet1!A1
let cell_reference = CellReferenceRC {
sheet: "Sheet1".to_string(),
row: 20,
column: 20,
};
// This is tricky, I am not sure what to do in these cases
let t = parser.parse("D1:$A3", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "$A1:D3");
}

View File

@@ -1,34 +0,0 @@
#![allow(clippy::panic)]
use std::collections::HashMap;
use crate::expressions::parser::stringify::to_string;
use crate::expressions::parser::Parser;
use crate::expressions::types::CellReferenceRC;
#[test]
fn exp_order() {
let worksheets = vec!["Sheet1".to_string()];
let mut parser = Parser::new(worksheets, HashMap::new());
// Reference cell is Sheet1!A1
let cell_reference = CellReferenceRC {
sheet: "Sheet1".to_string(),
row: 1,
column: 1,
};
let t = parser.parse("(1 + 2)^3 + 4", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "(1+2)^3+4");
let t = parser.parse("(C5 + 3)^R4", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "(C5+3)^R4");
let t = parser.parse("(C5 + 3)^(R4*6)", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "(C5+3)^(R4*6)");
let t = parser.parse("(C5)^(R4)", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "C5^R4");
let t = parser.parse("(5)^(4)", &Some(cell_reference.clone()));
assert_eq!(to_string(&t, &cell_reference), "5^4");
}

View File

@@ -245,9 +245,6 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
}
ParsePart::Number(p) => {
let mut text = "".to_string();
if let Some(c) = p.currency {
text = format!("{}", c);
}
let tokens = &p.tokens;
value = value * 100.0_f64.powi(p.percent) / (1000.0_f64.powi(p.comma));
// p.precision is the number of significant digits _after_ the decimal point

View File

@@ -10,7 +10,6 @@ pub struct Lexer {
pub enum Token {
Color(i32), // [Red] or [Color 23]
Condition(Compare, f64), // [<=100] (Comparator, number)
Currency(char), // [$€] ($ currency symbol)
Literal(char), // €, $, (, ), /, :, +, -, ^, ', {, }, <, =, !, ~, > and space or scaped \X
Spacer(char), // *X
Ghost(char), // _X
@@ -275,15 +274,6 @@ impl Lexer {
self.set_error("Failed to parse condition");
Token::ILLEGAL
}
} else if c == '$' {
// currency
self.read_next_char();
if let Some(currency) = self.read_next_char() {
self.read_next_char();
return Token::Currency(currency);
}
self.set_error("Failed to parse currency");
Token::ILLEGAL
} else {
// Color
if let Some(index) = self.consume_color() {

View File

@@ -74,7 +74,6 @@ mod test;
//
// * Color [Red] or [Color 23] or [Color23]
// * Conditions [<100]
// * Currency [$€]
// * Space _X when X is any given char
// * A spacer of chars: *X where X is repeated as much as possible
// * Literals: $, (, ), :, +, - and space

View File

@@ -40,7 +40,6 @@ pub struct NumberPart {
pub is_scientific: bool,
pub scientific_minus: bool,
pub exponent_digit_count: i32,
pub currency: Option<char>,
}
pub struct DatePart {
@@ -115,7 +114,6 @@ impl Parser {
let mut exponent_digit_count = 0;
let mut number = 'i';
let mut index = 0;
let mut currency = None;
while token != Token::EOF && token != Token::Separator {
let next_token = self.lexer.next_token();
@@ -172,9 +170,6 @@ impl Parser {
Token::Condition(cmp, value) => {
condition = Some((cmp, value));
}
Token::Currency(c) => {
currency = Some(c);
}
Token::QuestionMark => {
tokens.push(TextToken::Digit(Digit {
kind: '?',
@@ -296,7 +291,6 @@ impl Parser {
is_scientific,
scientific_minus,
exponent_digit_count,
currency,
})
}
}

View File

@@ -76,14 +76,6 @@ fn test_color() {
assert_eq!(format_number(3.1, "[blue]0.00", locale).color, Some(4));
}
#[test]
fn dollar_euro() {
let locale = get_default_locale();
let format = "[$€]#,##0.00";
let t = format_number(3.1, format, locale);
assert_eq!(t.text, "€3.10");
}
#[test]
fn test_parts() {
let locale = get_default_locale();

View File

@@ -89,9 +89,6 @@ fn compute_future_value(
if rate == 0.0 {
return Ok(-pv - pmt * nper);
}
if rate == -1.0 && nper < 0.0 {
return Err((Error::DIV, "Divide by zero".to_string()));
}
let rate_nper = (1.0 + rate).powf(nper);
let fv = if period_start {

View File

@@ -136,9 +136,6 @@ impl Model {
}
pub(crate) fn fn_or(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut result = false;
for arg in args {
match self.evaluate_node_in_context(arg, cell) {

View File

@@ -140,7 +140,6 @@ pub enum Function {
Countifs,
Maxifs,
Minifs,
Geomean,
// Date and time
Date,
@@ -249,7 +248,7 @@ pub enum Function {
}
impl Function {
pub fn into_iter() -> IntoIter<Function, 195> {
pub fn into_iter() -> IntoIter<Function, 194> {
[
Function::And,
Function::False,
@@ -349,7 +348,6 @@ impl Function {
Function::Countifs,
Function::Maxifs,
Function::Minifs,
Function::Geomean,
Function::Year,
Function::Day,
Function::Month,
@@ -613,7 +611,6 @@ impl Function {
"COUNTIFS" => Some(Function::Countifs),
"MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs),
"MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs),
"GEOMEAN" => Some(Function::Geomean),
// Date and Time
"YEAR" => Some(Function::Year),
"DAY" => Some(Function::Day),
@@ -821,7 +818,6 @@ impl fmt::Display for Function {
Function::Countifs => write!(f, "COUNTIFS"),
Function::Maxifs => write!(f, "MAXIFS"),
Function::Minifs => write!(f, "MINIFS"),
Function::Geomean => write!(f, "GEOMEAN"),
Function::Year => write!(f, "YEAR"),
Function::Day => write!(f, "DAY"),
Function::Month => write!(f, "MONTH"),
@@ -1058,7 +1054,6 @@ impl Model {
Function::Countifs => self.fn_countifs(args, cell),
Function::Maxifs => self.fn_maxifs(args, cell),
Function::Minifs => self.fn_minifs(args, cell),
Function::Geomean => self.fn_geomean(args, cell),
// Date and Time
Function::Year => self.fn_year(args, cell),
Function::Day => self.fn_day(args, cell),

View File

@@ -635,85 +635,4 @@ impl Model {
}
CalcResult::Number(max)
}
pub(crate) fn fn_geomean(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut count = 0.0;
let mut product = 1.0;
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
count += 1.0;
product *= value;
}
CalcResult::Boolean(b) => {
if let Node::ReferenceKind { .. } = arg {
} else {
product *= if b { 1.0 } else { 0.0 };
count += 1.0;
}
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
for row in left.row..(right.row + 1) {
for column in left.column..(right.column + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
count += 1.0;
product *= value;
}
error @ CalcResult::Error { .. } => return error,
CalcResult::Range { .. } => {
return CalcResult::new_error(
Error::ERROR,
cell,
"Unexpected Range".to_string(),
);
}
_ => {}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
CalcResult::String(s) => {
if let Node::ReferenceKind { .. } = arg {
// Do nothing
} else if let Ok(t) = s.parse::<f64>() {
product *= t;
count += 1.0;
} else {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Argument cannot be cast into number".to_string(),
};
}
}
_ => {
// Ignore everything else
}
};
}
if count == 0.0 {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Division by Zero".to_string(),
};
}
CalcResult::Number(product.powf(1.0 / count))
}
}

View File

@@ -15,7 +15,7 @@ use crate::{
},
token::{get_error_by_name, Error, OpCompare, OpProduct, OpSum, OpUnary},
types::*,
utils::{self, is_valid_column_number, is_valid_row},
utils::{self, is_valid_column_number, is_valid_row, parse_reference_a1},
},
formatter::{
format::{format_number, parse_formatted_number},
@@ -747,6 +747,29 @@ impl Model {
self.workbook.worksheet(sheet)?.is_empty_cell(row, column)
}
/// Returns 'true' if the cell belongs to any Merged cells
/// # Examples
///
/// ```rust
/// # use ironcalc_base::Model;
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let mut model = Model::new_empty("model", "en", "UTC")?;
/// model.merge_cells(0, "A1:D5");
/// assert_eq!(model.is_part_of_merged_cells(0, 1, 2)?, true);
/// # Ok(())
/// # }
/// ```
pub fn is_part_of_merged_cells(
&self,
sheet: u32,
row: i32,
column: i32,
) -> Result<bool, String> {
self.workbook
.worksheet(sheet)?
.is_part_of_merged_cells(row, column)
}
pub(crate) fn evaluate_cell(&mut self, cell_reference: CellReferenceIndex) -> CalcResult {
let row_data = match self.workbook.worksheets[cell_reference.sheet as usize]
.sheet_data
@@ -1225,6 +1248,14 @@ impl Model {
column: i32,
value: &str,
) -> Result<(), String> {
// Checking first whether cell we are updating is part of Merged cells
// if so returning with Err
if self.is_part_of_merged_cells(sheet, row, column)? {
return Err(format!(
"Cell row : {}, col : {} is part of merged cells block, so singular update to the cell is not possible",
row, column
));
}
let style_index = self.get_cell_style_index(sheet, row, column)?;
let new_style_index;
if common::value_needs_quoting(value, &self.language) {
@@ -1275,6 +1306,15 @@ impl Model {
column: i32,
value: bool,
) -> Result<(), String> {
// Checking first whether cell we are updating is part of Merged cells
// if so returning with Err
if self.is_part_of_merged_cells(sheet, row, column)? {
return Err(format!(
"Cell row : {}, col : {} is part of merged cells block, so singular update to the cell is not possible",
row, column
));
}
let style_index = self.get_cell_style_index(sheet, row, column)?;
let new_style_index = if self.workbook.styles.style_is_quote_prefix(style_index) {
self.workbook
@@ -1317,6 +1357,14 @@ impl Model {
column: i32,
value: f64,
) -> Result<(), String> {
// Checking first whether cell we are updating is part of Merged cells
// if so returning with Err
if self.is_part_of_merged_cells(sheet, row, column)? {
return Err(format!(
"Cell row : {}, col : {} is part of merged cells block, so singular update to the cell is not possible",
row, column
));
}
let style_index = self.get_cell_style_index(sheet, row, column)?;
let new_style_index = if self.workbook.styles.style_is_quote_prefix(style_index) {
self.workbook
@@ -1362,6 +1410,12 @@ impl Model {
column: i32,
formula: String,
) -> Result<(), String> {
if self.is_part_of_merged_cells(sheet, row, column)? {
return Err(format!(
"Cell row : {}, col : {} is part of merged cells block, so singular update to the cell is not possible",
row, column
));
}
let mut style_index = self.get_cell_style_index(sheet, row, column)?;
if self.workbook.styles.style_is_quote_prefix(style_index) {
style_index = self
@@ -1414,6 +1468,14 @@ impl Model {
column: i32,
value: String,
) -> Result<(), String> {
// Checking first whether cell we are updating is part of Merged cells
// if so returning with Err
if self.is_part_of_merged_cells(sheet, row, column)? {
return Err(format!(
"Cell row : {}, col : {} is part of merged cells block, so singular update to the cell is not possible",
row, column
));
}
// If value starts with "'" then we force the style to be quote_prefix
let style_index = self.get_cell_style_index(sheet, row, column)?;
if let Some(new_value) = value.strip_prefix('\'') {
@@ -1928,6 +1990,7 @@ impl Model {
/// Sets the number of frozen rows to `frozen_rows` in the workbook.
/// Fails if `frozen`_rows` is either too small (<0) or too large (>LAST_ROW)`
pub fn set_frozen_rows(&mut self, sheet: u32, frozen_rows: i32) -> Result<(), String> {
// TODO: What is frozen rows and do we need to take of this if row we are frozing is part of merge cells ?
if let Some(worksheet) = self.workbook.worksheets.get_mut(sheet as usize) {
if frozen_rows < 0 {
return Err("Frozen rows cannot be negative".to_string());
@@ -1945,6 +2008,7 @@ impl Model {
/// Sets the number of frozen columns to `frozen_column` in the workbook.
/// Fails if `frozen`_columns` is either too small (<0) or too large (>LAST_COLUMN)`
pub fn set_frozen_columns(&mut self, sheet: u32, frozen_columns: i32) -> Result<(), String> {
// TODO: What is frozen columns and do we need to take of this if column we are frozing is part of merge cells ?
if let Some(worksheet) = self.workbook.worksheets.get_mut(sheet as usize) {
if frozen_columns < 0 {
return Err("Frozen columns cannot be negative".to_string());
@@ -1986,6 +2050,164 @@ impl Model {
.worksheet_mut(sheet)?
.set_row_height(column, height)
}
fn parse_merged_range(&mut self, range: &str) -> Result<(i32, i32, i32, i32), String> {
let parts: Vec<&str> = range.split(':').collect();
if parts.len() == 1 {
Err(format!("Invalid range: '{}'", range))
} else if parts.len() == 2 {
match (parse_reference_a1(parts[0]), parse_reference_a1(parts[1])) {
(Some(left), Some(right)) => {
return Ok((left.row, left.column, right.row, right.column));
}
_ => return Err(format!("Invalid range: '{}'", range)),
}
} else {
return Err(format!("Invalid range: '{}'", range));
}
}
// Implementing public APIS related to Merge cells handling
/// Merges given selected cells
/// If no overlap, it will create that merged cells with left most top cell value representing the whole merged cells
/// If new merge cells creation overlaps with any of the existing merged cells, Overlapped merged cells gets unmerged
/// and new merge cells gets added
///
/// # Examples
///
/// ```rust
/// # use ironcalc_base::Model;
/// # use ironcalc_base::cell::CellValue;
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let mut model = Model::new_empty("model", "en", "UTC")?;
/// model.merge_cells(0, "D4:F6").unwrap();
/// model.merge_cells(0, "A1:B4").unwrap();
/// assert_eq!(model.workbook.worksheet(0).unwrap().merged_cells_list.len(), 2);
/// # Ok(())
/// # }
/// ```
///
/// See also:
/// * [Model::update_cell_with_formula()]
/// * [Model::update_cell_with_number()]
/// * [Model::update_cell_with_bool()]
/// * [Model::update_cell_with_text()]
pub fn merge_cells(&mut self, sheet: u32, range_ref: &str) -> Result<(), String> {
match self.parse_merged_range(range_ref) {
Ok(parsed_merge_cell_range) => {
// ATTENTION 2: Below thing we can support here but keeping it simple
// Web or different client needs to keep this in mind
// User can give errored parse ranges like C3:A1
// Where col_start and row_start and is greated then col_end and row_end
// Return error in these scenario
if parsed_merge_cell_range.0 > parsed_merge_cell_range.2
|| parsed_merge_cell_range.1 > parsed_merge_cell_range.3
{
return Err(
"Invalid parse range. Merge Mother cell always be top left cell"
.to_string(),
);
}
let mut merged_cells_overlaped_list: Vec<bool> = Vec::new();
// checking whether our new range overlaps with any of the already existing merged cells
// if so, need to unmerge those and create this new one
{
let worksheet = self.workbook.worksheet(sheet)?;
let merged_cells = worksheet.get_merged_cells_list();
for merge_node in merged_cells {
// checking whether any overlapping exist with this merge cell
if !(parsed_merge_cell_range.1 > merge_node.3
|| parsed_merge_cell_range.3 < merge_node.1
|| parsed_merge_cell_range.0 > merge_node.2
|| parsed_merge_cell_range.2 < merge_node.0)
{
// overlap has happened
merged_cells_overlaped_list.push(true);
} else {
merged_cells_overlaped_list.push(false);
}
}
}
if !merged_cells_overlaped_list.is_empty() {
// Lets take Mutable ref to Merge cell and deletes all those nodes which has overlapped
let worksheet = self.workbook.worksheet_mut(sheet)?;
let merged_cells_list_mut = worksheet.get_merged_cells_list_mut();
let mut merged_cells_overlaped_list_iter = merged_cells_overlaped_list.iter();
merged_cells_list_mut
.retain(|_| !(*merged_cells_overlaped_list_iter.next().unwrap()))
}
// Now need to update (n*m - 1) cells with empty cell ( except the Mother cell )
for row_index in parsed_merge_cell_range.0..=parsed_merge_cell_range.2 {
for col_index in parsed_merge_cell_range.1..=parsed_merge_cell_range.3 {
// skip Mother cell
if row_index == parsed_merge_cell_range.0
&& col_index == parsed_merge_cell_range.2
{
continue;
}
//update the node with empty cell
{
self.workbook.worksheet_mut(sheet)?.update_cell(
row_index,
col_index,
Cell::EmptyCell { s: 0 },
)?;
}
}
}
let new_merged_cells = MergedCells::new(parsed_merge_cell_range);
{
self.workbook
.worksheet_mut(sheet)?
.merged_cells_list
.push(new_merged_cells);
}
}
Err(err) => {
return Err(err);
}
}
Ok(())
}
/// Unmerges a given/selected merged cells
/// Once unmerged, only top most left corner value gets retained and all the others will have empty cell
/// # Examples
///
/// ```rust
/// # use ironcalc_base::Model;
/// # use ironcalc_base::cell::CellValue;
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let mut model = Model::new_empty("model", "en", "UTC")?;
/// model.merge_cells(0, "D4:F6");
/// model.unmerge_cells(0, "D4:F6");
/// # Ok(())
/// # }
/// ```
pub fn unmerge_cells(&mut self, sheet: u32, range_ref: &str) -> Result<(), String> {
let worksheet = self.workbook.worksheet(sheet)?;
let merged_cells = worksheet.get_merged_cells_list();
for (index, merge_node) in merged_cells.iter().enumerate() {
let merge_block_range_ref = merge_node.get_merged_cells_str_ref()?;
// finding the merge cell node to be deleted
if merge_block_range_ref.as_str() == range_ref {
// Merge cell to be deleted is found
self.workbook
.worksheet_mut(sheet)?
.merged_cells_list
.remove(index);
return Ok(());
}
}
Err("Invalid merge_cell_ref, Merged cells to be deleted is not found".to_string())
}
}
#[cfg(test)]

View File

@@ -4,7 +4,7 @@ use std::collections::HashMap;
use crate::{
calc_result::Range,
constants::{DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH},
constants::{DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH},
expressions::{
lexer::LexerMode,
parser::{
@@ -57,7 +57,7 @@ impl Model {
rows: vec![],
comments: vec![],
dimension: "A1".to_string(),
merge_cells: vec![],
merged_cells_list: vec![],
name: name.to_string(),
shared_formulas: vec![],
sheet_data: Default::default(),
@@ -359,7 +359,7 @@ impl Model {
WorkbookView {
sheet: 0,
window_width: DEFAULT_WINDOW_WIDTH,
window_height: DEFAULT_WINDOW_HEIGHT,
window_height: DEFAULT_WINDOW_HEIGH,
},
);

View File

@@ -223,6 +223,14 @@ impl Model {
column: i32,
style: &Style,
) -> Result<(), String> {
// Checking first whether cell we are updating is part of Merged cells
// if so returning with Err
if self.is_part_of_merged_cells(sheet, row, column)? {
return Err(format!(
"Cell row : {}, col : {} is part of merged cells block, so singular update to the cell is not possible",
row, column
));
}
let style_index = self.workbook.styles.get_style_index_or_create(style);
self.workbook
.worksheet_mut(sheet)?
@@ -252,6 +260,14 @@ impl Model {
column: i32,
style_name: &str,
) -> Result<(), String> {
// Checking first whether cell we are updating is part of Merged cells
// if so returning with Err
if self.is_part_of_merged_cells(sheet, row, column)? {
return Err(format!(
"Cell row : {}, col : {} is part of merged cells block, so singular update to the cell is not possible",
row, column
));
}
let style_index = self.workbook.styles.get_style_index_by_name(style_name)?;
self.workbook
.worksheet_mut(sheet)?

View File

@@ -46,17 +46,14 @@ pub(crate) mod util;
mod engineering;
mod test_fn_offset;
mod test_fn_or;
mod test_number_format;
mod test_escape_quotes;
mod test_extend;
mod test_fn_fv;
mod test_fn_type;
mod test_frozen_rows_and_columns;
mod test_geomean;
mod test_get_cell_content;
mod test_issue_155;
mod test_model_merge_cell_fns;
mod test_percentage;
mod test_set_functions_error_handling;
mod test_today;

View File

@@ -82,21 +82,3 @@ fn test_column_width_higher_edge() {
assert!((worksheet.get_column_width(17).unwrap() - DEFAULT_COLUMN_WIDTH).abs() < f64::EPSILON);
assert_eq!(model.get_cell_style_index(0, 23, 16), Ok(1));
}
#[test]
fn test_column_width_negative() {
let mut model = new_empty_model();
let result = model
.workbook
.worksheet_mut(0)
.unwrap()
.set_column_width(16, -1.0);
assert_eq!(result, Err("Can not set a negative width: -1".to_string()));
assert_eq!(model.workbook.worksheets[0].cols.len(), 0);
let worksheet = model.workbook.worksheet(0).unwrap();
assert_eq!(
(worksheet.get_column_width(16).unwrap()),
DEFAULT_COLUMN_WIDTH
);
assert_eq!(model.get_cell_style_index(0, 23, 16), Ok(0));
}

View File

@@ -2,6 +2,9 @@
use crate::test::util::new_empty_model;
#[test]
fn simple_cases() {}
#[test]
fn wrong_number_of_arguments() {
let mut model = new_empty_model();

View File

@@ -1,36 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn computation() {
let i2 = "=-C2*(1+D2)^E2-F2*((D2+1)*((1+D2)^E2-1))/D2";
let mut model = new_empty_model();
model._set("C2", "1");
model._set("D2", "2");
model._set("E2", "3");
model._set("F2", "4");
model._set("I2", i2);
model.evaluate();
assert_eq!(model._get_text("I2"), "-183");
assert_eq!(model._get_formula("I2"), i2);
}
#[test]
fn format_as_currency() {
let mut model = new_empty_model();
model._set("C2", "1");
model._set("D2", "2");
model._set("E2", "3");
model._set("F2", "4");
model._set("I2", "=FV(D2,E2,F2,C2,1)");
model.evaluate();
assert_eq!(model._get_text("I2"), "-$183.00");
}

View File

@@ -1,36 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn fn_or() {
let mut model = new_empty_model();
model._set("A1", "=OR(1, 0)");
model._set("A2", "=OR(0, 0)");
model._set("A3", "=OR(true, false)");
model._set("A4", "=OR(false, false)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"TRUE");
assert_eq!(model._get_text("A2"), *"FALSE");
assert_eq!(model._get_text("A3"), *"TRUE");
assert_eq!(model._get_text("A4"), *"FALSE");
}
#[test]
fn fn_or_no_arguments() {
let mut model = new_empty_model();
model._set("A1", "=OR()");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
}
#[test]
fn fn_or_missing_arguments() {
let mut model = new_empty_model();
model._set("A1", "=OR(,)");
model._set("A2", "=OR(,1)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"FALSE");
assert_eq!(model._get_text("A2"), *"TRUE");
}

View File

@@ -1,7 +1,5 @@
#![allow(clippy::unwrap_used)]
use crate::constants::DEFAULT_ROW_HEIGHT;
use crate::cell::CellValue;
use crate::number_format::to_excel_precision_str;
@@ -115,15 +113,6 @@ fn test_set_row_height() {
worksheet.set_row_height(5, 5.0).unwrap();
let worksheet = model.workbook.worksheet(0).unwrap();
assert!((5.0 - worksheet.row_height(5).unwrap()).abs() < f64::EPSILON);
let worksheet = model.workbook.worksheet_mut(0).unwrap();
let result = worksheet.set_row_height(6, -1.0);
assert_eq!(result, Err("Can not set a negative height: -1".to_string()));
assert_eq!(worksheet.row_height(6).unwrap(), DEFAULT_ROW_HEIGHT);
worksheet.set_row_height(6, 0.0).unwrap();
assert_eq!(worksheet.row_height(6).unwrap(), 0.0);
}
#[test]

View File

@@ -1,27 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn test_fn_geomean_arguments() {
let mut model = new_empty_model();
model._set("A1", "=GEOMEAN()");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!");
}
#[test]
fn test_fn_geomean_minimal() {
let mut model = new_empty_model();
model._set("B1", "1");
model._set("B2", "2");
model._set("B3", "3");
model._set("B4", "'2");
// B5 is empty
model._set("B6", "true");
model._set("A1", "=GEOMEAN(B1:B6)");
model.evaluate();
assert_eq!(model._get_text("A1"), *"1.817120593");
}

View File

@@ -1,14 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn issue_155() {
let mut model = new_empty_model();
model._set("A1", "1");
model._set("A2", "2");
model._set("B2", "=A$1:A2");
model.evaluate();
assert_eq!(model._get_formula("B2"), "=A$1:A2".to_string());
}

View File

@@ -0,0 +1,155 @@
#![allow(clippy::unwrap_used)]
use crate::{test::util::new_empty_model, types::CellType};
#[test]
fn test_model_set_fns_related_to_merge_cells() {
let mut model = new_empty_model();
// creating a merge cell of D1:F2
model.merge_cells(0, "D1:F2").unwrap();
// Updating the mother cell of Merge cells and expecting the update to go through
model.set_user_input(0, 1, 4, "Hello".to_string()).unwrap();
assert_eq!(model.get_cell_content(0, 1, 4).unwrap(), "Hello");
assert_eq!(model.get_cell_type(0, 1, 4).unwrap(), CellType::Text);
// Updating cell which is not in Merge cell block
assert_eq!(model.set_user_input(0, 1, 3, "Hello".to_string()), Ok(()));
assert_eq!(model.get_cell_content(0, 1, 3), Ok("Hello".to_string()));
assert_eq!(model.get_cell_type(0, 1, 3), Ok(CellType::Text));
// 1: testing with set_user_input()
assert_eq!(
model
.set_user_input(0, 1, 5, "Hello".to_string()),
Err("Cell row : 1, col : 5 is part of merged cells block, so singular update to the cell is not possible".to_string())
);
assert_eq!(model.get_cell_content(0, 1, 5), Ok("".to_string()));
assert_eq!(model.get_cell_type(0, 1, 5), Ok(CellType::Number));
// 2: testing with update_cell_with_bool()
assert_eq!(
model
.update_cell_with_bool(0, 1, 5, true),
Err("Cell row : 1, col : 5 is part of merged cells block, so singular update to the cell is not possible".to_string())
);
assert_eq!(model.get_cell_content(0, 1, 5), Ok("".to_string()));
assert_eq!(model.get_cell_type(0, 1, 5), Ok(CellType::Number));
// 3: testing with update_cell_with_formula()
assert_eq!(
model
.update_cell_with_formula(0, 1, 5, "=SUM(A1+A2)".to_string()),
Err("Cell row : 1, col : 5 is part of merged cells block, so singular update to the cell is not possible".to_string())
);
assert_eq!(model.get_cell_type(0, 1, 5), Ok(CellType::Number));
// 4: testing with update_cell_with_number()
assert_eq!(
model
.update_cell_with_number(0, 1, 5, 10.0),
Err("Cell row : 1, col : 5 is part of merged cells block, so singular update to the cell is not possible".to_string())
);
assert_eq!(model.get_cell_content(0, 1, 5), Ok("".to_string()));
assert_eq!(model.get_cell_type(0, 1, 5), Ok(CellType::Number));
// 5: testing with update_cell_with_text()
assert_eq!(
model
.update_cell_with_text(0, 1, 5, "new text"),
Err("Cell row : 1, col : 5 is part of merged cells block, so singular update to the cell is not possible".to_string())
);
assert_eq!(model.get_cell_content(0, 1, 5), Ok("".to_string()));
assert_eq!(model.get_cell_type(0, 1, 5), Ok(CellType::Number));
}
#[test]
fn test_model_merge_cells_crud_api() {
let mut model = new_empty_model();
// creating a merge cell of D4:F6
model.merge_cells(0, "D4:F6").unwrap();
model
.set_user_input(0, 4, 4, "Merge Block".to_string())
.unwrap();
// CRUD APIS testing on Merge Cells
// Case1: Creating a new merge cell without overlapping
// Newly created Merge block is left to D4:F6
assert_eq!(model.merge_cells(0, "A1:B4"), Ok(()));
assert_eq!(
model.workbook.worksheet(0).unwrap().merged_cells_list.len(),
2
);
model.set_user_input(0, 1, 1, "left".to_string()).unwrap();
// Newly created Merge block is right to D4:F6
assert_eq!(model.merge_cells(0, "G1:H7"), Ok(()));
assert_eq!(
model.workbook.worksheet(0).unwrap().merged_cells_list.len(),
3
);
model.set_user_input(0, 1, 7, "right".to_string()).unwrap();
// Newly created Merge block is above to D4:F6
assert_eq!(model.merge_cells(0, "C1:D3"), Ok(()));
assert_eq!(
model.workbook.worksheet(0).unwrap().merged_cells_list.len(),
4
);
model.set_user_input(0, 1, 3, "top".to_string()).unwrap();
// Newly created Merge block is down to D4:F6
assert_eq!(model.merge_cells(0, "D8:E9"), Ok(()));
assert_eq!(
model.workbook.worksheet(0).unwrap().merged_cells_list.len(),
5
);
model.set_user_input(0, 8, 4, "down".to_string()).unwrap();
// Case2: Creating a new merge cell with overlapping with other 3 merged cell
assert_eq!(model.merge_cells(0, "C1:G4"), Ok(()));
assert_eq!(
model.workbook.worksheet(0).unwrap().merged_cells_list.len(),
3
);
model
.set_user_input(0, 1, 3, "overlapped_new_merge_block".to_string())
.unwrap();
// Case3: Giving wrong parsing range
assert_eq!(
model.merge_cells(0, "C3:A1"),
Err("Invalid parse range. Merge Mother cell always be top left cell".to_string())
);
assert_eq!(
model.merge_cells(0, "CA:A1"),
Err("Invalid range: 'CA:A1'".to_string())
);
assert_eq!(
model.merge_cells(0, "C0:A1"),
Err("Invalid range: 'C0:A1'".to_string())
);
assert_eq!(
model.merge_cells(0, "C1:A0"),
Err("Invalid range: 'C1:A0'".to_string())
);
assert_eq!(
model.merge_cells(0, "C1"),
Err("Invalid range: 'C1'".to_string())
);
assert_eq!(
model.merge_cells(0, "C1:A1:B1"),
Err("Invalid range: 'C1:A1:B1'".to_string())
);
// Case3: Giving wrong merge_ref, which would resulting in error (Merge cell to be deleted is not found)
assert_eq!(
model.unmerge_cells(0, "C1:E1"),
Err("Invalid merge_cell_ref, Merged cells to be deleted is not found".to_string())
);
// Case4: unmerge scenario
assert_eq!(model.unmerge_cells(0, "C1:G4"), Ok(()));
}

View File

@@ -62,3 +62,45 @@ fn test_create_named_style() {
let style = model.get_style_for_cell(0, 1, 1).unwrap();
assert!(style.font.b);
}
#[test]
fn test_model_style_set_fns_in_merge_cell_context() {
let mut model = new_empty_model();
// creating a merge cell of D1:F2
model.merge_cells(0, "D1:F2").unwrap();
model.set_user_input(0, 1, 4, "Hello".to_string()).unwrap();
let mut style = model.get_style_for_cell(0, 1, 1).unwrap();
assert!(!style.font.b);
style.font.b = true;
// Updating the mother cell of Merge cells and expecting the update to go through
// This should make the text "Hello" in bold format
assert_eq!(model.set_cell_style(0, 1, 4, &style), Ok(()));
// 1: testing with set_cell_style()
let original_style: Style = model.get_style_for_cell(0, 1, 5).unwrap();
assert_eq!(
model
.set_cell_style(0, 1, 5, &style),
Err("Cell row : 1, col : 5 is part of merged cells block, so singular update to the cell is not possible".to_string())
);
assert_eq!(model.get_style_for_cell(0, 1, 5), Ok(original_style));
// 2: testing with set_cell_style_by_name
let mut style = model.get_style_for_cell(0, 1, 4).unwrap();
style.font.b = true;
assert_eq!(
model.workbook.styles.create_named_style("bold", &style),
Ok(())
);
let original_style: Style = model.get_style_for_cell(0, 1, 5).unwrap();
assert_eq!(
model
.set_cell_style_by_name(0, 1, 5, "bold"),
Err("Cell row : 1, col : 5 is part of merged cells block, so singular update to the cell is not possible".to_string())
);
assert_eq!(model.get_style_for_cell(0, 1, 5), Ok(original_style));
}

View File

@@ -283,3 +283,87 @@ fn test_worksheet_navigate_to_edge_in_direction() {
assert_eq!(navigate(8, 3, NavigationDirection::Up), (6, 3));
assert_eq!(navigate(9, 3, NavigationDirection::Up), (6, 3));
}
// Tests Merge cells related functions of worksheet
#[test]
fn test_merge_cell_fns_worksheet() {
let mut model = new_empty_model();
// Adding one Merge cell
model.merge_cells(0, "D1:E3").unwrap();
// Lets check whether D1 (Mother Merge cell) is part of Merge block or not
// It should not be considered as part of Merge cell
assert!(!model
.workbook
.worksheet(0)
.unwrap()
.is_part_of_merged_cells(1, 4)
.unwrap(),);
// Lets give cell which is actually part of Merge block and expect true from fn
assert!(model
.workbook
.worksheet(0)
.unwrap()
.is_part_of_merged_cells(2, 4)
.unwrap());
// Lets give cell which is not a part of Merge block and expect false from fn
assert!(!model
.workbook
.worksheet(0)
.unwrap()
.is_part_of_merged_cells(2, 6)
.unwrap());
// Lets give an Invalid row
assert_eq!(
model
.workbook
.worksheet(0)
.unwrap()
.is_part_of_merged_cells(0, 1),
Err("Incorrect row or column".to_string())
);
//Lets give Invalid column
assert_eq!(
model
.workbook
.worksheet(0)
.unwrap()
.is_part_of_merged_cells(1, 0),
Err("Incorrect row or column".to_string())
);
// Verifying get fns of worksheet
assert_eq!(
model
.workbook
.worksheet(0)
.unwrap()
.get_merged_cells_list()
.len(),
1
);
{
let merge_cell_vec = model
.workbook
.worksheet_mut(0)
.unwrap()
.get_merged_cells_list_mut();
merge_cell_vec.remove(0);
assert_eq!(
model
.workbook
.worksheet(0)
.unwrap()
.get_merged_cells_list()
.len(),
0
);
}
}

View File

@@ -2,7 +2,7 @@
use crate::{
constants::{
DEFAULT_COLUMN_WIDTH, DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH,
DEFAULT_COLUMN_WIDTH, DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH,
LAST_COLUMN,
},
test::util::new_empty_model,
@@ -87,7 +87,7 @@ fn last_colum() {
fn page_down() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
let window_height = DEFAULT_WINDOW_HEIGHT as f64;
let window_height = DEFAULT_WINDOW_HEIGH as f64;
let row_height = DEFAULT_ROW_HEIGHT;
let row_count = f64::floor(window_height / row_height) as i32;
model.on_page_down().unwrap();

View File

@@ -99,7 +99,7 @@ fn cut_paste() {
// paste in cell D4 (4, 4)
model
.paste_from_clipboard(0, (1, 1, 2, 2), &copy.data, true)
.paste_from_clipboard((1, 1, 2, 2), &copy.data, true)
.unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string()));
@@ -119,26 +119,6 @@ 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();
@@ -172,7 +152,7 @@ fn copy_paste_internal() {
// paste in cell D4 (4, 4)
model
.paste_from_clipboard(0, (1, 1, 2, 2), &copy.data, false)
.paste_from_clipboard((1, 1, 2, 2), &copy.data, false)
.unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string()));

View File

@@ -1,7 +1,7 @@
#![allow(clippy::unwrap_used)]
use crate::{
constants::{DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH},
constants::{DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH},
test::util::new_empty_model,
UserModel,
};
@@ -11,7 +11,7 @@ fn basic_test() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
let window_height = model.get_window_height().unwrap();
assert_eq!(window_height, DEFAULT_WINDOW_HEIGHT);
assert_eq!(window_height, DEFAULT_WINDOW_HEIGH);
let window_width = model.get_window_width().unwrap();
assert_eq!(window_width, DEFAULT_WINDOW_WIDTH);

View File

@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt::Display};
use crate::expressions::token::Error;
use crate::expressions::utils::number_to_column;
fn default_as_false() -> bool {
false
@@ -35,7 +36,7 @@ pub struct WorkbookView {
pub sheet: u32,
/// The current width of the window
pub window_width: i64,
/// The current height of the window
/// The current heigh of the window
pub window_height: i64,
}
@@ -110,7 +111,7 @@ pub struct Worksheet {
pub sheet_id: u32,
pub state: SheetState,
pub color: Option<String>,
pub merge_cells: Vec<String>,
pub merged_cells_list: Vec<MergedCells>,
pub comments: Vec<Comment>,
pub frozen_rows: i32,
pub frozen_columns: i32,
@@ -351,6 +352,43 @@ pub enum FontScheme {
None,
}
// MergedCells type
// There will be one MergedCells struct maintained for every Merged cells that we load
// merge_cell_range : Its tuple having [row_start, column_start, row_end, column_end]
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct MergedCells(pub i32, pub i32, pub i32, pub i32);
// implementing accessor function
impl MergedCells {
// Method which returns range_ref from the tuple
// ex : (3,1,4,2) is interpreted as A3:B4
pub fn get_merged_cells_str_ref(&self) -> Result<String, String> {
let start_column = number_to_column(self.1).ok_or(format!(
"Error while converting column start {} number to column string ref",
self.1
))?;
let end_column = number_to_column(self.3).ok_or(format!(
"Error while converting column end {} number to column string ref",
self.3
))?;
return Ok(start_column
+ &self.0.to_string()
+ &":".to_string()
+ &end_column
+ &self.2.to_string());
}
// Only Public function where Merge cell can be created
pub fn new(merge_cell_parsed_range: (i32, i32, i32, i32)) -> Self {
Self(
merge_cell_parsed_range.0,
merge_cell_parsed_range.1,
merge_cell_parsed_range.2,
merge_cell_parsed_range.3,
)
}
}
impl Display for FontScheme {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {

View File

@@ -309,7 +309,6 @@ impl Model {
Function::Sum => self.units_fn_sum_like(args, cell),
Function::Average => self.units_fn_sum_like(args, cell),
Function::Pmt => self.units_fn_currency(args, cell),
Function::Fv => self.units_fn_currency(args, cell),
Function::Nper => self.units_fn_currency(args, cell),
Function::Npv => self.units_fn_currency(args, cell),
Function::Irr => self.units_fn_percentage(args, cell),

View File

@@ -39,7 +39,6 @@ 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),
}
@@ -343,7 +342,7 @@ impl UserModel {
old_value: Box::new(old_value),
}];
let line_count = value.split('\n').count();
let line_count = value.split("\n").count();
let row_height = self.model.get_row_height(sheet, row)?;
let cell_height = (line_count as f64) * DEFAULT_ROW_HEIGHT;
if cell_height > row_height {
@@ -714,7 +713,7 @@ impl UserModel {
/// Paste `styles` in the selected area
pub fn on_paste_styles(&mut self, styles: &[Vec<Style>]) -> Result<(), String> {
let styles_height = styles.len() as i32;
let styles_heigh = styles.len() as i32;
let styles_width = styles[0].len() as i32;
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
@@ -733,13 +732,13 @@ impl UserModel {
// If the pasted area is smaller than the selected area we increase it
let [row_start, column_start, row_end, column_end] = range;
let last_row = row_end.max(row_start + styles_height - 1);
let last_row = row_end.max(row_start + styles_heigh - 1);
let last_column = column_end.max(column_start + styles_width - 1);
let mut diff_list = Vec::new();
for row in row_start..=last_row {
for column in column_start..=last_column {
let row_index = ((row - row_start) % styles_height) as usize;
let row_index = ((row - row_start) % styles_heigh) as usize;
let column_index = ((column - column_start) % styles_width) as usize;
let style = &styles[row_index][column_index];
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
@@ -1521,7 +1520,6 @@ impl UserModel {
Ok(Clipboard {
csv,
data,
sheet,
range: (row_start, column_start, row_end, column_end),
})
}
@@ -1529,7 +1527,6 @@ 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,
@@ -1620,17 +1617,17 @@ impl UserModel {
let old_value = self
.model
.workbook
.worksheet(source_sheet)?
.worksheet(sheet)?
.cell(row, column)
.cloned();
diff_list.push(Diff::CellClearContents {
sheet: source_sheet,
sheet,
row,
column,
old_value: Box::new(old_value),
});
self.model.cell_clear_contents(source_sheet, row, column)?;
self.model.cell_clear_contents(sheet, row, column)?;
}
}
}

View File

@@ -255,14 +255,11 @@ impl Worksheet {
/// * If the row does not a have a style we add it.
/// * If it has we modify the height and make sure it is applied.
///
/// Fails if row index is outside allowed range or height is negative.
/// Fails if column index is outside allowed range.
pub fn set_row_height(&mut self, row: i32, height: f64) -> Result<(), String> {
if !is_valid_row(row) {
return Err(format!("Row number '{row}' is not valid."));
}
if height < 0.0 {
return Err(format!("Can not set a negative height: {height}"));
}
let rows = &mut self.rows;
for r in rows.iter_mut() {
@@ -287,7 +284,7 @@ impl Worksheet {
/// * If the column does not a have a width we simply add it
/// * If it has, it might be part of a range and we ned to split the range.
///
/// Fails if column index is outside allowed range or width is negative.
/// Fails if column index is outside allowed range.
pub fn set_column_width(&mut self, column: i32, width: f64) -> Result<(), String> {
self.set_column_width_and_style(column, width, None)
}
@@ -301,9 +298,6 @@ impl Worksheet {
if !is_valid_column_number(column) {
return Err(format!("Column number '{column}' is not valid."));
}
if width < 0.0 {
return Err(format!("Can not set a negative width: {width}"));
}
let cols = &mut self.cols;
let mut col = Col {
min: column,
@@ -530,6 +524,23 @@ impl Worksheet {
Ok(is_empty)
}
/// Returns true if cell part of Merged Cells.
/// First cell of Merged cells block is not considered as part of Merged cells
/// Ex : if Merged cells were A1-C3, A1 is not considered as part of Merged cells block
pub fn is_part_of_merged_cells(&self, row: i32, column: i32) -> Result<bool, String> {
if !is_valid_column_number(column) || !is_valid_row(row) {
return Err("Incorrect row or column".to_string());
}
// traverse through Vector of Merged Cells and return (linear search)
for merged_cells in &self.merged_cells_list {
if merged_cells.is_cell_part_of_merged_cells(row, column) {
return Ok(true);
}
}
Ok(false)
}
/// It provides convenient method for user navigation in the spreadsheet by jumping to edges.
/// Spreadsheet engines usually allow this method of navigation by using CTRL+arrows.
/// Behaviour summary:
@@ -583,6 +594,16 @@ impl Worksheet {
}
}
}
/// Returns mutable reference to Vector of Merged cells list
pub fn get_merged_cells_list_mut(&mut self) -> &mut Vec<MergedCells> {
&mut self.merged_cells_list
}
/// Returns reference to Vector of Merged cells list
pub fn get_merged_cells_list(&self) -> &Vec<MergedCells> {
&self.merged_cells_list
}
}
struct WalkFoundCells {

View File

@@ -10,8 +10,6 @@ use xlsx::import;
mod types;
use crate::types::PyCellType;
create_exception!(_ironcalc, WorkbookError, PyException);
/// This is a model implementing the 'raw' API
@@ -60,21 +58,6 @@ impl PyModel {
// Get values
/// Get raw value
pub fn get_cell_content(&self, sheet: u32, row: i32, column: i32) -> PyResult<String> {
self.model
.get_cell_content(sheet, row, column)
.map_err(|e| WorkbookError::new_err(e.to_string()))
}
/// Get cell type
pub fn get_cell_type(&self, sheet: u32, row: i32, column: i32) -> PyResult<PyCellType> {
self.model
.get_cell_type(sheet, row, column)
.map(|cell_type| cell_type.into())
.map_err(|e| WorkbookError::new_err(e.to_string()))
}
/// Get formatted value
pub fn get_formatted_cell_value(&self, sheet: u32, row: i32, column: i32) -> PyResult<String> {
self.model

View File

@@ -1,7 +1,7 @@
use pyo3::prelude::*;
use xlsx::base::types::{
Alignment, Border, BorderItem, BorderStyle, CellType, Fill, Font, FontScheme,
HorizontalAlignment, Style, VerticalAlignment,
Alignment, Border, BorderItem, BorderStyle, Fill, Font, FontScheme, HorizontalAlignment, Style,
VerticalAlignment,
};
#[derive(Clone)]
@@ -161,17 +161,6 @@ pub struct PyFill {
pub bg_color: Option<String>,
}
#[pyclass(eq, eq_int)]
#[derive(PartialEq, Clone)]
pub enum PyCellType {
Number = 1,
Text = 2,
LogicalValue = 4,
ErrorValue = 16,
Array = 64,
CompoundData = 128,
}
// Conversions from references to Py* types to non-Py types
// Enums
@@ -437,31 +426,3 @@ impl From<Style> for PyStyle {
}
}
}
// Conversion from PyCellType to CellType
impl From<PyCellType> for CellType {
fn from(py_cell_type: PyCellType) -> Self {
match py_cell_type {
PyCellType::Number => CellType::Number,
PyCellType::Text => CellType::Text,
PyCellType::LogicalValue => CellType::LogicalValue,
PyCellType::ErrorValue => CellType::ErrorValue,
PyCellType::Array => CellType::Array,
PyCellType::CompoundData => CellType::CompoundData,
}
}
}
// Conversion from CellType to PyCellType
impl From<CellType> for PyCellType {
fn from(cell_type: CellType) -> Self {
match cell_type {
CellType::Number => PyCellType::Number,
CellType::Text => PyCellType::Text,
CellType::LogicalValue => PyCellType::LogicalValue,
CellType::ErrorValue => PyCellType::ErrorValue,
CellType::Array => PyCellType::Array,
CellType::CompoundData => PyCellType::CompoundData,
}
}
}

View File

@@ -169,22 +169,20 @@ clipboard_types = r"""
paste_from_clipboard = r"""
/**
* @param {number} source_sheet
* @param {any} source_range
* @param {any} clipboard
* @param {boolean} is_cut
*/
pasteFromClipboard(source_sheet: number, source_range: any, clipboard: any, is_cut: boolean): void;
pasteFromClipboard(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_sheet: number, source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void;
pasteFromClipboard(source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void;
"""
def fix_types(text):

View File

@@ -520,7 +520,6 @@ 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,
@@ -530,7 +529,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_sheet, source_range, &clipboard, is_cut)
.paste_from_clipboard(source_range, &clipboard, is_cut)
.map_err(|e| to_js_error(e.to_string()))
}

View File

@@ -2003,7 +2003,7 @@ export default defineConfig({
link: "/programming/python-bindings",
},
{
text: "JavaScript",
text: "JavScript",
link: "/programming/javascript-bindings",
},
],

View File

@@ -4,46 +4,8 @@ outline: deep
lang: en-US
---
# FV function
## Overview
FV (<u>F</u>uture <u>V</u>alue) is a function of the Financial category that can be used to predict the future value of an investment or asset based on its present value.
# FV
FV can be used to calculate future value over a specified number of compounding periods. A fixed interest rate or yield is assumed over all periods, and a fixed payment or deposit can be applied at the start or end of every period.
If your interest rate varies between periods, use the [FVSCHEDULE](/functions/financial/fvschedule) function instead of FV.
## Usage
### Syntax
**FV(rate, nper, pmt, pv, type)**
### Argument descriptions
* *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.md-->
## Details
* If *rate* = 0, FV is given by the equation:
$$
FV = -pv - (pmt \times nper)
$$
* 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}
$$
* 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.
* 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.
::: 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).
:::

View File

@@ -1 +0,0 @@
* For information about the different types of errors that you may encounter when using IronCalc functions, visit our [Error Types](/features/error-types) page.

View File

@@ -9,13 +9,13 @@ lang: en-US
At the moment IronCalc only supports a few function in this section.
You can track the progress in this [GitHub issue](https://github.com/ironcalc/IronCalc/issues/55).
| Function | Status | Documentation |
| ------------------------ |--------------------------------------------------| ------------- |
| Function | Status | Documentation |
| ------------------------ | ---------------------------------------------- | ------------- |
| AVEDEV | <Badge type="info" text="Not implemented yet" /> | |
| AVERAGE | <Badge type="tip" text="Available" /> | |
| AVERAGEA | <Badge type="tip" text="Available" /> | |
| AVERAGEIF | <Badge type="tip" text="Available" /> | |
| AVERAGEIFS | <Badge type="tip" text="Available" /> | |
| AVERAGE | <Badge type="tip" text="Available" /> | |
| AVERAGEA | <Badge type="tip" text="Available" /> | |
| AVERAGEIF | <Badge type="tip" text="Available" /> | |
| AVERAGEIFS | <Badge type="tip" text="Available" /> | |
| BETA.DIST | <Badge type="info" text="Not implemented yet" /> | |
| BETA.INV | <Badge type="info" text="Not implemented yet" /> | |
| BINOM.DIST | <Badge type="info" text="Not implemented yet" /> | |
@@ -29,11 +29,11 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| CONFIDENCE.NORM | <Badge type="info" text="Not implemented yet" /> | |
| CONFIDENCE.T | <Badge type="info" text="Not implemented yet" /> | |
| CORREL | <Badge type="info" text="Not implemented yet" /> | |
| COUNT | <Badge type="tip" text="Available" /> | |
| COUNTA | <Badge type="tip" text="Available" /> | |
| COUNTBLANK | <Badge type="tip" text="Available" /> | |
| COUNTIF | <Badge type="tip" text="Available" /> | |
| COUNTIFS | <Badge type="tip" text="Available" /> | |
| COUNT | <Badge type="tip" text="Available" /> | |
| COUNTA | <Badge type="tip" text="Available" /> | |
| COUNTBLANK | <Badge type="tip" text="Available" /> | |
| COUNTIF | <Badge type="tip" text="Available" /> | |
| COUNTIFS | <Badge type="tip" text="Available" /> | |
| COVARIANCE.P | <Badge type="info" text="Not implemented yet" /> | |
| COVARIANCE.S | <Badge type="info" text="Not implemented yet" /> | |
| DEVSQ | <Badge type="info" text="Not implemented yet" /> | |
@@ -58,7 +58,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| GAMMALN | <Badge type="info" text="Not implemented yet" /> | |
| GAMMALN.PRECISE | <Badge type="info" text="Not implemented yet" /> | |
| GAUSS | <Badge type="info" text="Not implemented yet" /> | |
| GEOMEAN | <Badge type="info" text="Available" /> | |
| GEOMEAN | <Badge type="info" text="Not implemented yet" /> | |
| GROWTH | <Badge type="info" text="Not implemented yet" /> | |
| HARMEAN | <Badge type="info" text="Not implemented yet" /> | |
| HYPGEOM.DIST | <Badge type="info" text="Not implemented yet" /> | |
@@ -69,9 +69,9 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| LOGEST | <Badge type="info" text="Not implemented yet" /> | |
| LOGNORM.DIST | <Badge type="info" text="Not implemented yet" /> | |
| LOGNORM.INV | <Badge type="info" text="Not implemented yet" /> | |
| MAX | <Badge type="tip" text="Available" /> | |
| MAX | <Badge type="tip" text="Available" /> | |
| MAXA | <Badge type="info" text="Not implemented yet" /> | |
| MAXIFS | <Badge type="tip" text="Available" /> | |
| MAXIFS | <Badge type="tip" text="Available" /> | |
| MEDIAN | <Badge type="info" text="Not implemented yet" /> | |
| MODE.MULT | <Badge type="info" text="Not implemented yet" /> | |
| MODE.SNGL | <Badge type="info" text="Not implemented yet" /> | |

View File

@@ -7,5 +7,6 @@ lang: en-US
# GEOMEAN
::: 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).
🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
:::

View File

@@ -1,160 +0,0 @@
import styled from "@emotion/styled";
import { Trash2 } from "lucide-react";
import { forwardRef, useEffect } from "react";
import { theme } from "../theme";
export const DeleteWorkbookDialog = forwardRef<
HTMLDivElement,
{
onClose: () => void;
onConfirm: () => void;
workbookName: string;
}
>((properties, ref) => {
useEffect(() => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "blur(2px)";
}
return () => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "none";
}
};
}, []);
return (
<DialogWrapper
ref={ref}
tabIndex={-1}
role="dialog"
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<IconWrapper>
<Trash2 />
</IconWrapper>
<ContentWrapper>
<Title>Are you sure?</Title>
<Body>
The workbook <strong>'{properties.workbookName}'</strong> will be
permanently deleted. This action cannot be undone.
</Body>
<ButtonGroup>
<DeleteButton
onClick={() => {
properties.onConfirm();
properties.onClose();
}}
>
Yes, delete workbook
</DeleteButton>
<CancelButton onClick={properties.onClose}>Cancel</CancelButton>
</ButtonGroup>
</ContentWrapper>
</DialogWrapper>
);
});
DeleteWorkbookDialog.displayName = "DeleteWorkbookDialog";
const DialogWrapper = styled.div`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
border-radius: 8px;
box-shadow: 0px 1px 3px 0px ${theme.palette.common.black}1A;
width: 280px;
max-width: calc(100% - 40px);
z-index: 50;
font-family: "Inter", sans-serif;
`;
const IconWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
border-radius: 4px;
background-color: ${theme.palette.error.main}1A;
margin: 12px auto 0 auto;
color: ${theme.palette.error.main};
svg {
width: 16px;
height: 16px;
}
`;
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-size: 14px;
word-break: break-word;
`;
const Title = styled.h2`
margin: 0;
font-weight: 600;
font-size: inherit;
color: ${theme.palette.grey["900"]};
`;
const Body = styled.p`
margin: 0;
text-align: center;
color: ${theme.palette.grey["900"]};
font-size: 12px;
`;
const ButtonGroup = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
width: 100%;
`;
const Button = styled.button`
cursor: pointer;
color: ${theme.palette.common.white};
background-color: ${theme.palette.primary.main};
padding: 0px 10px;
height: 36px;
border-radius: 4px;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
text-overflow: ellipsis;
transition: background-color 150ms;
&:hover {
background-color: ${theme.palette.primary.dark};
}
`;
const DeleteButton = styled(Button)`
background-color: ${theme.palette.error.main};
color: ${theme.palette.common.white};
&:hover {
background-color: ${theme.palette.error.dark};
}
`;
const CancelButton = styled(Button)`
background-color: ${theme.palette.grey["200"]};
color: ${theme.palette.grey["700"]};
&:hover {
background-color: ${theme.palette.grey["300"]};
}
`;

View File

@@ -35,11 +35,6 @@ export function FileBar(properties: {
}}
onDelete={properties.onDelete}
/>
<HelpButton
onClick={() => window.open("https://docs.ironcalc.com", "_blank")}
>
Help
</HelpButton>
<WorkbookTitle
name={properties.model.getName()}
onNameChange={(name) => {
@@ -89,7 +84,7 @@ export function FileBar(properties: {
const StyledDesktopLogo = styled(IronCalcLogo)`
width: 120px;
margin-left: 12px;
margin-left: 10px;
@media (max-width: 769px) {
display: none;
}
@@ -103,19 +98,6 @@ const StyledIronCalcIcon = styled(IronCalcIcon)`
}
`;
const HelpButton = styled("div")`
display: flex;
align-items: center;
font-size: 12px;
font-family: Inter;
padding: 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #f2f2f2;
}
`;
const Toast = styled("div")`
font-weight: 400;
font-size: 12px;
@@ -125,7 +107,7 @@ const Toast = styled("div")`
`;
const Divider = styled("div")`
margin: 0px 8px 0px 16px;
margin: 10px;
height: 12px;
border-left: 1px solid #e0e0e0;
`;

View File

@@ -1,8 +1,7 @@
import styled from "@emotion/styled";
import { Menu, MenuItem, Modal } from "@mui/material";
import { Check, FileDown, FileUp, Plus, Trash2 } from "lucide-react";
import { FileDown, FileUp, Plus, Trash2 } from "lucide-react";
import { useRef, useState } from "react";
import { DeleteWorkbookDialog } from "./DeleteWorkbookDialog";
import { UploadFileDialog } from "./UploadFileDialog";
import { getModelsMetadata, getSelectedUuid } from "./storage";
@@ -19,7 +18,6 @@ export function FileMenu(props: {
const models = getModelsMetadata();
const uuids = Object.keys(models);
const selectedUuid = getSelectedUuid();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const elements = [];
for (const uuid of uuids) {
@@ -31,9 +29,9 @@ export function FileMenu(props: {
setMenuOpen(false);
}}
>
<CheckIndicator>
{uuid === selectedUuid ? <StyledCheck /> : ""}
</CheckIndicator>
<span style={{ width: "20px" }}>
{uuid === selectedUuid ? "•" : ""}
</span>
<MenuItemText
style={{
maxWidth: "240px",
@@ -59,19 +57,9 @@ export function FileMenu(props: {
open={isMenuOpen}
onClose={(): void => setMenuOpen(false)}
anchorEl={anchorElement.current}
sx={{
"& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" },
"& .MuiList-root": { padding: "0" },
}}
// anchorOrigin={properties.anchorOrigin}
>
<MenuItemWrapper
onClick={() => {
props.newModel();
setMenuOpen(false);
}}
>
<MenuItemWrapper onClick={props.newModel}>
<StyledPlus />
<MenuItemText>New</MenuItemText>
</MenuItemWrapper>
@@ -90,14 +78,16 @@ export function FileMenu(props: {
Download (.xlsx)
</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
setDeleteDialogOpen(true);
setMenuOpen(false);
}}
>
<MenuItemWrapper>
<StyledTrash />
<MenuItemText>Delete workbook</MenuItemText>
<MenuItemText
onClick={() => {
props.onDelete();
setMenuOpen(false);
}}
>
Delete workbook
</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
{elements}
@@ -127,18 +117,6 @@ export function FileMenu(props: {
/>
</>
</Modal>
<Modal
open={isDeleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid] : ""}
/>
</Modal>
</>
);
}
@@ -171,19 +149,12 @@ const StyledTrash = styled(Trash2)`
padding-right: 10px;
`;
const StyledCheck = styled(Check)`
width: 16px;
height: 16px;
color: #333333;
padding-right: 10px;
`;
const MenuDivider = styled("div")`
width: 100%;
width: 80%;
margin: auto;
margin-top: 4px;
margin-bottom: 4px;
border-top: 1px solid #eeeeee;
margin-top: 8px;
margin-bottom: 8px;
border-top: 1px solid #e0e0e0;
`;
const MenuItemText = styled("div")`
@@ -195,12 +166,7 @@ const MenuItemWrapper = styled(MenuItem)`
display: flex;
justify-content: flex-start;
font-size: 14px;
width: calc(100% - 8px);
min-width: 172px;
margin: 0px 4px;
border-radius: 4px;
padding: 8px;
height: 32px;
width: 100%;
`;
const FileMenuWrapper = styled("div")`
@@ -208,16 +174,11 @@ const FileMenuWrapper = styled("div")`
align-items: center;
font-size: 12px;
font-family: Inter;
padding: 8px;
padding: 10px;
height: 20px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #f2f2f2;
}
`;
const CheckIndicator = styled("span")`
display: flex;
justify-content: center;
min-width: 26px;
`;

View File

@@ -134,7 +134,7 @@ export function UploadFileDialog(properties: {
width: 16,
color: "#EFAA6D",
backgroundColor: "#F2994A1A",
padding: "2px 6px",
padding: "2px 4px",
borderRadius: 4,
}}
/>
@@ -192,13 +192,7 @@ export function UploadFileDialog(properties: {
<BookOpen
style={{ width: 16, height: 16, marginLeft: 12, marginRight: 8 }}
/>
<UploadFooterLink
href="https://docs.ironcalc.com/web-application/importing-files.html"
target="_blank"
rel="noopener noreferrer"
>
Learn more about importing files into IronCalc
</UploadFooterLink>
<span>Learn more about importing files into IronCalc</span>
</UploadFooter>
</UploadDialog>
);
@@ -208,46 +202,34 @@ const Cross = styled("div")`
&:hover {
background-color: #f5f5f5;
}
display: flex;
border-radius: 4px;
height: 24px;
width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
height: 16px;
width: 16px;
`;
const DocLink = styled("span")`
color: #f2994a;
text-decoration: none;
text-decoration: underline;
&:hover {
text-decoration: underline;
font-weight: bold;
}
`;
const UploadFooter = styled("div")`
height: 44px;
height: 40px;
border-top: 1px solid #e0e0e0;
color: #757575;
display: flex;
align-items: center;
`;
const UploadFooterLink = styled("a")`
font-size: 12px;
font-weight: 400;
color: #757575;
text-decoration: none;
&:hover {
text-decoration: underline;
}
display: flex;
align-items: center;
`;
const UploadTitle = styled("div")`
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
height: 44px;
height: 40px;
font-size: 14px;
font-weight: 500;
`;
@@ -259,8 +241,7 @@ const UploadDialog = styled("div")`
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 460px;
max-width: 90%;
width: 455px;
height: 285px;
background: #fff;
border: 1px solid #e0e0e0;
@@ -270,16 +251,6 @@ const UploadDialog = styled("div")`
`;
const DropZone = styled("div")`
&:hover {
border: 1px dashed #f2994a;
transition: 0.2s ease-in-out;
gap: 8px;
background: linear-gradient(
180deg,
rgba(242, 153, 74, 0.12) 0%,
rgba(242, 153, 74, 0) 100%
);
}
flex-grow: 2;
border-radius: 10px;
text-align: center;
@@ -288,7 +259,7 @@ const DropZone = styled("div")`
font-family: Arial, sans-serif;
cursor: pointer;
background-color: #faebd7;
border: 1px dashed #efaa6d;
border: 1px dashed #f2994a;
background: linear-gradient(
180deg,
rgba(242, 153, 74, 0.08) 0%,
@@ -297,6 +268,4 @@ const DropZone = styled("div")`
display: flex;
flex-direction: column;
vertical-align: center;
gap: 16px;
transition: 0.2s ease-in-out;
`;

View File

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

View File

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

View File

@@ -5,10 +5,10 @@ export const headerGlobalSelectorColor = "#EAECF4";
export const headerSelectedBackground = "#EEEEEE";
export const headerFullSelectedBackground = "#D3D6E9";
export const headerSelectedColor = "#333";
export const headerBorderColor = "#E0E0E0";
export const headerBorderColor = "#DEE0EF";
export const gridColor = "#E0E0E0";
export const gridSeparatorColor = "#E0E0E0";
export const gridSeparatorColor = "#D3D6E9";
export const defaultTextColor = "#2E414D";
export const outlineColor = "#F2994A";

View File

@@ -867,18 +867,14 @@ export default class WorksheetCanvas {
frozenRows,
frozenColumns,
);
if (frozenColumns > 0) {
xFrozenEnd += this.getColumnWidth(
this.model.getSelectedSheet(),
frozenColumns,
);
}
if (frozenRows > 0) {
yFrozenEnd += this.getRowHeight(
this.model.getSelectedSheet(),
frozenRows,
);
}
xFrozenEnd += this.getColumnWidth(
this.model.getSelectedSheet(),
frozenColumns,
);
yFrozenEnd += this.getRowHeight(
this.model.getSelectedSheet(),
frozenRows,
);
if (startRow <= frozenRows && endRow > frozenRows) {
yEnd = Math.max(yEnd, yFrozenEnd);
}

View File

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

View File

@@ -45,8 +45,8 @@ const ColorPicker = (properties: ColorPickerProps) => {
"#3BB68A",
"#8CB354",
"#F8CD3C",
"#F2994A",
"#EC5753",
"#A23C52",
"#D03627",
"#523E93",
"#3358B7",
@@ -71,7 +71,6 @@ const ColorPicker = (properties: ColorPickerProps) => {
setColor(newColor);
}}
/>
<HorizontalDivider />
<ColorPickerInput>
<HexWrapper>
<HexLabel>{"Hex"}</HexLabel>
@@ -132,8 +131,6 @@ const ColorPicker = (properties: ColorPickerProps) => {
const RecentLabel = styled.div`
font-family: "Inter";
font-size: 12px;
font-family: Inter;
margin: 8px 8px 0px 8px;
color: ${theme.palette.text.secondary};
`;
@@ -141,17 +138,14 @@ const ColorList = styled.div`
display: flex;
flex-wrap: wrap;
flex-direction: row;
margin: 8px;
justify-content: flex-start;
gap: 4.7px;
`;
const Button = styled.button<{ $color: string }>`
width: 16px;
height: 16px;
width: 20px;
height: 20px;
${({ $color }): string => {
if ($color.toUpperCase() === "#FFFFFF") {
return `border: 1px solid ${theme.palette.grey["300"]};`;
return `border: 1px solid ${theme.palette.grey["600"]};`;
}
return `border: 1px solid ${$color};`;
}}
@@ -159,19 +153,17 @@ const Button = styled.button<{ $color: string }>`
return $color;
}};
box-sizing: border-box;
margin-top: 0px;
border-radius: 4px;
&:hover {
cursor: pointer;
outline: 1px solid ${theme.palette.grey["300"]};
outline-offset: 1px;
}
margin-top: 10px;
margin-right: 10px;
border-radius: 2px;
`;
const HorizontalDivider = styled.div`
height: 0px;
width: 100%;
border-top: 1px solid ${theme.palette.grey["200"]};
border-top: 1px solid ${theme.palette.grey["400"]};
margin-top: 15px;
margin-bottom: 5px;
`;
// const StyledPopover = styled(Popover)`
@@ -191,7 +183,7 @@ const HorizontalDivider = styled.div`
const ColorPickerDialog = styled.div`
background: ${theme.palette.background.default};
width: ${colorPickerWidth}px;
padding: 0px;
padding: 15px;
display: flex;
flex-direction: column;
@@ -201,11 +193,11 @@ const ColorPickerDialog = styled.div`
}
& .react-colorful__saturation {
border-bottom: none;
border-radius: 0px;
border-radius: 5px;
}
& .react-colorful__hue {
height: 8px;
margin: 8px;
height: 20px;
margin-top: 15px;
border-radius: 5px;
}
& .react-colorful__saturation-pointer {
@@ -214,22 +206,19 @@ const ColorPickerDialog = styled.div`
}
& .react-colorful__hue-pointer {
width: 7px;
border-radius: 8px;
height: 16px;
width: 16px;
border-bottom: 1px solid #eee;
border-radius: 3px;
}
`;
const HashLabel = styled.div`
margin: auto 0px auto 10px;
font-size: 13px;
color: #333;
color: #7d8ec2;
font-family: ${theme.typography.button.fontFamily};
`;
const HexLabel = styled.div`
margin: auto 0px;
margin: auto 10px auto 0px;
font-size: 12px;
display: inline-flex;
font-family: ${theme.typography.button.fontFamily};
@@ -238,22 +227,15 @@ const HexLabel = styled.div`
const HexColorInputBox = styled.div`
display: inline-flex;
flex-grow: 1;
margin-right: 10px;
width: 140px;
height: 28px;
border: 1px solid ${theme.palette.grey["300"]};
border: 1px solid ${theme.palette.grey["600"]};
border-radius: 5px;
&:hover {
border: 1px solid ${theme.palette.grey["600"]};
}
&:focus-within {
outline: 2px solid ${theme.palette.secondary.main};
outline-offset: 1px;
}
`;
const HexWrapper = styled.div`
display: flex;
gap: 8px;
flex-grow: 1;
& input {
min-width: 0px;
@@ -277,7 +259,7 @@ const Swatch = styled.div<{ $color: string }>`
display: inline-flex;
${({ $color }): string => {
if ($color.toUpperCase() === "#FFFFFF") {
return `border: 1px solid ${theme.palette.grey["300"]};`;
return `border: 1px solid ${theme.palette.grey["600"]};`;
}
return `border: 1px solid ${$color};`;
}}
@@ -291,8 +273,7 @@ const ColorPickerInput = styled.div`
display: flex;
flex-direction: row;
align-items: center;
margin: 8px;
gap: 8px;
margin-top: 15px;
`;
export default ColorPicker;

View File

@@ -1,3 +1,3 @@
export const TOOLBAR_HEIGHT = 48;
export const FORMULA_BAR_HEIGHT = 40;
export const NAVIGATION_HEIGHT = 40;
export const TOOLBAR_HEIGH = 48;
export const FORMULA_BAR_HEIGH = 40;
export const NAVIGATION_HEIGH = 40;

View File

@@ -7,16 +7,6 @@ import {
} from "@ironcalc/wasm";
import type { ActiveRange } from "../workbookState";
function sliceString(
text: string,
startScalar: number,
endScalar: number,
): string {
const scalarValues = Array.from(text);
const sliced = scalarValues.slice(startScalar, endScalar);
return sliced.join("");
}
export function tokenIsReferenceType(token: TokenType): token is Reference {
return typeof token === "object" && "Reference" in token;
}
@@ -137,7 +127,7 @@ function getFormulaHTML(
}
html.push(
<span key={index} style={{ color }}>
{sliceString(formula, start, end)}
{formula.slice(start, end)}
</span>,
);
activeRanges.push({
@@ -172,7 +162,7 @@ function getFormulaHTML(
}
html.push(
<span key={index} style={{ color }}>
{sliceString(formula, start, end)}
{formula.slice(start, end)}
</span>,
);
colorCount += 1;
@@ -186,7 +176,7 @@ function getFormulaHTML(
color,
});
} else {
html.push(<span key={index}>{sliceString(formula, start, end)}</span>);
html.push(<span key={index}>{formula.slice(start, end)}</span>);
}
}
html = [<span key="equals">=</span>].concat(html);

View File

@@ -34,18 +34,11 @@ const FormatMenu = (properties: FormatMenuProps) => {
>
{properties.children}
</ChildrenWrapper>
<StyledMenu
<Menu
open={isMenuOpen}
onClose={(): void => setMenuOpen(false)}
anchorEl={anchorElement.current}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
anchorOrigin={properties.anchorOrigin}
>
<MenuItemWrapper onClick={(): void => onSelect(NumberFormats.AUTO)}>
<MenuItemText>{t("toolbar.format_menu.auto")}</MenuItemText>
@@ -114,7 +107,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper onClick={(): void => setPickerOpen(true)}>
<MenuItemText>{t("toolbar.format_menu.custom")}</MenuItemText>
</MenuItemWrapper>
</StyledMenu>
</Menu>
<FormatPicker
numFmt={properties.numFmt}
onChange={onSelect}
@@ -126,40 +119,18 @@ const FormatMenu = (properties: FormatMenuProps) => {
);
};
const StyledMenu = styled(Menu)`
& .MuiPaper-root {
border-radius: 8px;
padding: 4px 0px;
margin-left: -4px; // Starting with a small offset
}
& .MuiList-root {
padding: 0;
}
`;
const MenuItemWrapper = styled(MenuItem)`
display: flex;
justify-content: space-between;
font-size: 12px;
width: calc(100% - 8px);
min-width: 172px;
margin: 0px 4px;
border-radius: 4px;
padding: 8px;
height: 32px;
width: 100%;
`;
const ChildrenWrapper = styled("div")`
display: flex;
`;
const MenuDivider = styled("div")`
width: 100%;
margin: auto;
margin-top: 4px;
margin-bottom: 4px;
border-top: 1px solid #eeeeee;
`;
const MenuDivider = styled("div")``;
const MenuItemText = styled("div")`
color: #000;

View File

@@ -1,9 +1,13 @@
import styled from "@emotion/styled";
import { Dialog, TextField } from "@mui/material";
import { Check } from "lucide-react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../theme";
type FormatPickerProps = {
className?: string;
@@ -18,53 +22,17 @@ const FormatPicker = (properties: FormatPickerProps) => {
const { t } = useTranslation();
const [formatCode, setFormatCode] = useState(properties.numFmt);
const handleClose = () => {
properties.onClose();
};
const onSubmit = (format_code: string): void => {
properties.onChange(format_code);
properties.onClose();
};
return (
<Dialog
open={properties.open}
onClose={properties.onClose}
PaperProps={{
style: { minWidth: "280px" },
}}
>
<StyledDialogTitle>
{t("num_fmt.title")}
<Cross onClick={handleClose} onKeyDown={() => {}}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Close</title>
<path
d="M12 4.5L4 12.5"
stroke="#333333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 4.5L12 12.5"
stroke="#333333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Cross>
</StyledDialogTitle>
<StyledDialogContent>
<StyledTextField
autoFocus
<Dialog open={properties.open} onClose={properties.onClose}>
<DialogTitle>{t("num_fmt.title")}</DialogTitle>
<DialogContent dividers>
<TextField
defaultValue={properties.numFmt}
label={t("num_fmt.label")}
name="format_code"
onChange={(event) => setFormatCode(event.target.value)}
onKeyDown={(event) => {
@@ -72,94 +40,15 @@ const FormatPicker = (properties: FormatPickerProps) => {
}}
spellCheck="false"
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.target.select()}
/>
</StyledDialogContent>
<DialogFooter>
<StyledButton onClick={() => onSubmit(formatCode)}>
<Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => onSubmit(formatCode)}>
{t("num_fmt.save")}
</StyledButton>
</DialogFooter>
</Button>
</DialogActions>
</Dialog>
);
};
const StyledDialogTitle = styled("div")`
display: flex;
align-items: center;
height: 44px;
font-size: 14px;
font-weight: 500;
font-family: Inter;
padding: 0px 12px;
justify-content: space-between;
border-bottom: 1px solid ${theme.palette.grey["300"]};
`;
const Cross = styled("div")`
&:hover {
background-color: ${theme.palette.grey["100"]};
}
display: flex;
border-radius: 4px;
height: 24px;
width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
`;
const StyledDialogContent = styled("div")`
font-size: 12px;
margin: 12px;
`;
const StyledTextField = styled(TextField)`
width: 100%;
min-width: 320px;
border-radius: 4px;
overflow: hidden;
& .MuiInputBase-input {
font-size: 14px;
padding: 10px;
border: 1px solid ${theme.palette.grey["300"]};
border-radius: 4px;
color: ${theme.palette.common.black};
background-color: ${theme.palette.common.white};
}
&:hover .MuiInputBase-input {
border: 1px solid ${theme.palette.grey["500"]};
}
`;
const DialogFooter = styled("div")`
color: #757575;
display: flex;
align-items: center;
border-top: 1px solid ${theme.palette.grey["300"]};
font-family: Inter;
justify-content: flex-end;
padding: 12px;
`;
const StyledButton = styled("div")`
cursor: pointer;
color: #ffffff;
background: #f2994a;
padding: 0px 10px;
height: 36px;
line-height: 36px;
border-radius: 4px;
display: flex;
align-items: center;
font-family: "Inter";
font-size: 14px;
&:hover {
background: #d68742;
}
`;
export default FormatPicker;

View File

@@ -5,7 +5,7 @@ import {
COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants";
import { FORMULA_BAR_HEIGHT } from "./constants";
import { FORMULA_BAR_HEIGH } from "./constants";
import Editor from "./editor/editor";
import type { WorkbookState } from "./workbookState";
@@ -122,7 +122,7 @@ const Container = styled("div")`
align-items: center;
background: ${(properties): string =>
properties.theme.palette.background.default};
height: ${FORMULA_BAR_HEIGHT}px;
height: ${FORMULA_BAR_HEIGH}px;
`;
const AddressContainer = styled("div")`

View File

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

View File

@@ -1,16 +1,66 @@
import { styled } from "@mui/material";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
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 type { SheetOptions } from "./types";
function isWhiteColor(color: string): boolean {
return ["#FFF", "#FFFFFF"].includes(color);
}
interface SheetRenameDialogProps {
isOpen: boolean;
close: () => void;
onNameChanged: (name: string) => void;
defaultName: string;
}
export const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
const { t } = useTranslation();
const [name, setName] = useState(properties.defaultName);
return (
<Dialog open={properties.isOpen} onClose={properties.close}>
<DialogTitle>{t("sheet_rename.title")}</DialogTitle>
<DialogContent dividers>
<TextField
defaultValue={properties.defaultName}
label={t("sheet_rename.label")}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
}}
onChange={(event) => {
setName(event.target.value);
}}
spellCheck="false"
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
properties.onNameChanged(name);
}}
>
{t("sheet_rename.rename")}
</Button>
</DialogActions>
</Dialog>
);
};
interface SheetListMenuProps {
open: boolean;
onClose: () => void;
isOpen: boolean;
close: () => void;
anchorEl: HTMLButtonElement | null;
onSheetSelected: (index: number) => void;
sheetOptionsList: SheetOptions[];
@@ -19,8 +69,8 @@ interface SheetListMenuProps {
const SheetListMenu = (properties: SheetListMenuProps) => {
const {
open,
onClose,
isOpen,
close,
anchorEl,
onSheetSelected,
sheetOptionsList,
@@ -31,8 +81,8 @@ const SheetListMenu = (properties: SheetListMenuProps) => {
return (
<StyledMenu
open={open}
onClose={onClose}
open={isOpen}
onClose={close}
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",

View File

@@ -2,15 +2,14 @@ import { styled } from "@mui/material";
import { Menu, Plus } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
import { NAVIGATION_HEIGHT } from "../constants";
import { NAVIGATION_HEIGH } from "../constants";
import { StyledButton } from "../toolbar";
import type { WorkbookState } from "../workbookState";
import SheetListMenu from "./SheetListMenu";
import SheetTab from "./SheetTab";
import SheetListMenu from "./menus";
import Sheet from "./sheet";
import type { SheetOptions } from "./types";
export interface SheetTabBarProps {
export interface NavigationProps {
sheets: SheetOptions[];
selectedIndex: number;
workbookState: WorkbookState;
@@ -21,7 +20,7 @@ export interface SheetTabBarProps {
onSheetDeleted: () => void;
}
function SheetTabBar(props: SheetTabBarProps) {
function Navigation(props: NavigationProps) {
const { t } = useTranslation();
const { workbookState, onSheetSelected, sheets, selectedIndex } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
@@ -35,27 +34,24 @@ function SheetTabBar(props: SheetTabBarProps) {
return (
<Container>
<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 />
<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>
<Sheets>
<SheetInner>
{sheets.map((tab, index) => (
<SheetTab
<Sheet
key={tab.sheetId}
name={tab.name}
color={tab.color}
@@ -71,7 +67,6 @@ function SheetTabBar(props: SheetTabBarProps) {
props.onSheetDeleted();
}}
workbookState={workbookState}
canDelete={sheets.length > 1}
/>
))}
</SheetInner>
@@ -81,8 +76,8 @@ function SheetTabBar(props: SheetTabBarProps) {
</Advert>
<SheetListMenu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
isOpen={open}
close={handleClose}
sheetOptionsList={sheets}
onSheetSelected={(index) => {
onSheetSelected(index);
@@ -96,29 +91,22 @@ function SheetTabBar(props: SheetTabBarProps) {
// 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;
right: 0px;
display: flex;
height: ${NAVIGATION_HEIGHT}px;
height: ${NAVIGATION_HEIGH}px;
align-items: center;
padding: 0px 12px;
padding-left: 12px;
font-family: Inter;
background-color: ${theme.palette.common.white};
border-top: 1px solid ${theme.palette.grey["300"]};
background-color: #fff;
border-top: 1px solid #E0E0E0;
`;
const Sheets = styled("div")`
flex-grow: 2;
overflow: hidden;
overflow-x: auto;
scrollbar-width: none;
padding-left: 12px;
display: flex;
flex-direction: row;
`;
const SheetInner = styled("div")`
@@ -126,35 +114,10 @@ const SheetInner = styled("div")`
`;
const Advert = styled("a")`
display: flex;
align-items: center;
color: ${theme.palette.primary.main};
padding: 0px 0px 0px 12px;
color: #f2994a;
margin-right: 12px;
font-size: 12px;
text-decoration: none;
border-left: 1px solid ${theme.palette.grey["300"]};
transition: color 0.2s ease-in-out;
&:hover {
text-decoration: underline;
}
@media (max-width: 769px) {
height: 100%;
}
`;
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;
export default Navigation;

View File

@@ -1,26 +1,23 @@
import { Button, Menu, MenuItem, styled } from "@mui/material";
import type { MenuItemProps } 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 "./SheetRenameDialog";
import { SheetRenameDialog } from "./menus";
interface SheetTabProps {
interface SheetProps {
name: string;
color: string;
selected: boolean;
onSelected: () => void;
onColorChanged: (hex: string) => void;
onRenamed: (name: string) => void;
canDelete: boolean;
onDeleted: () => void;
workbookState: WorkbookState;
}
function SheetTab(props: SheetTabProps) {
function Sheet(props: SheetProps) {
const { name, color, selected, workbookState, onSelected } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
@@ -41,9 +38,8 @@ function SheetTab(props: SheetTabProps) {
};
return (
<>
<TabWrapper
$color={color}
$selected={selected}
<Wrapper
style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }}
onClick={(event) => {
onSelected();
event.stopPropagation();
@@ -59,11 +55,11 @@ function SheetTab(props: SheetTabProps) {
}}
ref={colorButton}
>
<Name onDoubleClick={handleOpenRenameDialog}>{name}</Name>
<Name>{name}</Name>
<StyledButton onClick={handleOpen}>
<ChevronDown />
</StyledButton>
</TabWrapper>
</Wrapper>
<StyledMenu
anchorEl={anchorEl}
open={open}
@@ -94,7 +90,6 @@ function SheetTab(props: SheetTabProps) {
Change Color
</StyledMenuItem>
<StyledMenuItem
disabled={!props.canDelete}
onClick={() => {
props.onDeleted();
handleClose();
@@ -105,8 +100,8 @@ function SheetTab(props: SheetTabProps) {
</StyledMenuItem>
</StyledMenu>
<SheetRenameDialog
open={renameDialogOpen}
onClose={handleCloseRenameDialog}
isOpen={renameDialogOpen}
close={handleCloseRenameDialog}
defaultName={name}
onNameChanged={(newName) => {
props.onRenamed(newName);
@@ -129,42 +124,10 @@ function SheetTab(props: SheetTabProps) {
);
}
const StyledMenu = styled(Menu)`
& .MuiPaper-root {
border-radius: 8px;
padding: 4px 0px;
margin-left: -4px;
}
& .MuiList-root {
padding: 0;
}
`;
const StyledMenu = styled(Menu)``;
const StyledMenuItem = styled(MenuItem)<MenuItemProps>(() => ({
display: "flex",
justifyContent: "space-between",
fontSize: "12px",
width: "calc(100% - 8px)",
margin: "0px 4px",
borderRadius: "4px",
padding: "8px",
height: "32px",
"&:disabled": {
color: "#BDBDBD",
},
}));
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 StyledMenuItem = styled(MenuItem)`
font-size: 12px;
`;
const StyledButton = styled(Button)`
@@ -174,27 +137,26 @@ 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 SheetTab;
export default Sheet;

View File

@@ -18,7 +18,7 @@ import {
Grid2x2X,
Italic,
PaintBucket,
PaintRoller,
Paintbrush2,
Percent,
Redo2,
Strikethrough,
@@ -36,7 +36,7 @@ import {
import { theme } from "../theme";
import BorderPicker from "./borderPicker";
import ColorPicker from "./colorPicker";
import { TOOLBAR_HEIGHT } from "./constants";
import { TOOLBAR_HEIGH } from "./constants";
import FormatMenu from "./formatMenu";
import {
NumberFormats,
@@ -114,7 +114,7 @@ function Toolbar(properties: ToolbarProperties) {
onClick={properties.onCopyStyles}
title={t("toolbar.copy_styles")}
>
<PaintRoller />
<Paintbrush2 />
</StyledButton>
<Divider />
<StyledButton
@@ -183,7 +183,8 @@ function Toolbar(properties: ToolbarProperties) {
title={t("toolbar.format_number")}
sx={{
width: "40px", // Keep in sync with anchorOrigin in FormatMenu above
padding: "0px 4px",
fontSize: "13px",
fontWeight: 400,
}}
>
{"123"}
@@ -250,16 +251,6 @@ function Toolbar(properties: ToolbarProperties) {
>
<PaintBucket />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
onClick={() => setBorderPickerOpen(true)}
ref={borderButton}
disabled={!canEdit}
title={t("toolbar.borders.title")}
>
<Grid2X2 />
</StyledButton>
<Divider />
<StyledButton
type="button"
@@ -327,7 +318,17 @@ function Toolbar(properties: ToolbarProperties) {
>
<ArrowDownToLine />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
onClick={() => setBorderPickerOpen(true)}
ref={borderButton}
disabled={!canEdit}
title={t("toolbar.borders.title")}
>
<Grid2X2 />
</StyledButton>
<Divider />
<StyledButton
type="button"
@@ -384,15 +385,13 @@ const ToolbarContainer = styled("div")`
flex-shrink: 0;
align-items: center;
background: ${({ theme }) => theme.palette.background.paper};
height: ${TOOLBAR_HEIGHT}px;
line-height: ${TOOLBAR_HEIGHT}px;
height: ${TOOLBAR_HEIGH}px;
line-height: ${TOOLBAR_HEIGH}px;
border-bottom: 1px solid ${({ theme }) => theme.palette.grey["300"]};
font-family: Inter;
border-radius: 4px 4px 0px 0px;
overflow-x: auto;
padding: 0px 12px;
gap: 4px;
scrollbar-width: none;
padding-left: 11px;
`;
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
@@ -400,16 +399,15 @@ 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: "12px",
border: `0px solid ${theme.palette.common.white}`,
borderRadius: "4px",
fontSize: "26px",
border: "0px solid #fff",
borderRadius: "2px",
marginRight: "5px",
transition: "all 0.2s",
outline: `1px solid ${theme.palette.common.white}`,
cursor: "pointer",
backgroundColor: "white",
padding: "0px",
@@ -421,28 +419,19 @@ export const StyledButton = styled("button")<TypeButtonProperties>(
if (disabled) {
return {
...result,
color: theme.palette.grey["400"],
color: theme.palette.grey["600"],
cursor: "default",
};
}
return {
...result,
borderTop: $underlinedColor
? `3px solid ${theme.palette.common.white}`
: "none",
borderTop: $underlinedColor ? "3px solid #FFF" : "none",
borderBottom: $underlinedColor ? `3px solid ${$underlinedColor}` : "none",
color: theme.palette.grey["900"],
backgroundColor: $pressed
? theme.palette.grey["300"]
: theme.palette.common.white,
color: "#21243A",
backgroundColor: $pressed ? "#EEE" : "#FFF",
"&:hover": {
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"]}`,
backgroundColor: "#F1F2F8",
borderTopColor: "#F1F2F8",
},
};
},
@@ -450,9 +439,10 @@ export const StyledButton = styled("button")<TypeButtonProperties>(
const Divider = styled("div")({
width: "0px",
height: "12px",
borderLeft: `1px solid ${theme.palette.grey["300"]}`,
margin: "0px 12px",
height: "10px",
borderLeft: "1px solid #E0E0E0",
marginLeft: "5px",
marginRight: "10px",
});
export default Toolbar;

View File

@@ -6,7 +6,6 @@ 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,
@@ -17,6 +16,7 @@ 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";
@@ -390,12 +390,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}
data.set(Number.parseInt(row, 10), rowMap);
}
model.pasteFromClipboard(
source.sheet,
source.area,
data,
source.type === "cut",
);
model.pasteFromClipboard(source.area, data, source.type === "cut");
setRedrawId((id) => id + 1);
} else if (mimeType === "text/plain") {
const {
@@ -421,7 +416,6 @@ 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(
@@ -449,7 +443,6 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
type: "copy",
area: data.range,
sheetData,
sheet,
clipboardId,
});
event.clipboardData.setData("text/plain", data.csv);
@@ -459,7 +452,6 @@ 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(
@@ -487,7 +479,6 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
type: "cut",
area: data.range,
sheetData,
sheet,
clipboardId,
});
event.clipboardData.setData("text/plain", data.csv);
@@ -539,7 +530,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
);
setRedrawId((id) => id + 1);
}}
fillColor={style.fill.fg_color || "#FFFFFF"}
fillColor={style.fill.fg_color || "#FFF"}
fontColor={style.font.color}
bold={style.font.b}
underline={style.font.u}
@@ -581,7 +572,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}}
/>
<SheetTabBar
<Navigation
sheets={info}
selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}

View File

@@ -9,9 +9,9 @@ import {
} from "./WorksheetCanvas/constants";
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
import {
FORMULA_BAR_HEIGHT,
NAVIGATION_HEIGHT,
TOOLBAR_HEIGHT,
FORMULA_BAR_HEIGH,
NAVIGATION_HEIGH,
TOOLBAR_HEIGH,
} from "./constants";
import Editor from "./editor/editor";
import type { Cell } from "./types";
@@ -104,16 +104,10 @@ function Worksheet(props: {
editor: editor,
},
onColumnWidthChanges(sheet, column, width) {
if (width < 0) {
return;
}
model.setColumnWidth(sheet, column, width);
worksheetCanvas.current?.renderSheet();
},
onRowHeightChanges(sheet, row, height) {
if (height < 0) {
return;
}
model.setRowHeight(sheet, row, height);
worksheetCanvas.current?.renderSheet();
},
@@ -439,10 +433,10 @@ const SheetContainer = styled("div")`
const Wrapper = styled("div")({
position: "absolute",
overflow: "scroll",
top: TOOLBAR_HEIGHT + FORMULA_BAR_HEIGHT + 1,
top: TOOLBAR_HEIGH + FORMULA_BAR_HEIGH + 1,
left: 0,
right: 0,
bottom: NAVIGATION_HEIGHT + 1,
bottom: NAVIGATION_HEIGH + 1,
overscrollBehavior: "none",
});

View File

@@ -1,3 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
<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"/>
</svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -246,8 +246,18 @@ pub(crate) fn get_worksheet_xml(
}
let sheet_data = sheet_data_str.join("");
for merge_cell_ref in &worksheet.merge_cells {
merged_cells_str.push(format!("<mergeCell ref=\"{merge_cell_ref}\"/>"))
for merged_cells_ref in &worksheet.merged_cells_list {
let merged_cells_range_str_ref: String = match merged_cells_ref.get_merged_cells_str_ref() {
Ok(merged_cells_ref) => merged_cells_ref,
Err(err) => {
// ATTENTION : This should not happen. There should not be error while exporting
// already imported/created Mergedcells structure
// Currently, this function does not return any error. so logging the error and skipping this errored one
println!("{}", err);
continue;
}
};
merged_cells_str.push(format!("<mergeCell ref=\"{merged_cells_range_str_ref}\"/>"))
}
let merged_cells_count = merged_cells_str.len();

View File

@@ -10,7 +10,7 @@ use ironcalc_base::{
utils::{column_to_number, parse_reference_a1},
},
types::{
Cell, Col, Comment, DefinedName, Row, SheetData, SheetState, Table, Worksheet,
Cell, Col, Comment, DefinedName, MergedCells, Row, SheetData, SheetState, Table, Worksheet,
WorksheetView,
},
};
@@ -148,12 +148,12 @@ fn load_columns(ws: Node) -> Result<Vec<Col>, XlsxError> {
Ok(cols)
}
fn load_merge_cells(ws: Node) -> Result<Vec<String>, XlsxError> {
fn load_merge_cells_nodes(ws: Node) -> Result<Vec<MergedCells>, XlsxError> {
// 18.3.1.55 Merge Cells
// <mergeCells count="1">
// <mergeCell ref="K7:L10"/>
// </mergeCells>
let mut merge_cells = Vec::new();
let mut merged_cells_list: Vec<MergedCells> = Vec::new();
let merge_cells_nodes = ws
.children()
.filter(|n| n.has_tag_name("mergeCells"))
@@ -161,10 +161,18 @@ fn load_merge_cells(ws: Node) -> Result<Vec<String>, XlsxError> {
if merge_cells_nodes.len() == 1 {
for merge_cell in merge_cells_nodes[0].children() {
let reference = get_attribute(&merge_cell, "ref")?.to_string();
merge_cells.push(reference);
match parse_range(&reference) {
Ok(parsed_merge_cell_range) => {
let merge_cell_node = MergedCells::new(parsed_merge_cell_range);
merged_cells_list.push(merge_cell_node);
}
Err(err) => {
println!("encountered error while parsing merge cell ref : {}", err);
}
}
}
}
Ok(merge_cells)
Ok(merged_cells_list)
}
fn load_sheet_color(ws: Node) -> Result<Option<String>, XlsxError> {
@@ -943,7 +951,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
sheet_data.insert(row_index, data_row);
}
let merge_cells = load_merge_cells(ws)?;
let merge_cells_nodes = load_merge_cells_nodes(ws)?;
// Conditional Formatting
// <conditionalFormatting sqref="B1:B9">
@@ -982,7 +990,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
sheet_id,
state: state.to_owned(),
color,
merge_cells,
merged_cells_list: merge_cells_nodes,
comments: settings.comments,
frozen_rows: sheet_view.frozen_rows,
frozen_columns: sheet_view.frozen_columns,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -406,7 +406,7 @@ fn test_exporting_merged_cells() {
.worksheets
.first()
.unwrap()
.merge_cells
.merged_cells_list
.clone();
// exporting and saving it in another xlsx
model.evaluate();
@@ -423,7 +423,7 @@ fn test_exporting_merged_cells() {
.worksheets
.first()
.unwrap()
.merge_cells
.merged_cells_list
.clone();
assert_eq!(expected_merge_cell_ref, *got_merge_cell_ref);
fs::remove_file(temp_file_name).unwrap();
@@ -437,7 +437,7 @@ fn test_exporting_merged_cells() {
.worksheets
.get_mut(0)
.unwrap()
.merge_cells
.merged_cells_list
.clear();
save_to_xlsx(&temp_model, temp_file_name).unwrap();
@@ -447,7 +447,7 @@ fn test_exporting_merged_cells() {
.worksheets
.first()
.unwrap()
.merge_cells
.merged_cells_list
.len();
assert!(*got_merge_cell_ref_cnt == 0);
}
@@ -494,3 +494,73 @@ fn test_documentation_xlsx() {
}
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_merge_cell_import_export_behaviors() {
// loading the xlsx file containing merged cells
let example_file_name = "tests/Merged_cells.xlsx";
let mut model = load_from_xlsx(example_file_name, "en", "UTC").unwrap();
// Case1 : To check whether Merge cells structures got imported properly or not
let imported_merge_cell_vec = model.workbook.worksheet(0).unwrap().get_merged_cells_list();
assert_eq!(imported_merge_cell_vec.len(), 5);
let range_refs_of_merge_cell: Vec<String> = imported_merge_cell_vec
.iter()
.map(|cell| cell.get_merged_cells_str_ref().unwrap())
.collect();
assert_eq!(
range_refs_of_merge_cell,
[
"C1:D3".to_string(),
"A1:B4".to_string(),
"G1:H7".to_string(),
"D8:E9".to_string(),
"D4:F6".to_string()
]
);
// Create one More Merge cell which Overlaps with 3 More
model.merge_cells(0, "A1:D5").unwrap();
model
.set_user_input(0, 1, 1, "New overlapped Merge cell".to_string())
.unwrap();
let mut style = model.get_style_for_cell(0, 1, 1).unwrap();
style.font.b = true;
assert_eq!(
model.workbook.styles.create_named_style("bold", &style),
Ok(())
);
model.set_cell_style_by_name(0, 1, 1, "bold").unwrap();
// Lets export to different Excell
let exported_merge_cell_xlsx = "temporary_exported_mergecells.xlsx";
save_to_xlsx(&model, exported_merge_cell_xlsx).unwrap();
{
let temp_model = load_from_xlsx(exported_merge_cell_xlsx, "en", "UTC").unwrap();
// Loading the exported sheet back and verifying whether it got exported properly or not
let imported_merge_cell_vec = temp_model
.workbook
.worksheet(0)
.unwrap()
.get_merged_cells_list();
assert_eq!(imported_merge_cell_vec.len(), 3);
let range_refs_of_merge_cell: Vec<String> = imported_merge_cell_vec
.iter()
.map(|cell| cell.get_merged_cells_str_ref().unwrap())
.collect();
assert_eq!(
range_refs_of_merge_cell,
[
"G1:H7".to_string(),
"D8:E9".to_string(),
"A1:D5".to_string()
]
);
}
fs::remove_file(exported_merge_cell_xlsx).unwrap();
}