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
34 changed files with 2429 additions and 3023 deletions

View File

@@ -89,8 +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,
// Should we throw an error here?
Cell::Merged { .. } => {}
}; };
} }
@@ -106,8 +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,
// A merged cell has no style
Cell::Merged { .. } => 0,
} }
} }
@@ -123,7 +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::Merged { .. } => CellType::Number,
} }
} }
@@ -161,7 +156,6 @@ impl Cell {
let v = ei.to_localized_error_string(language); let v = ei.to_localized_error_string(language);
CellValue::String(v) CellValue::String(v)
} }
Cell::Merged { .. } => CellValue::None,
} }
} }

View File

@@ -59,7 +59,6 @@ pub mod mock_time;
pub use model::get_milliseconds_since_epoch; pub use model::get_milliseconds_since_epoch;
pub use model::Model; pub use model::Model;
pub use model::CellStructure;
pub use user_model::BorderArea; pub use user_model::BorderArea;
pub use user_model::ClipboardData; pub use user_model::ClipboardData;
pub use user_model::UserModel; pub use user_model::UserModel;

View File

@@ -31,7 +31,6 @@ use crate::{
}; };
use chrono_tz::Tz; use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
#[cfg(test)] #[cfg(test)]
pub use crate::mock_time::get_milliseconds_since_epoch; pub use crate::mock_time::get_milliseconds_since_epoch;
@@ -73,27 +72,6 @@ pub(crate) enum CellState {
Evaluating, Evaluating,
} }
/// Cell structure indicates if the cell is part of a merged cell or not
#[derive(Clone, Serialize, Deserialize)]
pub enum CellStructure {
/// The cell is not part of a merged cell
Simple,
/// The cell is part of a merged cell, and teh root cell is (row, column)
Merged {
/// Row of the root cell
row: i32,
/// Column of the root cell
column: i32,
},
/// The cell is the root of a merged cell of dimensions (width, height)
MergedRoot {
/// Width of the merged cell
width: i32,
/// Height of the merged cell
height: i32,
},
}
/// A parsed formula for a defined name /// A parsed formula for a defined name
#[derive(Clone)] #[derive(Clone)]
pub(crate) enum ParsedDefinedName { pub(crate) enum ParsedDefinedName {
@@ -773,7 +751,6 @@ impl Model {
} }
} }
} }
Merged { .. } => CalcResult::EmptyCell,
} }
} }
@@ -1461,10 +1438,6 @@ impl Model {
value: String, value: String,
) -> Result<(), String> { ) -> Result<(), String> {
// 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 cell = self.workbook.worksheet(sheet)?.cell(row, column);
if matches!(cell, Some(Cell::Merged { .. })) {
return Err("Cannot set value on merged cell".to_string());
}
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('\'') {
// First check if it needs quoting // First check if it needs quoting
@@ -1958,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)?,
@@ -1976,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"))
} }
@@ -2285,91 +2296,6 @@ impl Model {
pub fn delete_row_style(&mut self, sheet: u32, row: i32) -> Result<(), String> { pub fn delete_row_style(&mut self, sheet: u32, row: i32) -> Result<(), String> {
self.workbook.worksheet_mut(sheet)?.delete_row_style(row) self.workbook.worksheet_mut(sheet)?.delete_row_style(row)
} }
/// Returns the geometric structure of a cell
pub fn get_cell_structure(
&self,
sheet: u32,
row: i32,
column: i32,
) -> Result<CellStructure, String> {
let worksheet = self.workbook.worksheet(sheet)?;
worksheet.get_cell_structure(row, column)
}
/// Merges cells
pub fn merge_cells(
&mut self,
sheet: u32,
row: i32,
column: i32,
width: i32,
height: i32,
) -> Result<(), String> {
let worksheet = self.workbook.worksheet_mut(sheet)?;
let sheet_data = &mut worksheet.sheet_data;
// First check that it is possible to merge the cells
for r in row..(row + height) {
for c in column..(column + width) {
if let Some(Cell::Merged { .. }) =
sheet_data.get(&r).and_then(|row_data| row_data.get(&c))
{
return Err("Cannot merge cells".to_string());
}
}
}
worksheet
.merged_cells
.insert((row, column), (width, height));
for r in row..(row + height) {
for c in column..(column + width) {
// We remove everything except the "root" cell:
if r == row && c == column {
continue;
}
if let Some(row_data) = sheet_data.get_mut(&r) {
row_data.remove(&c);
row_data.insert(c, Cell::Merged { r: row, c: column });
} else {
let mut row_data = HashMap::new();
row_data.insert(c, Cell::Merged { r: row, c: column });
sheet_data.insert(r, row_data);
}
}
}
Ok(())
}
/// Unmerges cells
pub fn unmerge_cells(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> {
let s = self.get_cell_style_index(sheet, row, column)?;
let worksheet = self.workbook.worksheet_mut(sheet)?;
let sheet_data = &mut worksheet.sheet_data;
let (width, height) = match worksheet.merged_cells.get(&(row, column)) {
Some((w, h)) => (*w, *h),
None => return Ok(()),
};
worksheet.merged_cells.remove(&(row, column));
for r in row..(row + width) {
for c in column..(column + height) {
// We remove everything except the "root" cell:
if r == row && c == column {
continue;
}
if let Some(row_data) = sheet_data.get_mut(&r) {
row_data.remove(&c);
if s != 0 {
row_data.insert(c, Cell::EmptyCell { s });
}
} else if s != 0 {
let mut row_data = HashMap::new();
row_data.insert(c, Cell::EmptyCell { s });
sheet_data.insert(r, row_data);
}
}
}
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -58,10 +58,10 @@ impl Model {
rows: vec![], rows: vec![],
comments: vec![], comments: vec![],
dimension: "A1".to_string(), dimension: "A1".to_string(),
merge_cells: vec![],
name: name.to_string(), name: name.to_string(),
shared_formulas: vec![], shared_formulas: vec![],
sheet_data: Default::default(), sheet_data: Default::default(),
merged_cells: HashMap::new(),
sheet_id, sheet_id,
state: SheetState::Visible, state: SheetState::Visible,
color: Default::default(), color: Default::default(),

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

@@ -110,7 +110,7 @@ pub struct Worksheet {
pub sheet_id: u32, pub sheet_id: u32,
pub state: SheetState, pub state: SheetState,
pub color: Option<String>, pub color: Option<String>,
pub merged_cells: HashMap<(i32, i32), (i32, i32)>, pub merge_cells: Vec<String>,
pub comments: Vec<Comment>, pub comments: Vec<Comment>,
pub frozen_rows: i32, pub frozen_rows: i32,
pub frozen_columns: i32, pub frozen_columns: i32,
@@ -217,10 +217,7 @@ pub enum Cell {
// Error Message: "Not implemented function" // Error Message: "Not implemented function"
m: String, m: String,
}, },
Merged { // TODO: Array formulas
r: i32,
c: i32,
}, // TODO: Array formulas
} }
impl Default for Cell { impl Default for Cell {
@@ -306,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

@@ -11,7 +11,7 @@ use crate::{
types::{Area, CellReferenceIndex}, types::{Area, CellReferenceIndex},
utils::{is_valid_column_number, is_valid_row}, utils::{is_valid_column_number, is_valid_row},
}, },
model::{CellStructure, Model}, model::Model,
types::{ types::{
Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState, Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState,
Style, VerticalAlignment, Style, VerticalAlignment,
@@ -293,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:
@@ -1869,57 +1882,6 @@ impl UserModel {
Ok(()) Ok(())
} }
/// Merges cells
pub fn merge_cells(
&mut self,
sheet: u32,
row: i32,
column: i32,
width: i32,
height: i32,
) -> Result<(), String> {
let old_data = Vec::new();
let diff_list = vec![Diff::MergeCells {
sheet,
row,
column,
width,
height,
old_data,
}];
self.model.merge_cells(sheet, row, column, width, height)?;
self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(())
}
/// Check if cell is part of a merged cell
pub fn get_cell_structure(&self, sheet: u32, row: i32, column: i32) -> Result<CellStructure, String> {
self.model.get_cell_structure(sheet, row, column)
}
/// Unmerges cells
pub fn unmerge_cells(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> {
let (width, height) = self
.model
.workbook
.worksheet(sheet)?
.merged_cells
.get(&(row, column))
.ok_or("No merged cells found")?;
let diff_list = vec![Diff::UnmergeCells {
sheet,
row,
column,
width: *width,
height: *height,
}];
self.model.unmerge_cells(sheet, row, column)?;
self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(())
}
// **** Private methods ****** // // **** Private methods ****** //
pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) { pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) {
@@ -2163,6 +2125,7 @@ impl UserModel {
worksheet.frozen_rows = old_data.frozen_rows; worksheet.frozen_rows = old_data.frozen_rows;
worksheet.state = old_data.state.clone(); worksheet.state = old_data.state.clone();
worksheet.color = old_data.color.clone(); worksheet.color = old_data.color.clone();
worksheet.merge_cells = old_data.merge_cells.clone();
worksheet.shared_formulas = old_data.shared_formulas.clone(); worksheet.shared_formulas = old_data.shared_formulas.clone();
self.model.reset_parsed_structures(); self.model.reset_parsed_structures();
@@ -2213,34 +2176,6 @@ impl UserModel {
self.model.delete_row_style(*sheet, *row)?; self.model.delete_row_style(*sheet, *row)?;
} }
} }
Diff::MergeCells {
sheet,
row,
column,
width,
height,
old_data,
} => {
needs_evaluation = true;
self.model.unmerge_cells(*sheet, *row, *column)?;
// for (r, c, v) in old_data.iter() {
// self.model
// .workbook
// .worksheet_mut(*sheet)?
// .update_cell(*r, *c, v.clone())?;
// }
}
Diff::UnmergeCells {
sheet,
row,
column,
width,
height,
} => {
needs_evaluation = true;
self.model
.merge_cells(*sheet, *row, *column, *width, *height)?;
}
} }
} }
if needs_evaluation { if needs_evaluation {
@@ -2442,34 +2377,6 @@ impl UserModel {
} => { } => {
self.model.delete_row_style(*sheet, *row)?; self.model.delete_row_style(*sheet, *row)?;
} }
Diff::MergeCells {
sheet,
row,
column,
width,
height,
old_data: _,
} => {
needs_evaluation = true;
self.model
.merge_cells(*sheet, *row, *column, *width, *height)?;
// for (r, c, v) in old_data.iter() {
// self.model
// .workbook
// .worksheet_mut(*sheet)?
// .update_cell(*r, *c, v.clone())?;
// }
}
Diff::UnmergeCells {
sheet,
row,
column,
width,
height,
} => {
needs_evaluation = true;
self.model.unmerge_cells(*sheet, *row, *column)?;
}
} }
} }

View File

@@ -161,21 +161,7 @@ pub(crate) enum Diff {
new_scope: Option<u32>, new_scope: Option<u32>,
new_formula: String, new_formula: String,
}, },
MergeCells { // FIXME: we are missing SetViewDiffs
sheet: u32,
row: i32,
column: i32,
width: i32,
height: i32,
old_data: Vec<(Cell, Style)>,
},
UnmergeCells {
sheet: u32,
row: i32,
column: i32,
width: i32,
height: i32,
}, // FIXME: we are missing SetViewDiffs
} }
pub(crate) type DiffList = Vec<Diff>; pub(crate) type DiffList = Vec<Diff>;

View File

@@ -2,10 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::expressions::utils::{is_valid_column_number, is_valid_row};
expressions::utils::{is_valid_column_number, is_valid_row},
CellStructure,
};
use super::common::UserModel; use super::common::UserModel;
@@ -100,47 +97,26 @@ impl UserModel {
if !is_valid_row(row) { if !is_valid_row(row) {
return Err(format!("Invalid row: '{row}'")); return Err(format!("Invalid row: '{row}'"));
} }
let worksheet = self.model.workbook.worksheet_mut(sheet)?; if self.model.workbook.worksheet(sheet).is_err() {
let structure = worksheet.get_cell_structure(row, column)?; return Err(format!("Invalid worksheet index {}", sheet));
// check if the selected cell is a merged cell }
let [row_start, columns_start, row_end, columns_end] = match structure { if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
CellStructure::Simple => [row, column, row, column], if let Some(view) = worksheet.views.get_mut(&0) {
CellStructure::Merged { view.row = row;
row: row_start, view.column = column;
column: column_start, view.range = [row, column, row, column];
} => { }
let (width, height) = match worksheet.merged_cells.get(&(row_start, column_start)) {
Some(s) => s,
None => return Err(format!("Merged cell not found: ({row_start}, {column_start}) when clicking at ({row}, {column}).")),
};
let row_end = row_start + height - 1;
let column_end = column_start + width - 1;
[row_start, column_start, row_end, column_end]
}
CellStructure::MergedRoot { width, height } => {
let row_start = row;
let columns_start = column;
let row_end = row + height - 1;
let columns_end = column + width - 1;
[row_start, columns_start, row_end, columns_end]
}
};
if let Some(view) = worksheet.views.get_mut(&0) {
view.row = row_start;
view.column = columns_start;
view.range = [row_start, columns_start, row_end, columns_end];
} }
Ok(()) Ok(())
} }
/// Sets the selected range. Note that the selected cell must be in one of the corners. /// Sets the selected range. Note that the selected cell must be in one of the corners.
pub fn set_selected_range( pub fn set_selected_range(
&mut self, &mut self,
row_start: i32, start_row: i32,
column_start: i32, start_column: i32,
row_end: i32, end_row: i32,
column_end: i32, end_column: i32,
) -> Result<(), String> { ) -> Result<(), String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) { let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet view.sheet
@@ -148,72 +124,42 @@ impl UserModel {
0 0
}; };
if !is_valid_column_number(column_start) { if !is_valid_column_number(start_column) {
return Err(format!("Invalid column: '{column_start}'")); return Err(format!("Invalid column: '{start_column}'"));
} }
if !is_valid_row(row_start) { if !is_valid_row(start_row) {
return Err(format!("Invalid row: '{row_start}'")); return Err(format!("Invalid row: '{start_row}'"));
} }
if !is_valid_column_number(column_end) { if !is_valid_column_number(end_column) {
return Err(format!("Invalid column: '{column_end}'")); return Err(format!("Invalid column: '{end_column}'"));
} }
if !is_valid_row(row_end) { if !is_valid_row(end_row) {
return Err(format!("Invalid row: '{row_end}'")); return Err(format!("Invalid row: '{end_row}'"));
} }
let mut start_row = row_start; if self.model.workbook.worksheet(sheet).is_err() {
let mut start_column = column_start; return Err(format!("Invalid worksheet index {}", sheet));
let mut end_row = row_end; }
let mut end_column = column_end; if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
let worksheet = self.model.workbook.worksheet_mut(sheet)?; if let Some(view) = worksheet.views.get_mut(&0) {
let merged_cells = &worksheet.merged_cells; let selected_row = view.row;
if !merged_cells.is_empty() { let selected_column = view.column;
// We need to check if there are merged cells in the selected range // The selected cells must be on one of the corners of the selected range:
for row in row_start..=row_end { if selected_row != start_row && selected_row != end_row {
for column in column_start..=column_end { return Err(format!(
let structure = &worksheet.get_cell_structure(row, column)?; "The selected cells is not in one of the corners. Row: '{}' and row range '({}, {})'",
match structure { selected_row, start_row, end_row
CellStructure::Simple => {} ));
CellStructure::Merged { row: r, column: c } => {
// The selected range must contain the merged cell
let (width, height) = match merged_cells.get(&(*r, *c)) {
Some(s) => s,
None => return Err(format!("Merged cell not found: ({r}, {c}) when selecting range ({start_row}, {start_column}, {end_row}, {end_column}).")),
};
start_row = start_row.min(*r);
start_column = start_column.min(*c);
end_row = end_row.max(*r + height - 1);
end_column = end_column.max(*c + width - 1);
}
CellStructure::MergedRoot { width, height } => {
// The selected range must contain the merged cell
end_row = end_row.max(row + height - 1);
end_column = end_column.max(column + width - 1);
}
}
} }
if selected_column != start_column && selected_column != end_column {
return Err(format!(
"The selected cells is not in one of the corners. Column '{}' and column range '({}, {})'",
selected_column, start_column, end_column
));
}
view.range = [start_row, start_column, end_row, end_column];
} }
} }
if let Some(view) = worksheet.views.get_mut(&0) {
// let selected_row = view.row;
// let selected_column = view.column;
// // The selected cells must be on one of the corners of the selected range:
// if selected_row != start_row && selected_row != end_row {
// return Err(format!(
// "The selected cells is not in one of the corners. Row: '{}' and row range '({}, {})'",
// selected_row, start_row, end_row
// ));
// }
// if selected_column != start_column && selected_column != end_column {
// return Err(format!(
// "The selected cells is not in one of the corners. Column '{}' and column range '({}, {})'",
// selected_column, start_column, end_column
// ));
// }
view.range = [start_row, start_column, end_row, end_column];
}
Ok(()) Ok(())
} }

View File

@@ -1,7 +1,6 @@
use crate::constants::{self, LAST_COLUMN, LAST_ROW}; use crate::constants::{self, LAST_COLUMN, LAST_ROW};
use crate::expressions::types::CellReferenceIndex; use crate::expressions::types::CellReferenceIndex;
use crate::expressions::utils::{is_valid_column_number, is_valid_row}; use crate::expressions::utils::{is_valid_column_number, is_valid_row};
use crate::CellStructure;
use crate::{expressions::token::Error, types::*}; use crate::{expressions::token::Error, types::*};
use std::collections::HashMap; use std::collections::HashMap;
@@ -39,24 +38,6 @@ impl Worksheet {
self.sheet_data.get(&row)?.get(&column) self.sheet_data.get(&row)?.get(&column)
} }
pub fn get_cell_structure(&self, row: i32, column: i32) -> Result<CellStructure, String> {
if let Some((width, height)) = self.merged_cells.get(&(row, column)) {
return Ok(CellStructure::MergedRoot {
width: *width,
height: *height,
});
}
let cell = self.cell(row, column);
if let Some(Cell::Merged { r, c }) = cell {
return Ok(CellStructure::Merged {
row: *r,
column: *c,
});
}
Ok(CellStructure::Simple)
}
pub(crate) fn cell_mut(&mut self, row: i32, column: i32) -> Option<&mut Cell> { pub(crate) fn cell_mut(&mut self, row: i32, column: i32) -> Option<&mut Cell> {
self.sheet_data.get_mut(&row)?.get_mut(&column) self.sheet_data.get_mut(&row)?.get_mut(&column)
} }

View File

@@ -201,26 +201,6 @@ defined_name_list_types = r"""
getDefinedNameList(): DefinedName[]; getDefinedNameList(): DefinedName[];
""" """
merged_cells = r"""
/**
* @param {number} sheet
* @param {number} row
* @param {number} column
* @returns {any}
*/
getCellStructure(sheet: number, row: number, column: number): any;
"""
merged_cells_types = r"""
/**
* @param {number} sheet
* @param {number} row
* @param {number} column
* @returns {CellStructure}
*/
getCellStructure(sheet: number, row: number, column: number): CellStructure;
"""
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)
@@ -235,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(merged_cells, merged_cells_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

@@ -5,7 +5,9 @@ use wasm_bindgen::{
}; };
use ironcalc_base::{ use ironcalc_base::{
expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, types::{CellType, Style}, BorderArea, ClipboardData, UserModel as BaseModel expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column},
types::{CellType, Style},
BorderArea, ClipboardData, UserModel as BaseModel,
}; };
fn to_js_error(error: String) -> JsError { fn to_js_error(error: String) -> JsError {
@@ -671,35 +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 = "mergeCells")] #[wasm_bindgen(js_name = "getSheetMarkup")]
pub fn merge_cells( pub fn get_sheet_markup(
&mut self,
sheet: u32,
row: i32,
column: i32,
width: i32,
height: i32,
) -> Result<(), JsError> {
self.model
.merge_cells(sheet, row, column, width, height)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "unmergeCells")]
pub fn unmerge_cells(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), JsError> {
self.model
.unmerge_cells(sheet, row, column)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "getCellStructure")]
pub fn get_cell_structure(
&self, &self,
sheet: u32, sheet: u32,
row: i32, start_row: i32,
column: i32, start_column: i32,
) -> Result<JsValue, JsError> { end_row: i32,
let data = self.model.get_cell_structure(sheet, row, column).map_err(|e| to_js_error(e.to_string()))?; end_column: i32,
serde_wasm_bindgen::to_value(&data).map_err(|e| to_js_error(e.to_string())) ) -> Result<String, JsError> {
self.model
.get_sheet_markup(sheet, start_row, start_column, end_row, end_column)
.map_err(to_js_error)
} }
} }

View File

@@ -216,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;
@@ -233,9 +233,4 @@ export interface DefinedName {
name: string; name: string;
scope?: number; scope?: number;
formula: string; formula: string;
} }
export type CellStructure =
| "Simple"
| { Merged: { row: number; column: number } }
| { MergedRoot: { width: number; height: number } };

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

@@ -40,8 +40,7 @@ import {
ArrowMiddleFromLine, ArrowMiddleFromLine,
DecimalPlacesDecreaseIcon, DecimalPlacesDecreaseIcon,
DecimalPlacesIncreaseIcon, DecimalPlacesIncreaseIcon,
MergeCellsIcon, Markdown,
UnmergeCellsIcon,
} from "../../icons"; } from "../../icons";
import { theme } from "../../theme"; import { theme } from "../../theme";
import BorderPicker from "../BorderPicker/BorderPicker"; import BorderPicker from "../BorderPicker/BorderPicker";
@@ -76,8 +75,7 @@ type ToolbarProperties = {
onClearFormatting: () => void; onClearFormatting: () => void;
onIncreaseFontSize: (delta: number) => void; onIncreaseFontSize: (delta: number) => void;
onDownloadPNG: () => void; onDownloadPNG: () => void;
onMergeCells: () => void; onCopyMarkdown: () => void;
onUnmergeCells: () => void;
fillColor: string; fillColor: string;
fontColor: string; fontColor: string;
fontSize: number; fontSize: number;
@@ -436,24 +434,13 @@ function Toolbar(properties: ToolbarProperties) {
<StyledButton <StyledButton
type="button" type="button"
$pressed={false} $pressed={false}
disabled={!canEdit}
onClick={() => { onClick={() => {
properties.onMergeCells(); properties.onCopyMarkdown();
}} }}
title={t("toolbar.merge_cells")}
>
<MergeCellsIcon />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit} disabled={!canEdit}
onClick={() => { title={t("toolbar.selected_markdown")}
properties.onUnmergeCells();
}}
title={t("toolbar.unmerge_cells")}
> >
<UnmergeCellsIcon /> <Markdown />
</StyledButton> </StyledButton>
<ColorPicker <ColorPicker

View File

@@ -558,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();
@@ -567,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;
@@ -611,29 +627,6 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
downloadLink.download = "ironcalc.png"; downloadLink.download = "ironcalc.png";
downloadLink.click(); downloadLink.click();
}} }}
onMergeCells={() => {
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;
model.mergeCells(sheet, row, column, width, height);
setRedrawId((id) => id + 1);
}}
onUnmergeCells={() => {
const {
sheet,
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
const row = Math.min(rowStart, rowEnd);
const column = Math.min(columnStart, columnEnd);
model.unmergeCells(sheet, row, column);
setRedrawId((id) => id + 1);
}}
onBorderChanged={(border: BorderOptions): void => { onBorderChanged={(border: BorderOptions): void => {
const { const {
sheet, sheet,

View File

@@ -24,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";
@@ -59,7 +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 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);
@@ -85,7 +84,6 @@ 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 extendTo = extendToOutline.current; const extendTo = extendToOutline.current;
const editor = editorElement.current; const editor = editorElement.current;
@@ -97,7 +95,6 @@ const Worksheet = forwardRef(
!columnHeadersRef || !columnHeadersRef ||
!worksheetRef || !worksheetRef ||
!outline || !outline ||
!handle ||
!area || !area ||
!extendTo || !extendTo ||
!scrollElement.current || !scrollElement.current ||
@@ -118,7 +115,6 @@ const Worksheet = forwardRef(
rowGuide: rowGuideRef, rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef, columnHeaders: columnHeadersRef,
cellOutline: outline, cellOutline: outline,
cellOutlineHandle: handle,
areaOutline: area, areaOutline: area,
extendToOutline: extendTo, extendToOutline: extendTo,
editor: editor, editor: editor,
@@ -191,203 +187,74 @@ 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, onColumnSelected: (column: number, shift: boolean) => {
onColumnSelected: (column: number, shift: boolean) => { let firstColumn = column;
let firstColumn = column; let lastColumn = column;
let lastColumn = column; if (shift) {
if (shift) { const { range } = model.getSelectedView();
const { range } = model.getSelectedView(); firstColumn = Math.min(range[1], column, range[3]);
firstColumn = Math.min(range[1], column, range[3]); lastColumn = Math.max(range[3], column, range[1]);
lastColumn = Math.max(range[3], column, range[1]); }
} model.setSelectedCell(1, firstColumn);
model.setSelectedCell(1, firstColumn); model.setSelectedRange(1, firstColumn, LAST_ROW, lastColumn);
model.setSelectedRange(1, firstColumn, LAST_ROW, lastColumn); refresh();
refresh(); },
}, onRowSelected: (row: number, shift: boolean) => {
onRowSelected: (row: number, shift: boolean) => { let firstRow = row;
let firstRow = row; let lastRow = row;
let lastRow = row; if (shift) {
if (shift) { const { range } = model.getSelectedView();
const { range } = model.getSelectedView(); firstRow = Math.min(range[0], row, range[2]);
firstRow = Math.min(range[0], row, range[2]); lastRow = Math.max(range[2], row, range[0]);
lastRow = Math.max(range[2], row, range[0]); }
} model.setSelectedCell(firstRow, 1);
model.setSelectedCell(firstRow, 1); model.setSelectedRange(firstRow, 1, lastRow, LAST_COLUMN);
model.setSelectedRange(firstRow, 1, lastRow, LAST_COLUMN); refresh();
refresh(); },
}, onAllSheetSelected: () => {
onAllSheetSelected: () => { model.setSelectedCell(1, 1);
model.setSelectedCell(1, 1); model.setSelectedRange(1, 1, LAST_ROW, LAST_COLUMN);
model.setSelectedRange(1, 1, LAST_ROW, LAST_COLUMN); },
}, onCellSelected: (cell: Cell, event: React.MouseEvent) => {
onCellSelected: (cell: Cell, event: React.MouseEvent) => { event.preventDefault();
event.preventDefault(); event.stopPropagation();
event.stopPropagation(); model.setSelectedCell(cell.row, cell.column);
model.setSelectedCell(cell.row, cell.column); refresh();
refresh(); },
}, onAreaSelecting: (cell: Cell) => {
onAreaSelecting: (cell: Cell) => { const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
model.onAreaSelecting(row, column);
canvas.renderSheet();
refresh();
},
onAreaSelected: () => {
const styles = workbookState.getCopyStyles();
if (styles?.length) {
model.onPasteStyles(styles);
const canvas = worksheetCanvas.current; const canvas = worksheetCanvas.current;
if (!canvas) { if (!canvas) {
return; return;
} }
const { row, column } = cell;
model.onAreaSelecting(row, column);
canvas.renderSheet(); canvas.renderSheet();
refresh(); }
}, workbookState.setCopyStyles(null);
onAreaSelected: () => { if (worksheetElement.current) {
const styles = workbookState.getCopyStyles(); worksheetElement.current.style.cursor = "auto";
if (styles?.length) { }
model.onPasteStyles(styles); refresh();
const canvas = worksheetCanvas.current; },
if (!canvas) { canvasElement,
return; worksheetElement,
} worksheetCanvas,
canvas.renderSheet(); });
}
workbookState.setCopyStyles(null);
if (worksheetElement.current) {
worksheetElement.current.style.cursor = "auto";
}
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,
worksheetElement,
worksheetCanvas,
});
const onScroll = (): void => { const onScroll = (): void => {
if (!scrollElement.current || !worksheetCanvas.current) { if (!scrollElement.current || !worksheetCanvas.current) {
@@ -463,10 +330,6 @@ const Worksheet = forwardRef(
</EditorWrapper> </EditorWrapper>
<AreaOutline ref={areaOutline} /> <AreaOutline ref={areaOutline} />
<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} />
@@ -640,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,34 +227,25 @@ const usePointer = (options: PointerSettings): PointerEvents => {
); );
// we continue to select the new cell // we continue to select the new cell
} }
options.onCellSelected(cell, event); if (event.shiftKey) {
isSelecting.current = true; // We are extending the selection
worksheetWrapper.setPointerCapture(event.pointerId); options.onAreaSelecting(cell);
options.onAreaSelected();
} else {
// We are selecting a single cell
options.onCellSelected(cell, event);
isSelecting.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
}
} }
}, },
[options], [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],
);
return { return {
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
onPointerHandleDown,
}; };
}; };

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,7 +30,6 @@ export interface CanvasSettings {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
cellOutline: HTMLDivElement; cellOutline: HTMLDivElement;
areaOutline: HTMLDivElement; areaOutline: HTMLDivElement;
cellOutlineHandle: HTMLDivElement;
extendToOutline: HTMLDivElement; extendToOutline: HTMLDivElement;
columnGuide: HTMLDivElement; columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement; rowGuide: HTMLDivElement;
@@ -53,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;
@@ -169,7 +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.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;
@@ -179,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 {
@@ -386,29 +323,10 @@ export default class WorksheetCanvas {
column: number, column: number,
x: number, x: number,
y: number, y: number,
width1: number, width: number,
height1: number, height: number,
): void { ): void {
const selectedSheet = this.model.getSelectedSheet(); const selectedSheet = this.model.getSelectedSheet();
const structure = this.model.getCellStructure(selectedSheet, row, column);
if (typeof structure === 'object' && 'Merged' in structure) {
// We don't render merged cells
return;
}
let width = width1;
let height = height1;
if (typeof structure === 'object' && 'MergedRoot' in structure) {
const root = structure.MergedRoot;
const columns = root.width;
const rows = root.height;
for (let i = 1; i < columns; i += 1) {
width += this.getColumnWidth(selectedSheet, column + i);
}
for (let i = 1; i < rows; i += 1) {
height += this.getRowHeight(selectedSheet, row + i);
}
};
const style = this.model.getCellStyle(selectedSheet, row, column); const style = this.model.getCellStyle(selectedSheet, row, column);
let backgroundColor = "#FFFFFF"; let backgroundColor = "#FFFFFF";

View File

@@ -19,13 +19,11 @@ 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";
import MergeCellsIcon from "./merge-cells.svg?react";
import UnmergeCellsIcon from "./unmerge-cells.svg?react";
import Fx from "./fx.svg?react"; import Fx from "./fx.svg?react";
export { export {
@@ -50,7 +48,6 @@ export {
InsertRowBelow, InsertRowBelow,
IronCalcIcon, IronCalcIcon,
IronCalcLogo, IronCalcLogo,
MergeCellsIcon,
UnmergeCellsIcon,
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

@@ -1,4 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2L3.33333 2C2.59695 2 2 2.59695 2 3.33333L2 5M8 2L12.6667 2C13.403 2 14 2.59695 14 3.33333L14 5M8 2L8 5M8 14L12.6667 14C13.403 14 14 13.403 14 12.6667L14 11M8 14L3.33333 14C2.59695 14 2 13.403 2 12.6667L2 11M8 14L8 11M2 5L2 11M2 5L14 5M2 11L14 11M14 5L14 11" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8L11 8M5 8L6 9L6 7L5 8ZM11 8L10 7L10 9L11 8Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 564 B

View File

@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2L3.33333 2C2.59695 2 2 2.59695 2 3.33333L2 5M8 2L12.6667 2C13.403 2 14 2.59695 14 3.33333L14 5M8 2L8 5M8 14L12.6667 14C13.403 14 14 13.403 14 12.6667L14 11M8 14L3.33333 14C2.59695 14 2 13.403 2 12.6667L2 11M8 14L8 11M2 5L2 11M2 5L14 5M2 11L14 11M14 5L14 11" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 8L6 8M6 8L5 7L5 9L6 8Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 8L10 8M10 8L11 7L11 9L10 8Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 650 B

View File

@@ -26,9 +26,8 @@
"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",
"merge_cells": "Merge cells",
"unmerge_cells": "Unmerge cells",
"format_menu": { "format_menu": {
"auto": "Auto", "auto": "Auto",
"number": "Number", "number": "Number",

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

@@ -220,7 +220,6 @@ pub(crate) fn get_worksheet_xml(
"<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::Merged { .. } => { /* do nothing */ }
} }
} }
let row_style_str = match row_style_dict.get(row_index) { let row_style_str = match row_style_dict.get(row_index) {
@@ -248,7 +247,7 @@ pub(crate) fn get_worksheet_xml(
} }
let sheet_data = sheet_data_str.join(""); let sheet_data = sheet_data_str.join("");
for merge_cell_ref in &worksheet.merged_cells { for merge_cell_ref in &worksheet.merge_cells {
merged_cells_str.push(format!("<mergeCell ref=\"{merge_cell_ref}\"/>")) merged_cells_str.push(format!("<mergeCell ref=\"{merge_cell_ref}\"/>"))
} }
let merged_cells_count = merged_cells_str.len(); let merged_cells_count = merged_cells_str.len();

View File

@@ -989,7 +989,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
sheet_data.insert(row_index, data_row); sheet_data.insert(row_index, data_row);
} }
let merged_cells = load_merged_cells(ws)?; let merge_cells = load_merge_cells(ws)?;
// Conditional Formatting // Conditional Formatting
// <conditionalFormatting sqref="B1:B9"> // <conditionalFormatting sqref="B1:B9">
@@ -1028,7 +1028,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
sheet_id, sheet_id,
state: state.to_owned(), state: state.to_owned(),
color, color,
merged_cells, merge_cells,
comments: settings.comments, comments: settings.comments,
frozen_rows: sheet_view.frozen_rows, frozen_rows: sheet_view.frozen_rows,
frozen_columns: sheet_view.frozen_columns, frozen_columns: sheet_view.frozen_columns,