Compare commits

..

8 Commits

Author SHA1 Message Date
Nicolás Hatcher
6740a43fe6 UPDATE: Add "Export Area to markdown" 2025-06-08 12:40:54 +02:00
Nicolás Hatcher
2b3ae8e20f FIX: Remove some unused dependencies and updated dependencies 2025-06-07 15:32:23 +02:00
Nicolás Hatcher
138a483c65 UPDATE: Double click in the outline handle fills column
This also removes React from the equation.
So all event handling is done outside of the React loop.
This simplifies some things and helps us in a possible move
away from React.

This is closer to how we deal with the column and row handle resizers.

I think it works quite well and it is more future proof.

But TBH I just want to try it out and see what is the DX after this.

Fixes #359
2025-06-07 11:12:10 +02:00
Nicolás Hatcher
2eb9266c30 UPDATE: Factor out a couple of helper functions from the main canvas file 2025-06-07 11:12:10 +02:00
Nicolás Hatcher
b9d3f5329b FIX: There are two fills in every new Excel model
Excel expects two default fills. If a different fill is present Excel
removes it and substitutes it with the defaults.

This resulted in models having the default fill for the first style.

Thanks @scandel!
2025-06-05 06:36:34 +02:00
Nicolás Hatcher
af49d7ad96 FIX: Download to png off by one errors 2025-06-02 21:11:18 +02:00
Nicolás Hatcher
3e015bf13a FIX: control+shitf selects area 2025-06-02 20:59:18 +02:00
Nicolás Hatcher
a5d8ee9ef0 FIX: Download all selected area
We were previously downloading only the bounds of the visible cells,
without taking into account the frozen rows/colums.

Fixes #343
2025-05-17 11:49:42 +02:00
43 changed files with 2464 additions and 3696 deletions

View File

@@ -22,7 +22,7 @@ impl Model {
.cell(row, column) .cell(row, column)
.and_then(|c| c.get_formula()) .and_then(|c| c.get_formula())
{ {
let node = &self.parsed_formulas[sheet as usize][f as usize].0.clone(); let node = &self.parsed_formulas[sheet as usize][f as usize].clone();
let cell_reference = CellReferenceRC { let cell_reference = CellReferenceRC {
sheet: self.workbook.worksheets[sheet as usize].get_name(), sheet: self.workbook.worksheets[sheet as usize].get_name(),
row, row,

View File

@@ -77,6 +77,8 @@ impl Model {
match to_f64(&node) { match to_f64(&node) {
Ok(f2) => match op(f1, f2) { Ok(f2) => match op(f1, f2) {
Ok(x) => data_row.push(ArrayNode::Number(x)), Ok(x) => data_row.push(ArrayNode::Number(x)),
Err(Error::DIV) => data_row.push(ArrayNode::Error(Error::DIV)),
Err(Error::VALUE) => data_row.push(ArrayNode::Error(Error::VALUE)),
Err(e) => data_row.push(ArrayNode::Error(e)), Err(e) => data_row.push(ArrayNode::Error(e)),
}, },
Err(err) => data_row.push(ArrayNode::Error(err)), Err(err) => data_row.push(ArrayNode::Error(err)),
@@ -98,6 +100,8 @@ impl Model {
match to_f64(&node) { match to_f64(&node) {
Ok(f1) => match op(f1, f2) { Ok(f1) => match op(f1, f2) {
Ok(x) => data_row.push(ArrayNode::Number(x)), Ok(x) => data_row.push(ArrayNode::Number(x)),
Err(Error::DIV) => data_row.push(ArrayNode::Error(Error::DIV)),
Err(Error::VALUE) => data_row.push(ArrayNode::Error(Error::VALUE)),
Err(e) => data_row.push(ArrayNode::Error(e)), Err(e) => data_row.push(ArrayNode::Error(e)),
}, },
Err(err) => data_row.push(ArrayNode::Error(err)), Err(err) => data_row.push(ArrayNode::Error(err)),
@@ -133,6 +137,10 @@ impl Model {
(Some(v1), Some(v2)) => match (to_f64(v1), to_f64(v2)) { (Some(v1), Some(v2)) => match (to_f64(v1), to_f64(v2)) {
(Ok(f1), Ok(f2)) => match op(f1, f2) { (Ok(f1), Ok(f2)) => match op(f1, f2) {
Ok(x) => data_row.push(ArrayNode::Number(x)), Ok(x) => data_row.push(ArrayNode::Number(x)),
Err(Error::DIV) => data_row.push(ArrayNode::Error(Error::DIV)),
Err(Error::VALUE) => {
data_row.push(ArrayNode::Error(Error::VALUE))
}
Err(e) => data_row.push(ArrayNode::Error(e)), Err(e) => data_row.push(ArrayNode::Error(e)),
}, },
(Err(e), _) | (_, Err(e)) => data_row.push(ArrayNode::Error(e)), (Err(e), _) | (_, Err(e)) => data_row.push(ArrayNode::Error(e)),

View File

@@ -64,50 +64,12 @@ impl Cell {
/// Returns the formula of a cell if any. /// Returns the formula of a cell if any.
pub fn get_formula(&self) -> Option<i32> { pub fn get_formula(&self) -> Option<i32> {
match self { match self {
Cell::CellFormula { f, .. } Cell::CellFormula { f, .. } => Some(*f),
| Cell::CellFormulaBoolean { f, .. } Cell::CellFormulaBoolean { f, .. } => Some(*f),
| Cell::CellFormulaNumber { f, .. } Cell::CellFormulaNumber { f, .. } => Some(*f),
| Cell::CellFormulaString { f, .. } Cell::CellFormulaString { f, .. } => Some(*f),
| Cell::CellFormulaError { f, .. } Cell::CellFormulaError { f, .. } => Some(*f),
| Cell::DynamicCellFormula { f, .. } _ => None,
| Cell::DynamicCellFormulaBoolean { f, .. }
| Cell::DynamicCellFormulaNumber { f, .. }
| Cell::DynamicCellFormulaString { f, .. }
| Cell::DynamicCellFormulaError { f, .. } => Some(*f),
Cell::EmptyCell { .. }
| Cell::BooleanCell { .. }
| Cell::NumberCell { .. }
| Cell::ErrorCell { .. }
| Cell::SharedString { .. }
| Cell::SpillNumberCell { .. }
| Cell::SpillBooleanCell { .. }
| Cell::SpillErrorCell { .. }
| Cell::SpillStringCell { .. } => None,
}
}
/// Returns the dynamic range of a cell if any.
pub fn get_dynamic_range(&self) -> Option<(i32, i32)> {
match self {
Cell::DynamicCellFormula { r, .. } => Some(*r),
Cell::DynamicCellFormulaBoolean { r, .. } => Some(*r),
Cell::DynamicCellFormulaNumber { r, .. } => Some(*r),
Cell::DynamicCellFormulaString { r, .. } => Some(*r),
Cell::DynamicCellFormulaError { r, .. } => Some(*r),
Cell::EmptyCell { .. }
| Cell::BooleanCell { .. }
| Cell::NumberCell { .. }
| Cell::ErrorCell { .. }
| Cell::SharedString { .. }
| Cell::CellFormula { .. }
| Cell::CellFormulaBoolean { .. }
| Cell::CellFormulaNumber { .. }
| Cell::CellFormulaString { .. }
| Cell::CellFormulaError { .. }
| Cell::SpillNumberCell { .. }
| Cell::SpillBooleanCell { .. }
| Cell::SpillErrorCell { .. }
| Cell::SpillStringCell { .. } => None,
} }
} }
@@ -127,15 +89,6 @@ impl Cell {
Cell::CellFormulaNumber { s, .. } => *s = style, Cell::CellFormulaNumber { s, .. } => *s = style,
Cell::CellFormulaString { s, .. } => *s = style, Cell::CellFormulaString { s, .. } => *s = style,
Cell::CellFormulaError { s, .. } => *s = style, Cell::CellFormulaError { s, .. } => *s = style,
Cell::SpillBooleanCell { s, .. } => *s = style,
Cell::SpillNumberCell { s, .. } => *s = style,
Cell::SpillStringCell { s, .. } => *s = style,
Cell::SpillErrorCell { s, .. } => *s = style,
Cell::DynamicCellFormula { s, .. } => *s = style,
Cell::DynamicCellFormulaBoolean { s, .. } => *s = style,
Cell::DynamicCellFormulaNumber { s, .. } => *s = style,
Cell::DynamicCellFormulaString { s, .. } => *s = style,
Cell::DynamicCellFormulaError { s, .. } => *s = style,
}; };
} }
@@ -151,15 +104,6 @@ impl Cell {
Cell::CellFormulaNumber { s, .. } => *s, Cell::CellFormulaNumber { s, .. } => *s,
Cell::CellFormulaString { s, .. } => *s, Cell::CellFormulaString { s, .. } => *s,
Cell::CellFormulaError { s, .. } => *s, Cell::CellFormulaError { s, .. } => *s,
Cell::SpillBooleanCell { s, .. } => *s,
Cell::SpillNumberCell { s, .. } => *s,
Cell::SpillStringCell { s, .. } => *s,
Cell::SpillErrorCell { s, .. } => *s,
Cell::DynamicCellFormula { s, .. } => *s,
Cell::DynamicCellFormulaBoolean { s, .. } => *s,
Cell::DynamicCellFormulaNumber { s, .. } => *s,
Cell::DynamicCellFormulaString { s, .. } => *s,
Cell::DynamicCellFormulaError { s, .. } => *s,
} }
} }
@@ -175,15 +119,6 @@ impl Cell {
Cell::CellFormulaNumber { .. } => CellType::Number, Cell::CellFormulaNumber { .. } => CellType::Number,
Cell::CellFormulaString { .. } => CellType::Text, Cell::CellFormulaString { .. } => CellType::Text,
Cell::CellFormulaError { .. } => CellType::ErrorValue, Cell::CellFormulaError { .. } => CellType::ErrorValue,
Cell::SpillBooleanCell { .. } => CellType::LogicalValue,
Cell::SpillNumberCell { .. } => CellType::Number,
Cell::SpillStringCell { .. } => CellType::Text,
Cell::SpillErrorCell { .. } => CellType::ErrorValue,
Cell::DynamicCellFormula { .. } => CellType::Number,
Cell::DynamicCellFormulaBoolean { .. } => CellType::LogicalValue,
Cell::DynamicCellFormulaNumber { .. } => CellType::Number,
Cell::DynamicCellFormulaString { .. } => CellType::Text,
Cell::DynamicCellFormulaError { .. } => CellType::ErrorValue,
} }
} }
@@ -201,7 +136,7 @@ impl Cell {
Cell::EmptyCell { .. } => CellValue::None, Cell::EmptyCell { .. } => CellValue::None,
Cell::BooleanCell { v, s: _ } => CellValue::Boolean(*v), Cell::BooleanCell { v, s: _ } => CellValue::Boolean(*v),
Cell::NumberCell { v, s: _ } => CellValue::Number(*v), Cell::NumberCell { v, s: _ } => CellValue::Number(*v),
Cell::ErrorCell { ei, .. } | Cell::SpillErrorCell { ei, .. } => { Cell::ErrorCell { ei, .. } => {
let v = ei.to_localized_error_string(language); let v = ei.to_localized_error_string(language);
CellValue::String(v) CellValue::String(v)
} }
@@ -213,25 +148,14 @@ impl Cell {
}; };
CellValue::String(v) CellValue::String(v)
} }
Cell::DynamicCellFormula { .. } | Cell::CellFormula { .. } => { Cell::CellFormula { .. } => CellValue::String("#ERROR!".to_string()),
CellValue::String("#ERROR!".to_string()) Cell::CellFormulaBoolean { v, .. } => CellValue::Boolean(*v),
} Cell::CellFormulaNumber { v, .. } => CellValue::Number(*v),
Cell::DynamicCellFormulaBoolean { v, .. } | Cell::CellFormulaBoolean { v, .. } => { Cell::CellFormulaString { v, .. } => CellValue::String(v.clone()),
CellValue::Boolean(*v) Cell::CellFormulaError { ei, .. } => {
}
Cell::DynamicCellFormulaNumber { v, .. } | Cell::CellFormulaNumber { v, .. } => {
CellValue::Number(*v)
}
Cell::DynamicCellFormulaString { v, .. } | Cell::CellFormulaString { v, .. } => {
CellValue::String(v.clone())
}
Cell::DynamicCellFormulaError { ei, .. } | Cell::CellFormulaError { ei, .. } => {
let v = ei.to_localized_error_string(language); let v = ei.to_localized_error_string(language);
CellValue::String(v) CellValue::String(v)
} }
Cell::SpillBooleanCell { v, .. } => CellValue::Boolean(*v),
Cell::SpillNumberCell { v, .. } => CellValue::Number(*v),
Cell::SpillStringCell { v, .. } => CellValue::String(v.clone()),
} }
} }

View File

@@ -182,7 +182,7 @@ pub fn add_implicit_intersection(node: &mut Node, add: bool) {
}; };
} }
pub enum StaticResult { pub(crate) enum StaticResult {
Scalar, Scalar,
Array(i32, i32), Array(i32, i32),
Range(i32, i32), Range(i32, i32),
@@ -218,7 +218,7 @@ fn static_analysis_op_nodes(left: &Node, right: &Node) -> StaticResult {
// * Array(a, b) if we know it will be an a x b array. // * Array(a, b) if we know it will be an a x b array.
// * Range(a, b) if we know it will be a a x b range. // * Range(a, b) if we know it will be a a x b range.
// * Unknown if we cannot guaranty either // * Unknown if we cannot guaranty either
pub(crate) fn run_static_analysis_on_node(node: &Node) -> StaticResult { fn run_static_analysis_on_node(node: &Node) -> StaticResult {
match node { match node {
Node::BooleanKind(_) Node::BooleanKind(_)
| Node::NumberKind(_) | Node::NumberKind(_)

View File

@@ -96,7 +96,7 @@ impl Model {
match cell.get_formula() { match cell.get_formula() {
Some(f) => { Some(f) => {
let node = &self.parsed_formulas[sheet_index as usize][f as usize].0; let node = &self.parsed_formulas[sheet_index as usize][f as usize];
matches!( matches!(
node, node,
Node::FunctionKind { Node::FunctionKind {

View File

@@ -11,9 +11,8 @@ use crate::{
lexer::LexerMode, lexer::LexerMode,
parser::{ parser::{
move_formula::{move_formula, MoveContext}, move_formula::{move_formula, MoveContext},
static_analysis::{run_static_analysis_on_node, StaticResult},
stringify::{rename_defined_name_in_node, to_rc_format, to_string}, stringify::{rename_defined_name_in_node, to_rc_format, to_string},
ArrayNode, Node, Parser, Node, Parser,
}, },
token::{get_error_by_name, Error, OpCompare, OpProduct, OpSum, OpUnary}, token::{get_error_by_name, Error, OpCompare, OpProduct, OpSum, OpUnary},
types::*, types::*,
@@ -100,7 +99,7 @@ pub struct Model {
/// A Rust internal representation of an Excel workbook /// A Rust internal representation of an Excel workbook
pub workbook: Workbook, pub workbook: Workbook,
/// A list of parsed formulas /// A list of parsed formulas
pub parsed_formulas: Vec<Vec<(Node, StaticResult)>>, pub parsed_formulas: Vec<Vec<Node>>,
/// A list of parsed defined names /// A list of parsed defined names
pub(crate) parsed_defined_names: HashMap<(Option<u32>, String), ParsedDefinedName>, pub(crate) parsed_defined_names: HashMap<(Option<u32>, String), ParsedDefinedName>,
/// An optimization to lookup strings faster /// An optimization to lookup strings faster
@@ -523,195 +522,14 @@ impl Model {
} }
Ok(format!("{}!{}{}", sheet.name, column, cell_reference.row)) Ok(format!("{}!{}{}", sheet.name, column, cell_reference.row))
} }
/// Sets sheet, target_row, target_column, (width, height), &v
#[allow(clippy::too_many_arguments)]
fn set_spill_cell_with_formula_value(
&mut self,
sheet: u32,
row: i32,
column: i32,
r: (i32, i32),
v: &CalcResult,
s: i32,
f: i32,
) -> Result<(), String> {
let new_cell = match v {
CalcResult::EmptyCell => Cell::DynamicCellFormulaNumber {
f,
v: 0.0,
s,
r,
a: false,
},
CalcResult::String(v) => Cell::DynamicCellFormulaString {
f,
v: v.clone(),
s,
r,
a: false,
},
CalcResult::Number(v) => Cell::DynamicCellFormulaNumber {
v: *v,
s,
r,
f,
a: false,
},
CalcResult::Boolean(b) => Cell::DynamicCellFormulaBoolean {
v: *b,
s,
r,
f,
a: false,
},
CalcResult::Error { error, .. } => Cell::DynamicCellFormulaError {
ei: error.clone(),
s,
r,
f,
a: false,
o: "".to_string(),
m: "".to_string(),
},
// These cannot happen
// FIXME: Maybe the type of get_cell_value should be different
CalcResult::Range { .. } | CalcResult::EmptyArg | CalcResult::Array(_) => {
Cell::DynamicCellFormulaError {
ei: Error::ERROR,
s,
r,
f,
a: false,
o: "".to_string(),
m: "".to_string(),
}
}
};
let sheet_data = &mut self.workbook.worksheet_mut(sheet)?.sheet_data;
match sheet_data.get_mut(&row) {
Some(column_data) => match column_data.get(&column) {
Some(_cell) => {
column_data.insert(column, new_cell);
}
None => {
column_data.insert(column, new_cell);
}
},
None => {
let mut column_data = HashMap::new();
column_data.insert(column, new_cell);
sheet_data.insert(row, column_data);
}
}
Ok(())
}
/// Sets a cell with a "spill" value
fn set_spill_cell_with_value(
&mut self,
sheet: u32,
row: i32,
column: i32,
m: (i32, i32),
v: &CalcResult,
) -> Result<(), String> {
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
.styles
.get_style_without_quote_prefix(style_index)?
} else {
style_index
};
let new_cell = match v {
CalcResult::EmptyCell => Cell::SpillNumberCell {
v: 0.0,
s: style_index,
m,
},
CalcResult::String(s) => Cell::SpillStringCell {
v: s.clone(),
s: new_style_index,
m,
},
CalcResult::Number(f) => Cell::SpillNumberCell {
v: *f,
s: new_style_index,
m,
},
CalcResult::Boolean(b) => Cell::SpillBooleanCell {
v: *b,
s: new_style_index,
m,
},
CalcResult::Error { error, .. } => Cell::SpillErrorCell {
ei: error.clone(),
s: style_index,
m,
},
// These cannot happen
// FIXME: Maybe the type of get_cell_value should be different
CalcResult::Range { .. } | CalcResult::EmptyArg | CalcResult::Array(_) => {
Cell::SpillErrorCell {
ei: Error::ERROR,
s: style_index,
m,
}
}
};
let sheet_data = &mut self.workbook.worksheet_mut(sheet)?.sheet_data;
match sheet_data.get_mut(&row) {
Some(column_data) => match column_data.get(&column) {
Some(_cell) => {
column_data.insert(column, new_cell);
}
None => {
column_data.insert(column, new_cell);
}
},
None => {
let mut column_data = HashMap::new();
column_data.insert(column, new_cell);
sheet_data.insert(row, column_data);
}
}
Ok(())
}
/// Sets `result` in the cell given by `sheet` sheet index, row and column /// Sets `result` in the cell given by `sheet` sheet index, row and column
/// Note that will panic if the cell does not exist /// Note that will panic if the cell does not exist
/// It will do nothing if the cell does not have a formula /// It will do nothing if the cell does not have a formula
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
fn set_cell_value( fn set_cell_value(&mut self, cell_reference: CellReferenceIndex, result: &CalcResult) {
&mut self,
cell_reference: CellReferenceIndex,
result: &CalcResult,
) -> Result<(), String> {
let CellReferenceIndex { sheet, column, row } = cell_reference; let CellReferenceIndex { sheet, column, row } = cell_reference;
let cell = self let cell = &self.workbook.worksheets[sheet as usize].sheet_data[&row][&column];
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned()
.unwrap_or_default();
let s = cell.get_style(); let s = cell.get_style();
// If the cell is a dynamic cell we need to delete all the cells in the range
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
// skip the "mother" cell
if r == row && c == column {
continue;
}
self.cell_clear_contents(sheet, r, c)?;
}
}
}
if let Some(f) = cell.get_formula() { if let Some(f) = cell.get_formula() {
match result { match result {
CalcResult::Number(value) => { CalcResult::Number(value) => {
@@ -776,138 +594,19 @@ impl Model {
ei: error.clone(), ei: error.clone(),
}; };
} }
CalcResult::EmptyCell | CalcResult::EmptyArg => {
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::CellFormulaNumber { f, s, v: 0.0 };
}
CalcResult::Range { left, right } => { CalcResult::Range { left, right } => {
if left.sheet == right.sheet if left.sheet == right.sheet
&& left.row == right.row && left.row == right.row
&& left.column == right.column && left.column == right.column
{ {
// There is only one cell let intersection_cell = CellReferenceIndex {
let single_cell = CellReferenceIndex {
sheet: left.sheet, sheet: left.sheet,
column: left.column, column: left.column,
row: left.row, row: left.row,
}; };
let v = self.evaluate_cell(single_cell); let v = self.evaluate_cell(intersection_cell);
self.set_cell_value(cell_reference, &v)?; self.set_cell_value(cell_reference, &v);
} else { } else {
// We need to check if all the cells are empty, otherwise we mark the cell as #SPILL!
let mut all_empty = true;
for r in row..=row + right.row - left.row {
for c in column..=column + right.column - left.column {
if r == row && c == column {
continue;
}
if !self.is_empty_cell(sheet, r, c).unwrap_or(false) {
all_empty = false;
break;
}
}
}
if !all_empty {
let o = match self.cell_reference_to_string(&cell_reference) {
Ok(s) => s,
Err(_) => "".to_string(),
};
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::DynamicCellFormulaError {
f,
s,
o,
m: "Result would spill to non empty cells".to_string(),
ei: Error::SPILL,
r: (1, 1),
a: false,
};
return Ok(());
}
// evaluate all the cells in that range
for r in left.row..=right.row {
for c in left.column..=right.column {
let cell_reference = CellReferenceIndex {
sheet: left.sheet,
row: r,
column: c,
};
// FIXME: We ned to return an error
self.evaluate_cell(cell_reference);
}
}
// now write the result in the target
for r in left.row..=right.row {
let row_delta = r - left.row;
for c in left.column..=right.column {
let column_delta = c - left.column;
// We need to put whatever is in (left.sheet, r, c) in
// (sheet, row + row_delta, column + column_delta)
// But we need to preserve the style
let target_row = row + row_delta;
let target_column = column + column_delta;
let cell = self
.workbook
.worksheet(left.sheet)?
.cell(r, c)
.cloned()
.unwrap_or_default();
let cell_reference = CellReferenceIndex {
sheet: left.sheet,
row: r,
column: c,
};
let v = self.get_cell_value(&cell, cell_reference);
if row == target_row && column == target_column {
// let cell_reference = CellReferenceIndex { sheet, row, column };
// self.set_cell_value(cell_reference, &v);
self.set_spill_cell_with_formula_value(
sheet,
target_row,
target_column,
(right.column - left.column + 1, right.row - left.row + 1),
&v,
s,
f,
)?;
continue;
}
self.set_spill_cell_with_value(
sheet,
target_row,
target_column,
(row, column),
&v,
)?;
}
}
}
}
CalcResult::Array(array) => {
let width = array[0].len() as i32;
let height = array.len() as i32;
// First we check that we don't spill:
let mut all_empty = true;
for r in row..row + height {
for c in column..column + width {
if r == row && c == column {
continue;
}
if !self.is_empty_cell(sheet, r, c).unwrap_or(false) {
all_empty = false;
break;
}
}
}
if !all_empty {
let o = match self.cell_reference_to_string(&cell_reference) { let o = match self.cell_reference_to_string(&cell_reference) {
Ok(s) => s, Ok(s) => s,
Err(_) => "".to_string(), Err(_) => "".to_string(),
@@ -921,66 +620,58 @@ impl Model {
f, f,
s, s,
o, o,
m: "Result would spill to non empty cells".to_string(), m: "Implicit Intersection not implemented".to_string(),
ei: Error::SPILL, ei: Error::NIMPL,
}; };
return Ok(());
} }
let mut target_row = row; // if let Some(intersection_cell) = implicit_intersection(&cell_reference, &range)
for data_row in array { // {
let mut target_column = column; // let v = self.evaluate_cell(intersection_cell);
for value in data_row { // self.set_cell_value(cell_reference, &v);
if row == target_row && column == target_column { // } else {
// This is the root cell of the dynamic array // let o = match self.cell_reference_to_string(&cell_reference) {
let cell_reference = CellReferenceIndex { sheet, row, column }; // Ok(s) => s,
let v = match value { // Err(_) => "".to_string(),
ArrayNode::Boolean(b) => CalcResult::Boolean(*b), // };
ArrayNode::Number(f) => CalcResult::Number(*f), // *self.workbook.worksheets[sheet as usize]
ArrayNode::String(s) => CalcResult::String(s.clone()), // .sheet_data
ArrayNode::Error(error) => CalcResult::new_error( // .get_mut(&row)
error.clone(), // .expect("expected a row")
cell_reference, // .get_mut(&column)
error.to_localized_error_string(&self.language), // .expect("expected a column") = Cell::CellFormulaError {
), // f,
}; // s,
self.set_spill_cell_with_formula_value( // o,
sheet, // m: "Invalid reference".to_string(),
target_row, // ei: Error::VALUE,
target_column, // };
(width, height), // }
&v, }
s, CalcResult::EmptyCell | CalcResult::EmptyArg => {
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::CellFormulaNumber { f, s, v: 0.0 };
}
CalcResult::Array(_) => {
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::CellFormulaError {
f, f,
)?; s,
target_column += 1; o: "".to_string(),
continue; m: "Arrays not supported yet".to_string(),
} ei: Error::NIMPL,
let v = match value {
ArrayNode::Boolean(b) => CalcResult::Boolean(*b),
ArrayNode::Number(f) => CalcResult::Number(*f),
ArrayNode::String(s) => CalcResult::String(s.clone()),
ArrayNode::Error(error) => CalcResult::new_error(
error.clone(),
cell_reference,
error.to_localized_error_string(&self.language),
),
}; };
self.set_spill_cell_with_value(
sheet,
target_row,
target_column,
(row, column),
&v,
)?;
target_column += 1;
}
target_row += 1;
} }
} }
} }
} }
Ok(())
}
/// Sets the color of the sheet tab. /// Sets the color of the sheet tab.
/// ///
@@ -1023,18 +714,16 @@ impl Model {
Ok(()) Ok(())
} }
// EmptyCell, Boolean, Number, String, Error
fn get_cell_value(&self, cell: &Cell, cell_reference: CellReferenceIndex) -> CalcResult { fn get_cell_value(&self, cell: &Cell, cell_reference: CellReferenceIndex) -> CalcResult {
use Cell::*; use Cell::*;
match cell { match cell {
EmptyCell { .. } => CalcResult::EmptyCell, EmptyCell { .. } => CalcResult::EmptyCell,
BooleanCell { v, .. } | SpillBooleanCell { v, .. } => CalcResult::Boolean(*v), BooleanCell { v, .. } => CalcResult::Boolean(*v),
NumberCell { v, .. } | SpillNumberCell { v, .. } => CalcResult::Number(*v), NumberCell { v, .. } => CalcResult::Number(*v),
ErrorCell { ei, .. } | SpillErrorCell { ei, .. } => { ErrorCell { ei, .. } => {
let message = ei.to_localized_error_string(&self.language); let message = ei.to_localized_error_string(&self.language);
CalcResult::new_error(ei.clone(), cell_reference, message) CalcResult::new_error(ei.clone(), cell_reference, message)
} }
SpillStringCell { v, .. } => CalcResult::String(v.clone()),
SharedString { si, .. } => { SharedString { si, .. } => {
if let Some(s) = self.workbook.shared_strings.get(*si as usize) { if let Some(s) = self.workbook.shared_strings.get(*si as usize) {
CalcResult::String(s.clone()) CalcResult::String(s.clone())
@@ -1043,21 +732,15 @@ impl Model {
CalcResult::new_error(Error::ERROR, cell_reference, message) CalcResult::new_error(Error::ERROR, cell_reference, message)
} }
} }
DynamicCellFormula { .. } | CellFormula { .. } => CalcResult::Error { CellFormula { .. } => CalcResult::Error {
error: Error::ERROR, error: Error::ERROR,
origin: cell_reference, origin: cell_reference,
message: "Unevaluated formula".to_string(), message: "Unevaluated formula".to_string(),
}, },
DynamicCellFormulaBoolean { v, .. } | CellFormulaBoolean { v, .. } => { CellFormulaBoolean { v, .. } => CalcResult::Boolean(*v),
CalcResult::Boolean(*v) CellFormulaNumber { v, .. } => CalcResult::Number(*v),
} CellFormulaString { v, .. } => CalcResult::String(v.clone()),
DynamicCellFormulaNumber { v, .. } | CellFormulaNumber { v, .. } => { CellFormulaError { ei, o, m, .. } => {
CalcResult::Number(*v)
}
DynamicCellFormulaString { v, .. } | CellFormulaString { v, .. } => {
CalcResult::String(v.clone())
}
DynamicCellFormulaError { ei, o, m, .. } | CellFormulaError { ei, o, m, .. } => {
if let Some(cell_reference) = self.parse_reference(o) { if let Some(cell_reference) = self.parse_reference(o) {
CalcResult::new_error(ei.clone(), cell_reference, m.clone()) CalcResult::new_error(ei.clone(), cell_reference, m.clone())
} else { } else {
@@ -1127,10 +810,9 @@ impl Model {
self.cells.insert(key, CellState::Evaluating); self.cells.insert(key, CellState::Evaluating);
} }
} }
let (node, _static_result) = let node = &self.parsed_formulas[cell_reference.sheet as usize][f as usize].clone();
&self.parsed_formulas[cell_reference.sheet as usize][f as usize]; let result = self.evaluate_node_in_context(node, cell_reference);
let result = self.evaluate_node_in_context(&node.clone(), cell_reference); self.set_cell_value(cell_reference, &result);
let _ = self.set_cell_value(cell_reference, &result);
// mark cell as evaluated // mark cell as evaluated
self.cells.insert(key, CellState::Evaluated); self.cells.insert(key, CellState::Evaluated);
result result
@@ -1418,7 +1100,7 @@ impl Model {
Some(cell) => match cell.get_formula() { Some(cell) => match cell.get_formula() {
None => cell.get_text(&self.workbook.shared_strings, &self.language), None => cell.get_text(&self.workbook.shared_strings, &self.language),
Some(i) => { Some(i) => {
let formula = &self.parsed_formulas[sheet as usize][i as usize].0; let formula = &self.parsed_formulas[sheet as usize][i as usize];
let cell_ref = CellReferenceRC { let cell_ref = CellReferenceRC {
sheet: self.workbook.worksheets[sheet as usize].get_name(), sheet: self.workbook.worksheets[sheet as usize].get_name(),
row: target_row, row: target_row,
@@ -1521,8 +1203,7 @@ impl Model {
.get(sheet as usize) .get(sheet as usize)
.ok_or("missing sheet")? .ok_or("missing sheet")?
.get(formula_index as usize) .get(formula_index as usize)
.ok_or("missing formula")? .ok_or("missing formula")?;
.0;
let cell_ref = CellReferenceRC { let cell_ref = CellReferenceRC {
sheet: worksheet.get_name(), sheet: worksheet.get_name(),
row, row,
@@ -1756,25 +1437,6 @@ impl Model {
column: i32, column: i32,
value: String, value: String,
) -> Result<(), String> { ) -> Result<(), String> {
// We need to check if the cell is part of a dynamic array
let cell = self
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned()
.unwrap_or_default();
// If the cell is a dynamic cell we need to delete all the cells in the range
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
// skip the "mother" cell
if r == row && c == column {
continue;
}
self.cell_clear_contents(sheet, r, c)?;
}
}
}
// If value starts with "'" then we force the style to be quote_prefix // If value starts with "'" then we force the style to be quote_prefix
let style_index = self.get_cell_style_index(sheet, row, column)?; let style_index = self.get_cell_style_index(sheet, row, column)?;
if let Some(new_value) = value.strip_prefix('\'') { if let Some(new_value) = value.strip_prefix('\'') {
@@ -1800,8 +1462,7 @@ impl Model {
self.set_cell_with_formula(sheet, row, column, formula, new_style_index)?; self.set_cell_with_formula(sheet, row, column, formula, new_style_index)?;
// Update the style if needed // Update the style if needed
let cell = CellReferenceIndex { sheet, row, column }; let cell = CellReferenceIndex { sheet, row, column };
let parsed_formula = let parsed_formula = &self.parsed_formulas[sheet as usize][formula_index as usize];
&self.parsed_formulas[sheet as usize][formula_index as usize].0;
if let Some(units) = self.compute_node_units(parsed_formula, &cell) { if let Some(units) = self.compute_node_units(parsed_formula, &cell) {
let new_style_index = self let new_style_index = self
.workbook .workbook
@@ -1883,7 +1544,6 @@ impl Model {
_ => parsed_formula = new_parsed_formula, _ => parsed_formula = new_parsed_formula,
} }
} }
let static_result = run_static_analysis_on_node(&parsed_formula);
let s = to_rc_format(&parsed_formula); let s = to_rc_format(&parsed_formula);
let mut formula_index: i32 = -1; let mut formula_index: i32 = -1;
@@ -1892,7 +1552,7 @@ impl Model {
} }
if formula_index == -1 { if formula_index == -1 {
shared_formulas.push(s); shared_formulas.push(s);
self.parsed_formulas[sheet as usize].push((parsed_formula, static_result)); self.parsed_formulas[sheet as usize].push(parsed_formula);
formula_index = (shared_formulas.len() as i32) - 1; formula_index = (shared_formulas.len() as i32) - 1;
} }
worksheet.set_cell_with_formula(row, column, formula_index, style)?; worksheet.set_cell_with_formula(row, column, formula_index, style)?;
@@ -2087,7 +1747,7 @@ impl Model {
}; };
match cell.get_formula() { match cell.get_formula() {
Some(formula_index) => { Some(formula_index) => {
let formula = &self.parsed_formulas[sheet as usize][formula_index as usize].0; let formula = &self.parsed_formulas[sheet as usize][formula_index as usize];
let cell_ref = CellReferenceRC { let cell_ref = CellReferenceRC {
sheet: worksheet.get_name(), sheet: worksheet.get_name(),
row, row,
@@ -2102,14 +1762,6 @@ impl Model {
/// Returns a list of all cells /// Returns a list of all cells
pub fn get_all_cells(&self) -> Vec<CellIndex> { pub fn get_all_cells(&self) -> Vec<CellIndex> {
let mut cells = Vec::new(); let mut cells = Vec::new();
for (sheet, row, column) in &self.workbook.calc_chain {
let cell = CellIndex {
row: *row,
column: *column,
index: *sheet,
};
cells.push(cell);
}
for (index, sheet) in self.workbook.worksheets.iter().enumerate() { for (index, sheet) in self.workbook.worksheets.iter().enumerate() {
let mut sorted_rows: Vec<_> = sheet.sheet_data.keys().collect(); let mut sorted_rows: Vec<_> = sheet.sheet_data.keys().collect();
sorted_rows.sort_unstable(); sorted_rows.sort_unstable();
@@ -2136,8 +1788,6 @@ impl Model {
let cells = self.get_all_cells(); let cells = self.get_all_cells();
// First evaluate all dynamic arrays
for cell in cells { for cell in cells {
self.evaluate_cell(CellReferenceIndex { self.evaluate_cell(CellReferenceIndex {
sheet: cell.index, sheet: cell.index,
@@ -2168,22 +1818,9 @@ impl Model {
/// # } /// # }
/// ``` /// ```
pub fn cell_clear_contents(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> { pub fn cell_clear_contents(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> {
// If it has a spill formula we need to delete the contents of all the spilled cells self.workbook
let worksheet = self.workbook.worksheet_mut(sheet)?; .worksheet_mut(sheet)?
if let Some(cell) = worksheet.cell(row, column) { .cell_clear_contents(row, column)?;
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
if row == r && column == c {
// we skip the root cell
continue;
}
worksheet.cell_clear_contents(r, c)?;
}
}
}
}
worksheet.cell_clear_contents(row, column)?;
Ok(()) Ok(())
} }
@@ -2208,18 +1845,6 @@ impl Model {
/// # } /// # }
pub fn cell_clear_all(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> { pub fn cell_clear_all(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> {
let worksheet = self.workbook.worksheet_mut(sheet)?; let worksheet = self.workbook.worksheet_mut(sheet)?;
// Delete the contents of spilled cells if any
if let Some(cell) = worksheet.cell(row, column) {
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
if row == r && c == column {
worksheet.cell_clear_contents(r, c)?;
}
}
}
}
}
let sheet_data = &mut worksheet.sheet_data; let sheet_data = &mut worksheet.sheet_data;
if let Some(row_data) = sheet_data.get_mut(&row) { if let Some(row_data) = sheet_data.get_mut(&row) {
@@ -2306,16 +1931,32 @@ impl Model {
} }
/// Returns markup representation of the given `sheet`. /// Returns markup representation of the given `sheet`.
pub fn get_sheet_markup(&self, sheet: u32) -> Result<String, String> { pub fn get_sheet_markup(
let worksheet = self.workbook.worksheet(sheet)?; &self,
let dimension = worksheet.dimension(); sheet: u32,
start_row: i32,
start_column: i32,
width: i32,
height: i32,
) -> Result<String, String> {
let mut table: Vec<Vec<String>> = Vec::new();
if start_row < 1 || start_column < 1 {
return Err("Start row and column must be positive".to_string());
}
if start_row + height >= LAST_ROW || start_column + width >= LAST_COLUMN {
return Err("Start row and column exceed the maximum allowed".to_string());
}
if height <= 0 || width <= 0 {
return Err("Height must be positive and width must be positive".to_string());
}
let mut rows = Vec::new(); // a mutable vector to store the column widths of length `width + 1`
let mut column_widths: Vec<f64> = vec![0.0; (width + 1) as usize];
for row in 1..(dimension.max_row + 1) { for row in start_row..(start_row + height + 1) {
let mut row_markup: Vec<String> = Vec::new(); let mut row_markup: Vec<String> = Vec::new();
for column in 1..(dimension.max_column + 1) { for column in start_column..(start_column + width + 1) {
let mut cell_markup = match self.get_cell_formula(sheet, row, column)? { let mut cell_markup = match self.get_cell_formula(sheet, row, column)? {
Some(formula) => formula, Some(formula) => formula,
None => self.get_formatted_cell_value(sheet, row, column)?, None => self.get_formatted_cell_value(sheet, row, column)?,
@@ -2324,12 +1965,34 @@ impl Model {
if style.font.b { if style.font.b {
cell_markup = format!("**{cell_markup}**") cell_markup = format!("**{cell_markup}**")
} }
column_widths[(column - start_column) as usize] =
column_widths[(column - start_column) as usize].max(cell_markup.len() as f64);
row_markup.push(cell_markup); row_markup.push(cell_markup);
} }
rows.push(row_markup.join("|")); table.push(row_markup);
} }
let mut rows = Vec::new();
for (j, row) in table.iter().enumerate() {
if j == 1 {
let mut row_markup = String::new();
for i in 0..(width + 1) {
row_markup.push('|');
let wide = column_widths[i as usize] as usize;
row_markup.push_str(&"-".repeat(wide));
}
rows.push(row_markup);
}
let mut row_markup = String::new();
for (i, cell) in row.iter().enumerate() {
row_markup.push('|');
let wide = column_widths[i] as usize;
// Add padding to the cell content
row_markup.push_str(&format!("{:<wide$}", cell, wide = wide));
}
rows.push(row_markup);
}
Ok(rows.join("\n")) Ok(rows.join("\n"))
} }

View File

@@ -8,7 +8,6 @@ use crate::{
expressions::{ expressions::{
lexer::LexerMode, lexer::LexerMode,
parser::{ parser::{
static_analysis::run_static_analysis_on_node,
stringify::{rename_sheet_in_node, to_rc_format, to_string}, stringify::{rename_sheet_in_node, to_rc_format, to_string},
Parser, Parser,
}, },
@@ -95,8 +94,7 @@ impl Model {
let mut parse_formula = Vec::new(); let mut parse_formula = Vec::new();
for formula in shared_formulas { for formula in shared_formulas {
let t = self.parser.parse(formula, &cell_reference); let t = self.parser.parse(formula, &cell_reference);
let static_result = run_static_analysis_on_node(&t); parse_formula.push(t);
parse_formula.push((t, static_result));
} }
self.parsed_formulas.push(parse_formula); self.parsed_formulas.push(parse_formula);
} }

View File

@@ -52,7 +52,6 @@ mod test_fn_offset;
mod test_number_format; mod test_number_format;
mod test_arrays; mod test_arrays;
mod test_dynamic_arrays;
mod test_escape_quotes; mod test_escape_quotes;
mod test_extend; mod test_extend;
mod test_fn_fv; mod test_fn_fv;

View File

@@ -1,50 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn they_spill() {
let mut model = new_empty_model();
model._set("A1", "42");
model._set("A2", "5");
model._set("A3", "7");
model._set("B1", "=A1:A3");
model.evaluate();
assert_eq!(model._get_text("B1"), *"42");
assert_eq!(model._get_text("B2"), *"5");
assert_eq!(model._get_text("B3"), *"7");
}
#[test]
fn spill_error() {
let mut model = new_empty_model();
model._set("A1", "42");
model._set("A2", "5");
model._set("A3", "7");
model._set("B1", "=A1:A3");
model._set("B2", "4");
model.evaluate();
assert_eq!(model._get_text("B1"), *"#SPILL!");
assert_eq!(model._get_text("B2"), *"4");
assert_eq!(model._get_text("B3"), *"");
}
#[test]
fn second_evaluation() {
let mut model = new_empty_model();
model._set("C3", "={1,2,3}");
model.evaluate();
assert_eq!(model._get_text("D3"), "2");
model._set("D8", "23");
model.evaluate();
assert_eq!(model._get_text("D3"), "2");
}

View File

@@ -3,7 +3,7 @@
use crate::test::util::new_empty_model; use crate::test::util::new_empty_model;
#[test] #[test]
fn simple_column() { fn simple_colum() {
let mut model = new_empty_model(); let mut model = new_empty_model();
// We populate cells A1 to A3 // We populate cells A1 to A3
model._set("A1", "1"); model._set("A1", "1");
@@ -30,7 +30,7 @@ fn return_of_array_is_n_impl() {
model.evaluate(); model.evaluate();
assert_eq!(model._get_text("C2"), "1".to_string()); assert_eq!(model._get_text("C2"), "#N/IMPL!".to_string());
assert_eq!(model._get_text("D2"), "1.89188842".to_string()); assert_eq!(model._get_text("D2"), "1.89188842".to_string());
} }

View File

@@ -21,7 +21,7 @@ fn test_sheet_markup() {
model.set_cell_style(0, 4, 1, &style).unwrap(); model.set_cell_style(0, 4, 1, &style).unwrap();
assert_eq!( assert_eq!(
model.get_sheet_markup(0), model.get_sheet_markup(0, 1, 1, 4, 2),
Ok("**Item**|**Cost**\nRent|$600\nElectricity|$200\n**Total**|=SUM(B2:B3)".to_string()), Ok("**Item**|**Cost**\nRent|$600\nElectricity|$200\n**Total**|=SUM(B2:B3)".to_string()),
) )
} }

View File

@@ -62,3 +62,17 @@ fn test_create_named_style() {
let style = model.get_style_for_cell(0, 1, 1).unwrap(); let style = model.get_style_for_cell(0, 1, 1).unwrap();
assert!(style.font.b); assert!(style.font.b);
} }
#[test]
fn empty_models_have_two_fills() {
let model = new_empty_model();
assert_eq!(model.workbook.styles.fills.len(), 2);
assert_eq!(
model.workbook.styles.fills[0].pattern_type,
"none".to_string()
);
assert_eq!(
model.workbook.styles.fills[1].pattern_type,
"gray125".to_string()
);
}

View File

@@ -5,10 +5,8 @@ mod test_border;
mod test_clear_cells; mod test_clear_cells;
mod test_column_style; mod test_column_style;
mod test_defined_names; mod test_defined_names;
mod test_delete_evaluates;
mod test_delete_row_column_formatting; mod test_delete_row_column_formatting;
mod test_diff_queue; mod test_diff_queue;
mod test_dynamic_array;
mod test_evaluation; mod test_evaluation;
mod test_general; mod test_general;
mod test_grid_lines; mod test_grid_lines;

View File

@@ -1,47 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::{expressions::types::Area, UserModel};
#[test]
fn clear_cell_contents_evaluates() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 1, 2, "=A1").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("42".to_string())
);
model
.range_clear_contents(&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
})
.unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("0".to_string()));
}
#[test]
fn clear_cell_all_evaluates() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 1, 2, "=A1").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("42".to_string())
);
model
.range_clear_all(&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
})
.unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("0".to_string()));
}

View File

@@ -1,130 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::{expressions::types::Area, UserModel};
// Tests basic behavour.
#[test]
fn basic() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// We put a value by the dynamic array to check the border conditions
model.set_user_input(0, 2, 1, "22").unwrap();
model.set_user_input(0, 1, 1, "={34,35,3}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 1),
Ok("34".to_string())
);
}
// Test that overwriting a dynamic array with a single value dissolves the array
#[test]
fn sett_user_input_mother() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "={34,35,3}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("35".to_string())
);
model.set_user_input(0, 1, 1, "123").unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("".to_string()));
}
#[test]
fn set_user_input_sibling() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "={43,55,34}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("55".to_string())
);
// This does nothing
model.set_user_input(0, 1, 2, "123").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("55".to_string())
);
}
#[test]
fn basic_undo_redo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "={34,35,3}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("35".to_string())
);
model.undo().unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("".to_string()));
model.redo().unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("35".to_string())
);
}
#[test]
fn mixed_spills() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// D9 => ={34,35,3}
model.set_user_input(0, 9, 4, "={34,35,3}").unwrap();
// F6 => ={1;2;3;4}
model.set_user_input(0, 6, 6, "={1;2;3;4}").unwrap();
// F6 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 6, 6),
Ok("#SPILL!".to_string())
);
// We delete D9
model
.range_clear_contents(&Area {
sheet: 0,
row: 9,
column: 4,
width: 1,
height: 1,
})
.unwrap();
// F6 should be 1
assert_eq!(model.get_formatted_cell_value(0, 6, 6), Ok("1".to_string()));
// Now we undo that
model.undo().unwrap();
// F6 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 6, 6),
Ok("#SPILL!".to_string())
);
}
#[test]
fn spill_order_d9_f6() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// D9 => ={1,2,3}
model.set_user_input(0, 9, 4, "={34,35,3}").unwrap();
// F6 => ={1;2;3;4}
model.set_user_input(0, 6, 6, "={1;2;3;4}").unwrap();
// F6 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 6, 6),
Ok("#SPILL!".to_string())
);
}
#[test]
fn spill_order_f6_d9() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// F6 => ={1;2;3;4}
model.set_user_input(0, 6, 6, "={1;2;3;4}").unwrap();
// D9 => ={1,2,3}
model.set_user_input(0, 9, 4, "={34,35,3}").unwrap();
// D9 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 9, 4),
Ok("#SPILL!".to_string())
);
}

View File

@@ -51,9 +51,6 @@ pub struct Workbook {
pub metadata: Metadata, pub metadata: Metadata,
pub tables: HashMap<String, Table>, pub tables: HashMap<String, Table>,
pub views: HashMap<u32, WorkbookView>, pub views: HashMap<u32, WorkbookView>,
/// Calculation chain of the dynamic arrays.
/// List of tuples (sheet_id, row, column)
pub calc_chain: Vec<(u32, i32, i32)>,
} }
/// A defined name. The `sheet_id` is the sheet index in case the name is local /// A defined name. The `sheet_id` is the sheet index in case the name is local
@@ -162,17 +159,17 @@ pub enum CellType {
CompoundData = 128, CompoundData = 128,
} }
/// Cell types
/// s is always the style index of the cell
#[derive(Encode, Decode, Debug, Clone, PartialEq)] #[derive(Encode, Decode, Debug, Clone, PartialEq)]
pub enum Cell { pub enum Cell {
EmptyCell { EmptyCell {
s: i32, s: i32,
}, },
BooleanCell { BooleanCell {
v: bool, v: bool,
s: i32, s: i32,
}, },
NumberCell { NumberCell {
v: f64, v: f64,
s: i32, s: i32,
@@ -184,7 +181,6 @@ pub enum Cell {
}, },
// Always a shared string // Always a shared string
SharedString { SharedString {
// string index
si: i32, si: i32,
s: i32, s: i32,
}, },
@@ -193,11 +189,13 @@ pub enum Cell {
f: i32, f: i32,
s: i32, s: i32,
}, },
CellFormulaBoolean { CellFormulaBoolean {
f: i32, f: i32,
v: bool, v: bool,
s: i32, s: i32,
}, },
CellFormulaNumber { CellFormulaNumber {
f: i32, f: i32,
v: f64, v: f64,
@@ -209,9 +207,9 @@ pub enum Cell {
v: String, v: String,
s: i32, s: i32,
}, },
CellFormulaError { CellFormulaError {
f: i32, f: i32,
// error index
ei: Error, ei: Error,
s: i32, s: i32,
// Origin: Sheet3!C4 // Origin: Sheet3!C4
@@ -219,81 +217,7 @@ pub enum Cell {
// Error Message: "Not implemented function" // Error Message: "Not implemented function"
m: String, m: String,
}, },
// All Spill/dynamic cells have a boolean, a for array, if true it is an array formula // TODO: Array formulas
// Spill cells point to a mother cell (row, column)
SpillNumberCell {
v: f64,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
SpillBooleanCell {
v: bool,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
SpillErrorCell {
ei: Error,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
SpillStringCell {
v: String,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
// Dynamic cell formulas have a range (width, height)
DynamicCellFormula {
f: i32,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaBoolean {
f: i32,
v: bool,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaNumber {
f: i32,
v: f64,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaString {
f: i32,
v: String,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaError {
f: i32,
ei: Error,
s: i32,
// Cell origin of the error
o: String,
// Error message in text
m: String,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
} }
impl Default for Cell { impl Default for Cell {
@@ -379,7 +303,14 @@ impl Default for Styles {
Styles { Styles {
num_fmts: vec![], num_fmts: vec![],
fonts: vec![Default::default()], fonts: vec![Default::default()],
fills: vec![Default::default()], fills: vec![
Default::default(),
Fill {
pattern_type: "gray125".to_string(),
fg_color: None,
bg_color: None,
},
],
borders: vec![Default::default()], borders: vec![Default::default()],
cell_style_xfs: vec![Default::default()], cell_style_xfs: vec![Default::default()],
cell_xfs: vec![Default::default()], cell_xfs: vec![Default::default()],

View File

@@ -13,8 +13,8 @@ use crate::{
}, },
model::Model, model::Model,
types::{ types::{
Alignment, BorderItem, Cell, CellType, Col, HorizontalAlignment, SheetProperties, Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState,
SheetState, Style, VerticalAlignment, Style, VerticalAlignment,
}, },
utils::is_valid_hex_color, utils::is_valid_hex_color,
}; };
@@ -24,18 +24,6 @@ use crate::user_model::history::{
}; };
use super::border_utils::is_max_border; use super::border_utils::is_max_border;
#[derive(Serialize, Deserialize)]
pub enum CellArrayStructure {
// It s just a single cell
SingleCell,
// It is part o a dynamic array
// (mother_row, mother_column, width, height)
DynamicChild(i32, i32, i32, i32),
// Mother of a dynamic array (width, height)
DynamicMother(i32, i32),
}
/// Data for the clipboard /// Data for the clipboard
pub type ClipboardData = HashMap<i32, HashMap<i32, ClipboardCell>>; pub type ClipboardData = HashMap<i32, HashMap<i32, ClipboardCell>>;
@@ -305,6 +293,19 @@ impl UserModel {
self.model.workbook.name = name.to_string(); self.model.workbook.name = name.to_string();
} }
/// Get area markdown
pub fn get_sheet_markup(
&self,
sheet: u32,
row_start: i32,
column_start: i32,
row_end: i32,
column_end: i32,
) -> Result<String, String> {
self.model
.get_sheet_markup(sheet, row_start, column_start, row_end, column_end)
}
/// Undoes last change if any, places the change in the redo list and evaluates the model if needed /// Undoes last change if any, places the change in the redo list and evaluates the model if needed
/// ///
/// See also: /// See also:
@@ -639,7 +640,6 @@ impl UserModel {
} }
} }
self.push_diff_list(diff_list); self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(()) Ok(())
} }
@@ -669,7 +669,6 @@ impl UserModel {
} }
} }
self.push_diff_list(diff_list); self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(()) Ok(())
} }
@@ -1609,65 +1608,6 @@ impl UserModel {
Ok(self.model.workbook.worksheet(sheet)?.show_grid_lines) Ok(self.model.workbook.worksheet(sheet)?.show_grid_lines)
} }
/// Returns the geometric structure of a cell
pub fn get_cell_array_structure(
&self,
sheet: u32,
row: i32,
column: i32,
) -> Result<CellArrayStructure, String> {
let cell = self
.model
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned()
.unwrap_or_default();
match cell {
Cell::EmptyCell { .. }
| Cell::BooleanCell { .. }
| Cell::NumberCell { .. }
| Cell::ErrorCell { .. }
| Cell::SharedString { .. }
| Cell::CellFormula { .. }
| Cell::CellFormulaBoolean { .. }
| Cell::CellFormulaNumber { .. }
| Cell::CellFormulaString { .. }
| Cell::CellFormulaError { .. } => Ok(CellArrayStructure::SingleCell),
Cell::SpillNumberCell { m, .. }
| Cell::SpillBooleanCell { m, .. }
| Cell::SpillErrorCell { m, .. }
| Cell::SpillStringCell { m, .. } => {
let (m_row, m_column) = m;
let m_cell = self
.model
.workbook
.worksheet(sheet)?
.cell(m_row, m_column)
.cloned()
.unwrap_or_default();
let (width, height) = match m_cell {
Cell::DynamicCellFormula { r, .. }
| Cell::DynamicCellFormulaBoolean { r, .. }
| Cell::DynamicCellFormulaNumber { r, .. }
| Cell::DynamicCellFormulaString { r, .. }
| Cell::DynamicCellFormulaError { r, .. } => (r.0, r.1),
_ => return Err("Invalid structure".to_string()),
};
Ok(CellArrayStructure::DynamicChild(
m_row, m_column, width, height,
))
}
Cell::DynamicCellFormula { r, .. }
| Cell::DynamicCellFormulaBoolean { r, .. }
| Cell::DynamicCellFormulaNumber { r, .. }
| Cell::DynamicCellFormulaString { r, .. }
| Cell::DynamicCellFormulaError { r, .. } => {
Ok(CellArrayStructure::DynamicMother(r.0, r.1))
}
}
}
/// Returns a copy of the selected area /// Returns a copy of the selected area
pub fn copy_to_clipboard(&self) -> Result<Clipboard, String> { pub fn copy_to_clipboard(&self) -> Result<Clipboard, String> {
let selected_area = self.get_selected_view(); let selected_area = self.get_selected_view();
@@ -1970,24 +1910,6 @@ impl UserModel {
old_value, old_value,
} => { } => {
needs_evaluation = true; needs_evaluation = true;
let cell = self
.model
.workbook
.worksheet(*sheet)?
.cell(*row, *column)
.cloned()
.unwrap_or_default();
if let Some((width, height)) = cell.get_dynamic_range() {
for r in *row..*row + height {
for c in *column..*column + width {
// skip the "mother" cell
if r == *row && c == *column {
continue;
}
self.model.cell_clear_contents(*sheet, r, c)?;
}
}
}
match *old_value.clone() { match *old_value.clone() {
Some(value) => { Some(value) => {
self.model self.model

View File

@@ -5,21 +5,18 @@ use bitcode::{Decode, Encode};
use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet}; use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet};
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]
#[cfg_attr(debug_assertions, derive(Debug))]
pub(crate) struct RowData { pub(crate) struct RowData {
pub(crate) row: Option<Row>, pub(crate) row: Option<Row>,
pub(crate) data: HashMap<i32, Cell>, pub(crate) data: HashMap<i32, Cell>,
} }
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]
#[cfg_attr(debug_assertions, derive(Debug))]
pub(crate) struct ColumnData { pub(crate) struct ColumnData {
pub(crate) column: Option<Col>, pub(crate) column: Option<Col>,
pub(crate) data: HashMap<i32, Cell>, pub(crate) data: HashMap<i32, Cell>,
} }
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]
#[cfg_attr(debug_assertions, derive(Debug))]
pub(crate) enum Diff { pub(crate) enum Diff {
// Cell diffs // Cell diffs
SetCellValue { SetCellValue {

View File

@@ -201,18 +201,6 @@ defined_name_list_types = r"""
getDefinedNameList(): DefinedName[]; getDefinedNameList(): DefinedName[];
""" """
cell_structure = r"""
* @returns {any}
*/
getCellArrayStructure(sheet: number, row: number, column: number): any;
"""
cell_structure_types = r"""
* @returns {CellArrayStructure}
*/
getCellArrayStructure(sheet: number, row: number, column: number): CellArrayStructure;
"""
def fix_types(text): def fix_types(text):
text = text.replace(get_tokens_str, get_tokens_str_types) text = text.replace(get_tokens_str, get_tokens_str_types)
text = text.replace(update_style_str, update_style_str_types) text = text.replace(update_style_str, update_style_str_types)
@@ -227,7 +215,6 @@ def fix_types(text):
text = text.replace(clipboard, clipboard_types) text = text.replace(clipboard, clipboard_types)
text = text.replace(paste_from_clipboard, paste_from_clipboard_types) text = text.replace(paste_from_clipboard, paste_from_clipboard_types)
text = text.replace(defined_name_list, defined_name_list_types) text = text.replace(defined_name_list, defined_name_list_types)
text = text.replace(cell_structure, cell_structure_types)
with open("types.ts") as f: with open("types.ts") as f:
types_str = f.read() types_str = f.read()
header_types = "{}\n\n{}".format(header, types_str) header_types = "{}\n\n{}".format(header, types_str)

View File

@@ -673,17 +673,17 @@ impl Model {
.map_err(|e| to_js_error(e.to_string())) .map_err(|e| to_js_error(e.to_string()))
} }
#[wasm_bindgen(js_name = "getCellArrayStructure")] #[wasm_bindgen(js_name = "getSheetMarkup")]
pub fn get_cell_array_structure( pub fn get_sheet_markup(
&self, &self,
sheet: u32, sheet: u32,
row: i32, start_row: i32,
column: i32, start_column: i32,
) -> Result<JsValue, JsError> { end_row: i32,
let cell_structure = self end_column: i32,
.model ) -> Result<String, JsError> {
.get_cell_array_structure(sheet, row, column) self.model
.map_err(|e| to_js_error(e.to_string()))?; .get_sheet_markup(sheet, start_row, start_column, end_row, end_column)
serde_wasm_bindgen::to_value(&cell_structure).map_err(JsError::from) .map_err(to_js_error)
} }
} }

View File

@@ -109,11 +109,6 @@ export interface MarkedToken {
end: number; end: number;
} }
export type CellArrayStructure =
| "SingleCell"
| { DynamicChild: [number, number, number, number] }
| { DynamicMother: [number, number] };
export interface WorksheetProperties { export interface WorksheetProperties {
name: string; name: string;
color: string; color: string;
@@ -221,7 +216,7 @@ export interface SelectedView {
// }; // };
// type ClipboardData = Record<string, Record <string, ClipboardCell>>; // type ClipboardData = Record<string, Record <string, ClipboardCell>>;
type ClipboardData = Map<number, Map<number, ClipboardCell>>; type ClipboardData = Map<number, Map <number, ClipboardCell>>;
export interface ClipboardCell { export interface ClipboardCell {
text: string; text: string;

View File

@@ -2,11 +2,7 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [ addons: [],
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
framework: { framework: {
name: "@storybook/react-vite", name: "@storybook/react-vite",
options: {}, options: {},

File diff suppressed because it is too large Load Diff

View File

@@ -18,31 +18,26 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@ironcalc/wasm": "file:../../bindings/wasm/pkg", "@ironcalc/wasm": "file:../../bindings/wasm/pkg",
"@mui/material": "^6.4", "@mui/material": "^7.1.1",
"@mui/system": "^6.4", "@mui/system": "^7.1.1",
"i18next": "^23.11.1", "i18next": "^25.2.1",
"lucide-react": "^0.473.0", "lucide-react": "^0.513.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-i18next": "^15.4.0" "react-i18next": "^15.5.2"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "^3.2.4", "@storybook/react": "^9.0.5",
"@storybook/addon-essentials": "^8.6.0", "@storybook/react-vite": "^9.0.5",
"@storybook/addon-interactions": "^8.6.0",
"@storybook/blocks": "^8.6.0",
"@storybook/react": "^8.6.0",
"@storybook/react-vite": "^8.6.0",
"@storybook/test": "^8.6.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"react": "^19.0.0", "react": "^19.1.0",
"react-dom": "^19.0.0", "react-dom": "^19.1.0",
"storybook": "^8.6.0", "storybook": "^9.0.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.6.2", "typescript": "~5.8.3",
"vite": "^6.2.0", "vite": "^6.3.5",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vitest": "^3.0.7" "vitest": "^3.2.2"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0", "@types/react": "^18.0.0 || ^19.0.0",

View File

@@ -1,6 +1,6 @@
import "./index.css"; import "./index.css";
import type { Model } from "@ironcalc/wasm"; import type { Model } from "@ironcalc/wasm";
import ThemeProvider from "@mui/material/styles/ThemeProvider"; import { ThemeProvider } from "@mui/material";
import Workbook from "./components/Workbook/Workbook.tsx"; import Workbook from "./components/Workbook/Workbook.tsx";
import { WorkbookState } from "./components/workbookState.ts"; import { WorkbookState } from "./components/workbookState.ts";
import { theme } from "./theme.ts"; import { theme } from "./theme.ts";

View File

@@ -13,7 +13,6 @@ import type { WorkbookState } from "../workbookState";
type FormulaBarProps = { type FormulaBarProps = {
cellAddress: string; cellAddress: string;
formulaValue: string; formulaValue: string;
isPartOfArray: boolean;
model: Model; model: Model;
workbookState: WorkbookState; workbookState: WorkbookState;
onChange: () => void; onChange: () => void;
@@ -24,7 +23,6 @@ function FormulaBar(properties: FormulaBarProps) {
const { const {
cellAddress, cellAddress,
formulaValue, formulaValue,
isPartOfArray,
model, model,
onChange, onChange,
onTextUpdated, onTextUpdated,
@@ -64,9 +62,6 @@ function FormulaBar(properties: FormulaBarProps) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
}} }}
sx={{
color: isPartOfArray ? "grey" : "black",
}}
> >
<Editor <Editor
originalText={formulaValue} originalText={formulaValue}

View File

@@ -40,6 +40,7 @@ import {
ArrowMiddleFromLine, ArrowMiddleFromLine,
DecimalPlacesDecreaseIcon, DecimalPlacesDecreaseIcon,
DecimalPlacesIncreaseIcon, DecimalPlacesIncreaseIcon,
Markdown,
} from "../../icons"; } from "../../icons";
import { theme } from "../../theme"; import { theme } from "../../theme";
import BorderPicker from "../BorderPicker/BorderPicker"; import BorderPicker from "../BorderPicker/BorderPicker";
@@ -74,6 +75,7 @@ type ToolbarProperties = {
onClearFormatting: () => void; onClearFormatting: () => void;
onIncreaseFontSize: (delta: number) => void; onIncreaseFontSize: (delta: number) => void;
onDownloadPNG: () => void; onDownloadPNG: () => void;
onCopyMarkdown: () => void;
fillColor: string; fillColor: string;
fontColor: string; fontColor: string;
fontSize: number; fontSize: number;
@@ -429,6 +431,17 @@ function Toolbar(properties: ToolbarProperties) {
> >
<ImageDown /> <ImageDown />
</StyledButton> </StyledButton>
<StyledButton
type="button"
$pressed={false}
onClick={() => {
properties.onCopyMarkdown();
}}
disabled={!canEdit}
title={t("toolbar.selected_markdown")}
>
<Markdown />
</StyledButton>
<ColorPicker <ColorPicker
color={properties.fontColor} color={properties.fontColor}

View File

@@ -348,28 +348,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
return workbookState.getEditingText(); return workbookState.getEditingText();
} }
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
const r = model.getCellArrayStructure(sheet, row, column);
if (r === "SingleCell") {
return model.getCellContent(sheet, row, column); return model.getCellContent(sheet, row, column);
}
if ("DynamicMother" in r) {
return model.getCellContent(sheet, row, column);
}
const [mother_row, mother_column, _] = r.DynamicChild;
return model.getCellContent(sheet, mother_row, mother_column);
};
// returns true if it is either single cell or the root cell of an array
const isRootCellOfArray = () => {
const { sheet, row, column } = model.getSelectedView();
const r = model.getCellArrayStructure(sheet, row, column);
if (r === "SingleCell") {
return false;
}
if ("DynamicMother" in r) {
return false;
}
return true;
}; };
const getCellStyle = useCallback(() => { const getCellStyle = useCallback(() => {
@@ -579,6 +558,26 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
onIncreaseFontSize={(delta: number) => { onIncreaseFontSize={(delta: number) => {
onIncreaseFontSize(delta); onIncreaseFontSize(delta);
}} }}
onCopyMarkdown={async () => {
const {
sheet,
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
const row = Math.min(rowStart, rowEnd);
const column = Math.min(columnStart, columnEnd);
const width = Math.abs(columnEnd - columnStart) + 1;
const height = Math.abs(rowEnd - rowStart) + 1;
const markdown = model.getSheetMarkup(
sheet,
row,
column,
width,
height,
);
// Copy to clipboard
// NB: This will not work in non secure contexts or in iframes (i.e storybook)
await navigator.clipboard.writeText(markdown);
}}
onDownloadPNG={() => { onDownloadPNG={() => {
// creates a new canvas element in the visible part of the the selected area // creates a new canvas element in the visible part of the the selected area
const worksheetCanvas = worksheetRef.current?.getCanvas(); const worksheetCanvas = worksheetRef.current?.getCanvas();
@@ -588,19 +587,15 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const { const {
range: [rowStart, columnStart, rowEnd, columnEnd], range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView(); } = model.getSelectedView();
const { topLeftCell, bottomRightCell } = // NB: cells outside of the displayed area are not rendered
worksheetCanvas.getVisibleCells(); // I think the only reasonable way to do this would be server side.
const firstRow = Math.max(rowStart, topLeftCell.row);
const firstColumn = Math.max(columnStart, topLeftCell.column);
const lastRow = Math.min(rowEnd, bottomRightCell.row);
const lastColumn = Math.min(columnEnd, bottomRightCell.column);
let [x, y] = worksheetCanvas.getCoordinatesByCell( let [x, y] = worksheetCanvas.getCoordinatesByCell(
firstRow, rowStart,
firstColumn, columnStart,
); );
const [x1, y1] = worksheetCanvas.getCoordinatesByCell( const [x1, y1] = worksheetCanvas.getCoordinatesByCell(
lastRow + 1, rowEnd + 1,
lastColumn + 1, columnEnd + 1,
); );
const width = (x1 - x) * devicePixelRatio; const width = (x1 - x) * devicePixelRatio;
const height = (y1 - y) * devicePixelRatio; const height = (y1 - y) * devicePixelRatio;
@@ -719,7 +714,6 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}} }}
model={model} model={model}
workbookState={workbookState} workbookState={workbookState}
isPartOfArray={isRootCellOfArray()}
/> />
<Worksheet <Worksheet
model={model} model={model}

View File

@@ -14,7 +14,6 @@ import {
LAST_COLUMN, LAST_COLUMN,
LAST_ROW, LAST_ROW,
ROW_HEIGH_SCALE, ROW_HEIGH_SCALE,
cellArrayStructureColor,
outlineBackgroundColor, outlineBackgroundColor,
outlineColor, outlineColor,
} from "../WorksheetCanvas/constants"; } from "../WorksheetCanvas/constants";
@@ -25,7 +24,7 @@ import {
TOOLBAR_HEIGHT, TOOLBAR_HEIGHT,
} from "../constants"; } from "../constants";
import type { Cell } from "../types"; import type { Cell } from "../types";
import { AreaType, type WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import CellContextMenu from "./CellContextMenu"; import CellContextMenu from "./CellContextMenu";
import usePointer from "./usePointer"; import usePointer from "./usePointer";
@@ -60,8 +59,6 @@ const Worksheet = forwardRef(
const spacerElement = useRef<HTMLDivElement>(null); const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null); const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null); const areaOutline = useRef<HTMLDivElement>(null);
const cellOutlineHandle = useRef<HTMLDivElement>(null);
const cellArrayStructure = useRef<HTMLDivElement>(null);
const extendToOutline = useRef<HTMLDivElement>(null); const extendToOutline = useRef<HTMLDivElement>(null);
const columnResizeGuide = useRef<HTMLDivElement>(null); const columnResizeGuide = useRef<HTMLDivElement>(null);
const rowResizeGuide = useRef<HTMLDivElement>(null); const rowResizeGuide = useRef<HTMLDivElement>(null);
@@ -87,9 +84,7 @@ const Worksheet = forwardRef(
const worksheetRef = worksheetElement.current; const worksheetRef = worksheetElement.current;
const outline = cellOutline.current; const outline = cellOutline.current;
const handle = cellOutlineHandle.current;
const area = areaOutline.current; const area = areaOutline.current;
const arrayStructure = cellArrayStructure.current;
const extendTo = extendToOutline.current; const extendTo = extendToOutline.current;
const editor = editorElement.current; const editor = editorElement.current;
@@ -100,12 +95,10 @@ const Worksheet = forwardRef(
!columnHeadersRef || !columnHeadersRef ||
!worksheetRef || !worksheetRef ||
!outline || !outline ||
!handle ||
!area || !area ||
!extendTo || !extendTo ||
!scrollElement.current || !scrollElement.current ||
!editor || !editor
!arrayStructure
) )
return; return;
// FIXME: This two need to be computed. // FIXME: This two need to be computed.
@@ -122,8 +115,6 @@ const Worksheet = forwardRef(
rowGuide: rowGuideRef, rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef, columnHeaders: columnHeadersRef,
cellOutline: outline, cellOutline: outline,
cellOutlineHandle: handle,
cellArrayStructure: arrayStructure,
areaOutline: area, areaOutline: area,
extendToOutline: extendTo, extendToOutline: extendTo,
editor: editor, editor: editor,
@@ -196,8 +187,7 @@ const Worksheet = forwardRef(
worksheetCanvas.current = canvas; worksheetCanvas.current = canvas;
}); });
const { onPointerMove, onPointerDown, onPointerHandleDown, onPointerUp } = const { onPointerMove, onPointerDown, onPointerUp } = usePointer({
usePointer({
model, model,
workbookState, workbookState,
refresh, refresh,
@@ -261,134 +251,6 @@ const Worksheet = forwardRef(
} }
refresh(); refresh();
}, },
onExtendToCell: (cell) => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
// We are either extending by rows or by columns
// And we could be doing it in the positive direction (downwards or right)
// or the negative direction (upwards or left)
if (
row > rowEnd &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < row - rowEnd) ||
(column > columnEnd && column - columnEnd < row - rowEnd))
) {
// rows downwards
const area = {
type: AreaType.rowsDown,
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
row < rowStart &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < rowStart - row) ||
(column > columnEnd && column - columnEnd < rowStart - row))
) {
// rows upwards
const area = {
type: AreaType.rowsUp,
rowStart: row,
rowEnd: rowStart,
columnStart,
columnEnd,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
column > columnEnd &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < column - columnEnd) ||
(row > rowEnd && row - rowEnd < column - columnEnd))
) {
// columns right
const area = {
type: AreaType.columnsRight,
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
column < columnStart &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < columnStart - column) ||
(row > rowEnd && row - rowEnd < columnStart - column))
) {
// columns left
const area = {
type: AreaType.columnsLeft,
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
}
},
onExtendToEnd: () => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { sheet, range } = model.getSelectedView();
const extendedArea = workbookState.getExtendToArea();
if (!extendedArea) {
return;
}
const rowStart = Math.min(range[0], range[2]);
const height = Math.abs(range[2] - range[0]) + 1;
const width = Math.abs(range[3] - range[1]) + 1;
const columnStart = Math.min(range[1], range[3]);
const area = {
sheet,
row: rowStart,
column: columnStart,
width,
height,
};
switch (extendedArea.type) {
case AreaType.rowsDown:
model.autoFillRows(area, extendedArea.rowEnd);
break;
case AreaType.rowsUp: {
model.autoFillRows(area, extendedArea.rowStart);
break;
}
case AreaType.columnsRight: {
model.autoFillColumns(area, extendedArea.columnEnd);
break;
}
case AreaType.columnsLeft: {
model.autoFillColumns(area, extendedArea.columnStart);
break;
}
}
model.setSelectedRange(
Math.min(rowStart, extendedArea.rowStart),
Math.min(columnStart, extendedArea.columnStart),
Math.max(rowStart + height - 1, extendedArea.rowEnd),
Math.max(columnStart + width - 1, extendedArea.columnEnd),
);
workbookState.clearExtendToArea();
canvas.renderSheet();
},
canvasElement, canvasElement,
worksheetElement, worksheetElement,
worksheetCanvas, worksheetCanvas,
@@ -467,12 +329,7 @@ const Worksheet = forwardRef(
/> />
</EditorWrapper> </EditorWrapper>
<AreaOutline ref={areaOutline} /> <AreaOutline ref={areaOutline} />
<CellArrayStructure ref={cellArrayStructure} />
<ExtendToOutline ref={extendToOutline} /> <ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle
ref={cellOutlineHandle}
onPointerDown={onPointerHandleDown}
/>
<ColumnResizeGuide ref={columnResizeGuide} /> <ColumnResizeGuide ref={columnResizeGuide} />
<RowResizeGuide ref={rowResizeGuide} /> <RowResizeGuide ref={rowResizeGuide} />
<ColumnHeaders ref={columnHeaders} /> <ColumnHeaders ref={columnHeaders} />
@@ -637,12 +494,6 @@ const AreaOutline = styled("div")`
background-color: ${outlineBackgroundColor}; background-color: ${outlineBackgroundColor};
`; `;
const CellArrayStructure = styled("div")`
position: absolute;
border: 1px solid ${cellArrayStructureColor};
border-radius: 3px;
`;
const CellOutline = styled("div")` const CellOutline = styled("div")`
position: absolute; position: absolute;
border: 2px solid ${outlineColor}; border: 2px solid ${outlineColor};
@@ -652,15 +503,6 @@ const CellOutline = styled("div")`
display: flex; display: flex;
`; `;
const CellOutlineHandle = styled("div")`
position: absolute;
width: 5px;
height: 5px;
background: ${outlineColor};
cursor: crosshair;
border-radius: 1px;
`;
const ExtendToOutline = styled("div")` const ExtendToOutline = styled("div")`
position: absolute; position: absolute;
border: 1px dashed ${outlineColor}; border: 1px dashed ${outlineColor};

View File

@@ -20,8 +20,6 @@ interface PointerSettings {
onAllSheetSelected: () => void; onAllSheetSelected: () => void;
onAreaSelecting: (cell: Cell) => void; onAreaSelecting: (cell: Cell) => void;
onAreaSelected: () => void; onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void;
onExtendToEnd: () => void;
model: Model; model: Model;
workbookState: WorkbookState; workbookState: WorkbookState;
refresh: () => void; refresh: () => void;
@@ -31,12 +29,10 @@ interface PointerEvents {
onPointerDown: (event: PointerEvent) => void; onPointerDown: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void; onPointerMove: (event: PointerEvent) => void;
onPointerUp: (event: PointerEvent) => void; onPointerUp: (event: PointerEvent) => void;
onPointerHandleDown: (event: PointerEvent) => void;
} }
const usePointer = (options: PointerSettings): PointerEvents => { const usePointer = (options: PointerSettings): PointerEvents => {
const isSelecting = useRef(false); const isSelecting = useRef(false);
const isExtending = useRef(false);
const isInsertingRef = useRef(false); const isInsertingRef = useRef(false);
const onPointerMove = useCallback( const onPointerMove = useCallback(
@@ -47,9 +43,7 @@ const usePointer = (options: PointerSettings): PointerEvents => {
return; return;
} }
if ( if (!(isSelecting.current || isInsertingRef.current)) {
!(isSelecting.current || isExtending.current || isInsertingRef.current)
) {
return; return;
} }
const { canvasElement, model, worksheetCanvas } = options; const { canvasElement, model, worksheetCanvas } = options;
@@ -70,8 +64,6 @@ const usePointer = (options: PointerSettings): PointerEvents => {
if (isSelecting.current) { if (isSelecting.current) {
options.onAreaSelecting(cell); options.onAreaSelecting(cell);
} else if (isExtending.current) {
options.onExtendToCell(cell);
} else if (isInsertingRef.current) { } else if (isInsertingRef.current) {
const { refresh, workbookState } = options; const { refresh, workbookState } = options;
const editingCell = workbookState.getEditingCell(); const editingCell = workbookState.getEditingCell();
@@ -103,11 +95,6 @@ const usePointer = (options: PointerSettings): PointerEvents => {
isSelecting.current = false; isSelecting.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId); worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onAreaSelected(); options.onAreaSelected();
} else if (isExtending.current) {
const { worksheetElement } = options;
isExtending.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onExtendToEnd();
} else if (isInsertingRef.current) { } else if (isInsertingRef.current) {
const { worksheetElement } = options; const { worksheetElement } = options;
isInsertingRef.current = false; isInsertingRef.current = false;
@@ -120,10 +107,14 @@ const usePointer = (options: PointerSettings): PointerEvents => {
const onPointerDown = useCallback( const onPointerDown = useCallback(
(event: PointerEvent) => { (event: PointerEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target !== null && target.className === "column-resize-handle") { if (target.className === "column-resize-handle") {
// we are resizing a column // we are resizing a column
return; return;
} }
if (target.className.includes("ironcalc-cell-handle")) {
// we are extending values
return;
}
let x = event.clientX; let x = event.clientX;
let y = event.clientY; let y = event.clientY;
const { const {
@@ -236,25 +227,17 @@ const usePointer = (options: PointerSettings): PointerEvents => {
); );
// we continue to select the new cell // we continue to select the new cell
} }
if (event.shiftKey) {
// We are extending the selection
options.onAreaSelecting(cell);
options.onAreaSelected();
} else {
// We are selecting a single cell
options.onCellSelected(cell, event); options.onCellSelected(cell, event);
isSelecting.current = true; isSelecting.current = true;
worksheetWrapper.setPointerCapture(event.pointerId); worksheetWrapper.setPointerCapture(event.pointerId);
} }
},
[options],
);
const onPointerHandleDown = useCallback(
(event: PointerEvent) => {
const worksheetWrapper = options.worksheetElement.current;
// Silence the linter
if (!worksheetWrapper) {
return;
} }
isExtending.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
event.stopPropagation();
event.preventDefault();
}, },
[options], [options],
); );
@@ -263,7 +246,6 @@ const usePointer = (options: PointerSettings): PointerEvents => {
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
onPointerHandleDown,
}; };
}; };

View File

@@ -13,7 +13,6 @@ export const defaultTextColor = "#2E414D";
export const outlineColor = "#F2994A"; export const outlineColor = "#F2994A";
export const outlineBackgroundColor = "#F2994A1A"; export const outlineBackgroundColor = "#F2994A1A";
export const cellArrayStructureColor = "#64BDFDA1";
export const LAST_COLUMN = 16_384; export const LAST_COLUMN = 16_384;
export const LAST_ROW = 1_048_576; export const LAST_ROW = 1_048_576;

View File

@@ -0,0 +1,211 @@
import { AreaType } from "../workbookState";
import { LAST_COLUMN, LAST_ROW, outlineColor } from "./constants";
import type WorksheetCanvas from "./worksheetCanvas";
export function attachOutlineHandle(
worksheet: WorksheetCanvas,
): HTMLDivElement {
// There is *always* a parent
const parent = worksheet.canvas.parentElement as HTMLDivElement;
// Remove any existing cell outline handles
for (const handle of parent.querySelectorAll(".ironcalc-cell-handle")) {
handle.remove();
}
// Create a new cell outline handle
const cellOutlineHandle = document.createElement("div");
cellOutlineHandle.className = "ironcalc-cell-handle";
parent.appendChild(cellOutlineHandle);
worksheet.cellOutlineHandle = cellOutlineHandle;
Object.assign(cellOutlineHandle.style, {
position: "absolute",
width: "5px",
height: "5px",
background: outlineColor,
cursor: "crosshair",
borderRadius: "1px",
});
// cell handle events
const resizeHandleMove = (event: MouseEvent): void => {
const canvasRect = worksheet.canvas.getBoundingClientRect();
const x = event.clientX - canvasRect.x;
const y = event.clientY - canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (!cell) {
return;
}
const { row, column } = cell;
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = worksheet.model.getSelectedView();
// We are either extending by rows or by columns
// And we could be doing it in the positive direction (downwards or right)
// or the negative direction (upwards or left)
if (
row > rowEnd &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < row - rowEnd) ||
(column > columnEnd && column - columnEnd < row - rowEnd))
) {
// rows downwards
const area = {
type: AreaType.rowsDown,
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
row < rowStart &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < rowStart - row) ||
(column > columnEnd && column - columnEnd < rowStart - row))
) {
// rows upwards
const area = {
type: AreaType.rowsUp,
rowStart: row,
rowEnd: rowStart,
columnStart,
columnEnd,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
column > columnEnd &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < column - columnEnd) ||
(row > rowEnd && row - rowEnd < column - columnEnd))
) {
// columns right
const area = {
type: AreaType.columnsRight,
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
column < columnStart &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < columnStart - column) ||
(row > rowEnd && row - rowEnd < columnStart - column))
) {
// columns left
const area = {
type: AreaType.columnsLeft,
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
}
};
const resizeHandleUp = (_event: MouseEvent): void => {
document.removeEventListener("pointermove", resizeHandleMove);
document.removeEventListener("pointerup", resizeHandleUp);
const { sheet, range } = worksheet.model.getSelectedView();
const extendedArea = worksheet.workbookState.getExtendToArea();
if (!extendedArea) {
return;
}
const rowStart = Math.min(range[0], range[2]);
const height = Math.abs(range[2] - range[0]) + 1;
const width = Math.abs(range[3] - range[1]) + 1;
const columnStart = Math.min(range[1], range[3]);
const area = {
sheet,
row: rowStart,
column: columnStart,
width,
height,
};
switch (extendedArea.type) {
case AreaType.rowsDown:
worksheet.model.autoFillRows(area, extendedArea.rowEnd);
break;
case AreaType.rowsUp: {
worksheet.model.autoFillRows(area, extendedArea.rowStart);
break;
}
case AreaType.columnsRight: {
worksheet.model.autoFillColumns(area, extendedArea.columnEnd);
break;
}
case AreaType.columnsLeft: {
worksheet.model.autoFillColumns(area, extendedArea.columnStart);
break;
}
}
worksheet.model.setSelectedRange(
Math.min(rowStart, extendedArea.rowStart),
Math.min(columnStart, extendedArea.columnStart),
Math.max(rowStart + height - 1, extendedArea.rowEnd),
Math.max(columnStart + width - 1, extendedArea.columnEnd),
);
worksheet.workbookState.clearExtendToArea();
worksheet.renderSheet();
};
cellOutlineHandle.addEventListener("pointerdown", () => {
document.addEventListener("pointermove", resizeHandleMove);
document.addEventListener("pointerup", resizeHandleUp);
});
cellOutlineHandle.addEventListener("dblclick", (event) => {
// On double-click, we will auto-fill the rows below the selected cell
const [sheet, row, column] = worksheet.model.getSelectedCell();
let lastUsedRow = row + 1;
let testColumn = column - 1;
// The "test column" is the column to the left of the selected cell or the next column if the left one is empty
if (
testColumn < 1 ||
worksheet.model.getFormattedCellValue(sheet, row, column - 1) === ""
) {
testColumn = column + 1;
if (
testColumn > LAST_COLUMN ||
worksheet.model.getFormattedCellValue(sheet, row, testColumn) === ""
) {
return;
}
}
// Find the last used row in the "test column"
for (let r = row + 1; r <= LAST_ROW; r += 1) {
if (worksheet.model.getFormattedCellValue(sheet, r, testColumn) === "") {
break;
}
lastUsedRow = r;
}
const area = {
sheet,
row: row,
column: column,
width: 1,
height: 1,
};
worksheet.model.autoFillRows(area, lastUsedRow);
event.stopPropagation();
worksheet.renderSheet();
});
return cellOutlineHandle;
}

View File

@@ -0,0 +1,63 @@
// Get a 10% transparency of an hex color
export function hexToRGBA10Percent(colorHex: string): string {
// Remove the leading hash (#) if present
const hex = colorHex.replace(/^#/, "");
// Parse the hex color
const red = Number.parseInt(hex.substring(0, 2), 16);
const green = Number.parseInt(hex.substring(2, 4), 16);
const blue = Number.parseInt(hex.substring(4, 6), 16);
// Set the alpha (opacity) to 0.1 (10%)
const alpha = 0.1;
// Return the RGBA color string
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
/**
* Splits the given text into multiple lines. If `wrapText` is true, it applies word-wrapping
* based on the specified canvas context, maximum width, and horizontal padding.
*
* - First, the text is split by newline characters so that explicit newlines are respected.
* - If wrapping is enabled, each line is further split into words and measured against the
* available width. Whenever adding an extra word would exceed
* this limit, a new line is started.
*
* @param text The text to split into lines.
* @param wrapText Whether to apply word-wrapping or just return text split by newlines.
* @param context The `CanvasRenderingContext2D` used for measuring text width.
* @param width The maximum width for each line.
* @returns An array of lines (strings), each fitting within the specified width if wrapping is enabled.
*/
export function computeWrappedLines(
text: string,
wrapText: boolean,
context: CanvasRenderingContext2D,
width: number,
): string[] {
// Split the text into lines
const rawLines = text.split("\n");
if (!wrapText) {
// If there is no wrapping, return the raw lines
return rawLines;
}
const wrappedLines = [];
for (const line of rawLines) {
const words = line.split(" ");
let currentLine = words[0];
for (let i = 1; i < words.length; i += 1) {
const word = words[i];
const testLine = `${currentLine} ${word}`;
const textWidth = context.measureText(testLine).width;
if (textWidth < width) {
currentLine = testLine;
} else {
wrappedLines.push(currentLine);
currentLine = word;
}
}
wrappedLines.push(currentLine);
}
return wrappedLines;
}

View File

@@ -18,6 +18,8 @@ import {
headerTextColor, headerTextColor,
outlineColor, outlineColor,
} from "./constants"; } from "./constants";
import { attachOutlineHandle } from "./outlineHandle";
import { computeWrappedLines, hexToRGBA10Percent } from "./util";
export interface CanvasSettings { export interface CanvasSettings {
model: Model; model: Model;
@@ -28,8 +30,6 @@ export interface CanvasSettings {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
cellOutline: HTMLDivElement; cellOutline: HTMLDivElement;
areaOutline: HTMLDivElement; areaOutline: HTMLDivElement;
cellOutlineHandle: HTMLDivElement;
cellArrayStructure: HTMLDivElement;
extendToOutline: HTMLDivElement; extendToOutline: HTMLDivElement;
columnGuide: HTMLDivElement; columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement; rowGuide: HTMLDivElement;
@@ -54,70 +54,6 @@ export const defaultCellFontFamily = fonts.regular;
export const headerFontFamily = fonts.regular; export const headerFontFamily = fonts.regular;
export const frozenSeparatorWidth = 3; export const frozenSeparatorWidth = 3;
// Get a 10% transparency of an hex color
function hexToRGBA10Percent(colorHex: string): string {
// Remove the leading hash (#) if present
const hex = colorHex.replace(/^#/, "");
// Parse the hex color
const red = Number.parseInt(hex.substring(0, 2), 16);
const green = Number.parseInt(hex.substring(2, 4), 16);
const blue = Number.parseInt(hex.substring(4, 6), 16);
// Set the alpha (opacity) to 0.1 (10%)
const alpha = 0.1;
// Return the RGBA color string
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
/**
* Splits the given text into multiple lines. If `wrapText` is true, it applies word-wrapping
* based on the specified canvas context, maximum width, and horizontal padding.
*
* - First, the text is split by newline characters so that explicit newlines are respected.
* - If wrapping is enabled, each line is further split into words and measured against the
* available width. Whenever adding an extra word would exceed
* this limit, a new line is started.
*
* @param text The text to split into lines.
* @param wrapText Whether to apply word-wrapping or just return text split by newlines.
* @param context The `CanvasRenderingContext2D` used for measuring text width.
* @param width The maximum width for each line.
* @returns An array of lines (strings), each fitting within the specified width if wrapping is enabled.
*/
function computeWrappedLines(
text: string,
wrapText: boolean,
context: CanvasRenderingContext2D,
width: number,
): string[] {
// Split the text into lines
const rawLines = text.split("\n");
if (!wrapText) {
// If there is no wrapping, return the raw lines
return rawLines;
}
const wrappedLines = [];
for (const line of rawLines) {
const words = line.split(" ");
let currentLine = words[0];
for (let i = 1; i < words.length; i += 1) {
const word = words[i];
const testLine = `${currentLine} ${word}`;
const textWidth = context.measureText(testLine).width;
if (textWidth < width) {
currentLine = testLine;
} else {
wrappedLines.push(currentLine);
currentLine = word;
}
}
wrappedLines.push(currentLine);
}
return wrappedLines;
}
export default class WorksheetCanvas { export default class WorksheetCanvas {
sheetWidth: number; sheetWidth: number;
@@ -139,8 +75,6 @@ export default class WorksheetCanvas {
cellOutlineHandle: HTMLDivElement; cellOutlineHandle: HTMLDivElement;
cellArrayStructure: HTMLDivElement;
extendToOutline: HTMLDivElement; extendToOutline: HTMLDivElement;
workbookState: WorkbookState; workbookState: WorkbookState;
@@ -172,8 +106,6 @@ export default class WorksheetCanvas {
this.refresh = options.refresh; this.refresh = options.refresh;
this.cellOutline = options.elements.cellOutline; this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle;
this.cellArrayStructure = options.elements.cellArrayStructure;
this.areaOutline = options.elements.areaOutline; this.areaOutline = options.elements.areaOutline;
this.extendToOutline = options.elements.extendToOutline; this.extendToOutline = options.elements.extendToOutline;
this.rowGuide = options.elements.rowGuide; this.rowGuide = options.elements.rowGuide;
@@ -183,6 +115,7 @@ export default class WorksheetCanvas {
this.onColumnWidthChanges = options.onColumnWidthChanges; this.onColumnWidthChanges = options.onColumnWidthChanges;
this.onRowHeightChanges = options.onRowHeightChanges; this.onRowHeightChanges = options.onRowHeightChanges;
this.resetHeaders(); this.resetHeaders();
this.cellOutlineHandle = attachOutlineHandle(this);
} }
setScrollPosition(scrollPosition: { left: number; top: number }): void { setScrollPosition(scrollPosition: { left: number; top: number }): void {
@@ -1249,20 +1182,16 @@ export default class WorksheetCanvas {
} }
private drawCellOutline(): void { private drawCellOutline(): void {
const { cellArrayStructure, cellOutline, areaOutline, cellOutlineHandle } = const { cellOutline, areaOutline, cellOutlineHandle } = this;
this;
if (this.workbookState.getEditingCell()) { if (this.workbookState.getEditingCell()) {
cellOutline.style.visibility = "hidden"; cellOutline.style.visibility = "hidden";
cellOutlineHandle.style.visibility = "hidden"; cellOutlineHandle.style.visibility = "hidden";
areaOutline.style.visibility = "hidden"; areaOutline.style.visibility = "hidden";
cellArrayStructure.style.visibility = "hidden";
return; return;
} }
cellOutline.style.visibility = "visible"; cellOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible"; cellOutlineHandle.style.visibility = "visible";
areaOutline.style.visibility = "visible"; areaOutline.style.visibility = "visible";
// This one is hidden by default
cellArrayStructure.style.visibility = "hidden";
const [selectedSheet, selectedRow, selectedColumn] = const [selectedSheet, selectedRow, selectedColumn] =
this.model.getSelectedCell(); this.model.getSelectedCell();
@@ -1318,34 +1247,6 @@ export default class WorksheetCanvas {
[handleX, handleY] = this.getCoordinatesByCell(rowStart, columnStart); [handleX, handleY] = this.getCoordinatesByCell(rowStart, columnStart);
handleX += this.getColumnWidth(selectedSheet, columnStart); handleX += this.getColumnWidth(selectedSheet, columnStart);
handleY += this.getRowHeight(selectedSheet, rowStart); handleY += this.getRowHeight(selectedSheet, rowStart);
// we draw the array structure if needed only in this case
const arrayStructure = this.model.getCellArrayStructure(
selectedSheet,
selectedRow,
selectedColumn,
);
let array = null;
if (arrayStructure === "SingleCell") {
// nothing to see here
} else if ("DynamicMother" in arrayStructure) {
cellArrayStructure.style.visibility = "visible";
const [arrayWidth, arrayHeight] = arrayStructure.DynamicMother;
array = [selectedRow, selectedColumn, arrayWidth, arrayHeight];
} else {
cellArrayStructure.style.visibility = "visible";
array = arrayStructure.DynamicChild;
}
if (array !== null) {
const [arrayX, arrayY] = this.getCoordinatesByCell(array[0], array[1]);
const [arrayX1, arrayY1] = this.getCoordinatesByCell(
array[0] + array[3],
array[1] + array[2],
);
cellArrayStructure.style.left = `${arrayX}px`;
cellArrayStructure.style.top = `${arrayY}px`;
cellArrayStructure.style.width = `${arrayX1 - arrayX}px`;
cellArrayStructure.style.height = `${arrayY1 - arrayY}px`;
}
} else { } else {
areaOutline.style.visibility = "visible"; areaOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible"; cellOutlineHandle.style.visibility = "visible";

View File

@@ -19,6 +19,7 @@ import InsertColumnLeftIcon from "./insert-column-left.svg?react";
import InsertColumnRightIcon from "./insert-column-right.svg?react"; import InsertColumnRightIcon from "./insert-column-right.svg?react";
import InsertRowAboveIcon from "./insert-row-above.svg?react"; import InsertRowAboveIcon from "./insert-row-above.svg?react";
import InsertRowBelow from "./insert-row-below.svg?react"; import InsertRowBelow from "./insert-row-below.svg?react";
import Markdown from "./markdown.svg?react";
import IronCalcIcon from "./ironcalc_icon.svg?react"; import IronCalcIcon from "./ironcalc_icon.svg?react";
import IronCalcLogo from "./orange+black.svg?react"; import IronCalcLogo from "./orange+black.svg?react";
@@ -48,4 +49,5 @@ export {
IronCalcIcon, IronCalcIcon,
IronCalcLogo, IronCalcLogo,
Fx, Fx,
Markdown,
}; };

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path fill-rule="nonzero" d="M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v14h16V5H4zm3 10.5H5v-7h2l2 2 2-2h2v7h-2v-4l-2 2-2-2v4zm11-3h2l-3 3-3-3h2v-4h2v4z"/>
</g>

After

Width:  |  Height:  |  Size: 477 B

View File

@@ -26,6 +26,7 @@
"vertical_align_middle": " Align middle", "vertical_align_middle": " Align middle",
"vertical_align_top": "Align top", "vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG", "selected_png": "Export Selected area as PNG",
"selected_markdown": "Export Selected area as Markdown",
"wrap_text": "Wrap text", "wrap_text": "Wrap text",
"format_menu": { "format_menu": {
"auto": "Auto", "auto": "Auto",

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,19 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@ironcalc/workbook": "file:../../IronCalc/", "@ironcalc/workbook": "file:../../IronCalc/",
"@mui/material": "^6.4", "@mui/material": "^7.1.1",
"lucide-react": "^0.473.0", "lucide-react": "^0.513.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.1.0",
"react-dom": "^19.0.0" "react-dom": "^19.1.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@types/react": "^19.0.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2", "typescript": "~5.8.3",
"vite": "^6.0.5", "vite": "^6.3.5",
"vite-plugin-svgr": "^4.2.0" "vite-plugin-svgr": "^4.2.0"
} }
} }

View File

@@ -22,7 +22,7 @@ use itertools::Itertools;
use ironcalc_base::{ use ironcalc_base::{
expressions::{ expressions::{
parser::{static_analysis::StaticResult, stringify::to_excel_string, Node}, parser::{stringify::to_excel_string, Node},
types::CellReferenceRC, types::CellReferenceRC,
utils::number_to_column, utils::number_to_column,
}, },
@@ -56,7 +56,7 @@ fn get_formula_attribute(
pub(crate) fn get_worksheet_xml( pub(crate) fn get_worksheet_xml(
worksheet: &Worksheet, worksheet: &Worksheet,
parsed_formulas: &[(Node, StaticResult)], parsed_formulas: &[Node],
dimension: &str, dimension: &str,
is_sheet_selected: bool, is_sheet_selected: bool,
) -> String { ) -> String {
@@ -104,7 +104,7 @@ pub(crate) fn get_worksheet_xml(
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!("<c r=\"{cell_name}\"{style}/>")); row_data_str.push(format!("<c r=\"{cell_name}\"{style}/>"));
} }
Cell::SpillBooleanCell { v, s, .. } | Cell::BooleanCell { v, s } => { Cell::BooleanCell { v, s } => {
// <c r="A8" t="b" s="1"> // <c r="A8" t="b" s="1">
// <v>1</v> // <v>1</v>
// </c> // </c>
@@ -114,7 +114,7 @@ pub(crate) fn get_worksheet_xml(
"<c r=\"{cell_name}\" t=\"b\"{style}><v>{b}</v></c>" "<c r=\"{cell_name}\" t=\"b\"{style}><v>{b}</v></c>"
)); ));
} }
Cell::SpillNumberCell { v, s, .. } | Cell::NumberCell { v, s } => { Cell::NumberCell { v, s } => {
// Normally the type number is left out. Example: // Normally the type number is left out. Example:
// <c r="C6" s="1"> // <c r="C6" s="1">
// <v>3</v> // <v>3</v>
@@ -122,7 +122,7 @@ pub(crate) fn get_worksheet_xml(
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!("<c r=\"{cell_name}\"{style}><v>{v}</v></c>")); row_data_str.push(format!("<c r=\"{cell_name}\"{style}><v>{v}</v></c>"));
} }
Cell::SpillErrorCell { ei, s, .. } | Cell::ErrorCell { ei, s } => { Cell::ErrorCell { ei, s } => {
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!( row_data_str.push(format!(
"<c r=\"{cell_name}\" t=\"e\"{style}><v>{ei}</v></c>" "<c r=\"{cell_name}\" t=\"e\"{style}><v>{ei}</v></c>"
@@ -153,7 +153,7 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize].0, &parsed_formulas[*f as usize],
); );
let b = i32::from(*v); let b = i32::from(*v);
@@ -172,7 +172,7 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize].0, &parsed_formulas[*f as usize],
); );
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
@@ -189,7 +189,7 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize].0, &parsed_formulas[*f as usize],
); );
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
let escaped_v = escape_xml(v); let escaped_v = escape_xml(v);
@@ -213,135 +213,13 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize].0, &parsed_formulas[*f as usize],
); );
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!( row_data_str.push(format!(
"<c r=\"{cell_name}\" t=\"e\"{style}><f>{formula}</f><v>{ei}</v></c>" "<c r=\"{cell_name}\" t=\"e\"{style}><f>{formula}</f><v>{ei}</v></c>"
)); ));
} }
Cell::SpillStringCell { v, s, .. } => {
// inline string
// <c r="A1" t="str">
let style = get_cell_style_attribute(*s);
let escaped_v = escape_xml(v);
row_data_str.push(format!(
"<c r=\"{cell_name}\" t=\"str\"{style}><v>{escaped_v}</v></c>"
));
}
Cell::DynamicCellFormula { .. } => {
panic!("Model needs to be evaluated before saving!");
}
Cell::DynamicCellFormulaBoolean { f, v, s, r, a: _ } => {
// <c r="A1" s="3" cm="1">
// <f t="array" ref="A1:A10">A1:A10</f>
// <v>1</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
let b = i32::from(*v);
row_data_str.push(format!(
r#"<c r="{cell_name}" t="b" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{b}</v></c>"#
));
}
Cell::DynamicCellFormulaNumber { f, v, s, r, a: _ } => {
// <c r="C4" s="3" cm="1">
// <f t="array" ref="C4:C10">C4:C10</f>
// <v>123</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
row_data_str.push(format!(
r#"<c r="{cell_name}" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{v}</v></c>"#
));
}
Cell::DynamicCellFormulaString { f, v, s, r, a: _ } => {
// <c r="C6" t="str" s="5" cm="1">
// <f t="array" ref="C6:C10">C6:C10</f>
// <v>Hello world!</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
let escaped_v = escape_xml(v);
row_data_str.push(format!(
r#"<c r="{cell_name}" t="str" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{escaped_v}</v></c>"#
));
}
Cell::DynamicCellFormulaError {
f,
ei,
s,
o: _,
m: _,
r,
a: _,
} => {
// <c r="C6" t="e" s="4" cm="1">
// <f t="array" ref="C6:C10">C6:C10</f>
// <v>#DIV/0!</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
row_data_str.push(format!(
r#"<c r="{cell_name}" t="e" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{ei}</v></c>"#
));
}
} }
} }
let row_style_str = match row_style_dict.get(row_index) { let row_style_str = match row_style_dict.get(row_index) {

View File

@@ -306,15 +306,13 @@ fn from_a1_to_rc(
context: String, context: String,
tables: HashMap<String, Table>, tables: HashMap<String, Table>,
defined_names: Vec<DefinedNameS>, defined_names: Vec<DefinedNameS>,
is_array: bool,
) -> Result<String, XlsxError> { ) -> Result<String, XlsxError> {
let mut parser = Parser::new(worksheets.to_owned(), defined_names, tables); let mut parser = Parser::new(worksheets.to_owned(), defined_names, tables);
let cell_reference = let cell_reference =
parse_reference(&context).map_err(|error| XlsxError::Xml(error.to_string()))?; parse_reference(&context).map_err(|error| XlsxError::Xml(error.to_string()))?;
let mut t = parser.parse(&formula, &cell_reference); let mut t = parser.parse(&formula, &cell_reference);
if !is_array {
add_implicit_intersection(&mut t, true); add_implicit_intersection(&mut t, true);
}
Ok(to_rc_format(&t)) Ok(to_rc_format(&t))
} }
@@ -829,7 +827,6 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
}; };
let cell_metadata = cell.attribute("cm"); let cell_metadata = cell.attribute("cm");
let is_dynamic_array = cell_metadata == Some("1");
// type, the default type being "n" for number // type, the default type being "n" for number
// If the cell does not have a value is an empty cell // If the cell does not have a value is an empty cell
@@ -896,7 +893,6 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
context, context,
tables.clone(), tables.clone(),
defined_names.clone(), defined_names.clone(),
is_dynamic_array,
)?; )?;
match index_map.get(&si) { match index_map.get(&si) {
Some(index) => { Some(index) => {
@@ -945,6 +941,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
return Err(XlsxError::NotImplemented("data table formulas".to_string())); return Err(XlsxError::NotImplemented("data table formulas".to_string()));
} }
"array" | "normal" => { "array" | "normal" => {
let is_dynamic_array = cell_metadata == Some("1");
if formula_type == "array" && !is_dynamic_array { if formula_type == "array" && !is_dynamic_array {
// Dynamic formulas in Excel are formulas of type array with the cm=1, those we support. // Dynamic formulas in Excel are formulas of type array with the cm=1, those we support.
// On the other hand the old CSE formulas or array formulas are not supported in IronCalc for the time being // On the other hand the old CSE formulas or array formulas are not supported in IronCalc for the time being
@@ -959,7 +956,6 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
context, context,
tables.clone(), tables.clone(),
defined_names.clone(), defined_names.clone(),
is_dynamic_array,
)?; )?;
match get_formula_index(&formula, &shared_formulas) { match get_formula_index(&formula, &shared_formulas) {

Binary file not shown.