Compare commits

...

16 Commits

Author SHA1 Message Date
Nicolás Hatcher
9d83cc87c9 UPDATE: Adds web app 2024-07-21 14:48:56 +02:00
Nicolás Hatcher Andrés
0ba80035d2 FIX: Run test coverage only on Pull Request (#77) 2024-07-16 07:48:41 +02:00
Nicolás Hatcher Andrés
55a043366a FIX: Fixes TypeScript types correctly (#75)
We really need a better way of doing this :/
2024-06-02 19:03:59 +02:00
Nicolás Hatcher Andrés
864a37f1e6 UPDATE: Adds autofill_columns (#74) 2024-06-02 18:43:43 +02:00
Nicolás Hatcher Andrés
72c7c94f3d UPDATE: Autofill by rows (#73) 2024-06-02 10:01:46 +02:00
Nicolás Hatcher Andrés
c3a9b006d2 FIX: Export views and showGridLines properly (#72) 2024-05-28 21:52:10 +02:00
Nicolás Hatcher Andrés
b37397acb8 UPDATE: Adds ability to show/hide grid lines (#71) 2024-05-26 21:28:00 +02:00
Nicolás Hatcher Andrés
49c3b14bf0 UPDATE: Adds get/set views to the user model API (#69) 2024-05-20 17:51:09 +02:00
Nicolás Hatcher Andrés
d2cba48f8e FIX: Fixes incorrect result in M1 Apple silicom (#68) 2024-05-19 10:34:57 +02:00
Nicolás Hatcher Andrés
f752c90058 UPDATE: Parses selected row/column/range and selected sheet (#67)
* FIX: Update to Rust 1.78.0

* UPDATE: Parses selected row/column/range and selected sheet
2024-05-09 11:46:26 +02:00
Daniel González-Albo
a78d5593f2 Merge pull request #60 from ironcalc/feature/dani-logo
UPDATE: adds missing favicons
2024-04-27 18:11:29 +02:00
Daniel
079208a1bd UPDATE: adds missing favicons 2024-04-27 18:02:04 +02:00
Daniel González-Albo
4721582dfe Merge pull request #42 from ironcalc/feature/dani-logo
UPDATE: adds logo
2024-04-25 19:49:43 +02:00
Daniel
1746eec5da UPDATE: adds logo 2024-04-25 19:42:10 +02:00
Nicolás Hatcher Andrés
f9cf86a17c Bugfix/nicolas more fixes (#36)
* FIX: Remove the serde_json depndendency

* UPDATE: Use binary representation also for languages and locales
2024-04-15 19:25:38 +02:00
Nicolás Hatcher Andrés
49ef846ebd FIX: small diverse fixes (#35) 2024-04-14 21:50:14 +02:00
142 changed files with 27364 additions and 3038 deletions

View File

@@ -1,6 +1,6 @@
name: Coverage name: Coverage
on: [pull_request, push] on: [pull_request]
jobs: jobs:
coverage: coverage:

3
.gitignore vendored
View File

@@ -1 +1,2 @@
target/* target/*
.DS_Store

12
Cargo.lock generated
View File

@@ -370,7 +370,6 @@ dependencies = [
"ryu", "ryu",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr",
] ]
[[package]] [[package]]
@@ -679,17 +678,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_repr"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
assets/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

BIN
assets/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,8 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="600" height="600" rx="20" fill="#F2994A"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M348.98 100C348.98 166.034 322.748 229.362 276.055 276.055C268.163 283.947 259.796 291.255 251.021 297.95L251.021 500L348.98 500H251.021C251.021 433.966 277.252 370.637 323.945 323.945C331.837 316.053 340.204 308.745 348.98 302.05L348.98 100Z" fill="white"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M251.021 100.068C251.003 140.096 235.094 178.481 206.788 206.787C178.466 235.109 140.053 251.02 100 251.02V348.979C154.873 348.979 207.877 330.866 251.021 297.95V100.068Z" fill="white"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M348.98 499.882C349.011 459.872 364.918 421.507 393.213 393.213C421.534 364.891 459.947 348.98 500 348.98V251.02C445.128 251.02 392.123 269.134 348.98 302.05V499.882Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M276.055 276.055C322.748 229.362 348.98 166.034 348.98 100H251.021V297.95C259.796 291.255 268.163 283.947 276.055 276.055Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M348.98 302.05V499.895C348.98 499.93 348.98 499.965 348.98 500L251.021 500C251.021 499.946 251.02 499.891 251.021 499.837C251.064 433.862 277.291 370.599 323.945 323.945C331.837 316.053 340.204 308.745 348.98 302.05Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 33 KiB

BIN
assets/logo/png/black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
assets/logo/png/white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -12,8 +12,6 @@ readme = "README.md"
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_repr = "0.1"
ryu = "1.0" ryu = "1.0"
chrono = "0.4" chrono = "0.4"
chrono-tz = "0.9" chrono-tz = "0.9"
@@ -21,6 +19,9 @@ regex = "1.0"
once_cell = "1.16.0" once_cell = "1.16.0"
bitcode = "0.6.0" bitcode = "0.6.0"
[dev-dependencies]
serde_json = "1.0"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = { version = "0.3.69" } js-sys = { version = "0.3.69" }

View File

@@ -1,12 +1,9 @@
use crate::{ use crate::{
expressions::token::Error, language::Language, number_format::to_excel_precision_str, types::*, expressions::token::Error, language::Language, number_format::to_excel_precision_str, types::*,
}; };
use serde::{Deserialize, Serialize};
use serde_json::json;
/// A CellValue is the representation of the cell content. /// A CellValue is the representation of the cell content.
#[derive(Serialize, Deserialize, Debug, PartialEq)] #[derive(Debug, PartialEq)]
#[serde(untagged)]
pub enum CellValue { pub enum CellValue {
None, None,
String(String), String(String),
@@ -14,17 +11,6 @@ pub enum CellValue {
Boolean(bool), Boolean(bool),
} }
impl CellValue {
pub fn to_json_str(&self) -> String {
match &self {
CellValue::None => "null".to_string(),
CellValue::String(s) => json!(s).to_string(),
CellValue::Number(f) => json!(f).to_string(),
CellValue::Boolean(b) => json!(b).to_string(),
}
}
}
impl From<f64> for CellValue { impl From<f64> for CellValue {
fn from(value: f64) -> Self { fn from(value: f64) -> Self {
Self::Number(value) Self::Number(value)

View File

@@ -308,9 +308,9 @@ impl Lexer {
return self.consume_range(None); return self.consume_range(None);
} }
let name_upper = name.to_ascii_uppercase(); let name_upper = name.to_ascii_uppercase();
if name_upper == self.language.booleans.true_value { if name_upper == self.language.booleans.r#true {
return TokenType::Boolean(true); return TokenType::Boolean(true);
} else if name_upper == self.language.booleans.false_value { } else if name_upper == self.language.booleans.r#false {
return TokenType::Boolean(false); return TokenType::Boolean(false);
} }
if self.mode == LexerMode::A1 { if self.mode == LexerMode::A1 {
@@ -660,8 +660,8 @@ impl Lexer {
fn consume_error(&mut self) -> TokenType { fn consume_error(&mut self) -> TokenType {
let errors = &self.language.errors; let errors = &self.language.errors;
let rest_of_formula: String = self.chars[self.position - 1..self.len].iter().collect(); let rest_of_formula: String = self.chars[self.position - 1..self.len].iter().collect();
if rest_of_formula.starts_with(&errors.ref_value) { if rest_of_formula.starts_with(&errors.r#ref) {
self.position += errors.ref_value.chars().count() - 1; self.position += errors.r#ref.chars().count() - 1;
return TokenType::Error(Error::REF); return TokenType::Error(Error::REF);
} else if rest_of_formula.starts_with(&errors.name) { } else if rest_of_formula.starts_with(&errors.name) {
self.position += errors.name.chars().count() - 1; self.position += errors.name.chars().count() - 1;

View File

@@ -6,11 +6,11 @@ use crate::{
token::TokenType, token::TokenType,
}, },
language::get_language, language::get_language,
locale::get_locale_fix, locale::get_locale,
}; };
fn new_language_lexer(formula: &str, locale: &str, language: &str) -> Lexer { fn new_language_lexer(formula: &str, locale: &str, language: &str) -> Lexer {
let locale = get_locale_fix(locale).unwrap(); let locale = get_locale(locale).unwrap();
let language = get_language(language).unwrap(); let language = get_language(language).unwrap();
Lexer::new(formula, LexerMode::A1, locale, language) Lexer::new(formula, LexerMode::A1, locale, language)
} }

View File

@@ -222,7 +222,7 @@ impl Parser {
pub fn parse(&mut self, formula: &str, context: &Option<CellReferenceRC>) -> Node { pub fn parse(&mut self, formula: &str, context: &Option<CellReferenceRC>) -> Node {
self.lexer.set_formula(formula); self.lexer.set_formula(formula);
self.context = context.clone(); self.context.clone_from(context);
self.parse_expr() self.parse_expr()
} }

View File

@@ -2,7 +2,6 @@ use std::fmt;
use bitcode::{Decode, Encode}; use bitcode::{Decode, Encode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use crate::language::Language; use crate::language::Language;
@@ -81,8 +80,7 @@ impl fmt::Display for OpProduct {
/// * "#ERROR!" means there was an error processing the formula (for instance "=A1+") /// * "#ERROR!" means there was an error processing the formula (for instance "=A1+")
/// * "#N/IMPL!" means the formula or feature in Excel but has not been implemented in IronCalc /// * "#N/IMPL!" means the formula or feature in Excel but has not been implemented in IronCalc
/// Note that they are serialized/deserialized by index /// Note that they are serialized/deserialized by index
#[derive(Serialize_repr, Deserialize_repr, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
#[repr(u8)]
pub enum Error { pub enum Error {
REF, REF,
NAME, NAME,
@@ -120,7 +118,7 @@ impl Error {
pub fn to_localized_error_string(&self, language: &Language) -> String { pub fn to_localized_error_string(&self, language: &Language) -> String {
match self { match self {
Error::NULL => language.errors.null.to_string(), Error::NULL => language.errors.null.to_string(),
Error::REF => language.errors.ref_value.to_string(), Error::REF => language.errors.r#ref.to_string(),
Error::NAME => language.errors.name.to_string(), Error::NAME => language.errors.name.to_string(),
Error::VALUE => language.errors.value.to_string(), Error::VALUE => language.errors.value.to_string(),
Error::DIV => language.errors.div.to_string(), Error::DIV => language.errors.div.to_string(),
@@ -137,7 +135,7 @@ impl Error {
pub fn get_error_by_name(name: &str, language: &Language) -> Option<Error> { pub fn get_error_by_name(name: &str, language: &Language) -> Option<Error> {
let errors = &language.errors; let errors = &language.errors;
if name == errors.ref_value { if name == errors.r#ref {
return Some(Error::REF); return Some(Error::REF);
} else if name == errors.name { } else if name == errors.name {
return Some(Error::NAME); return Some(Error::NAME);

View File

@@ -0,0 +1 @@
PfrendeesD<>VRAITRUEWAHRVERDADEROTVFAUXFALSEFALSCHFALSOUw#REF!#REF!#BEZUG!#¡REF!e<>#NOM?#NAME?#NAME?#¿NOMBRE?x<>#VALEUR!#VALUE!#WERT!#¡VALOR!w<>#DIV/0!#DIV/0!#DIV/0!#¡DIV/0!<04>#N/A#N/A#NV#N/AXv#NOMBRE!#NUM!#ZAHL!#¡NUM!<02><>#N/IMPL!#N/IMPL!#N/IMPL!#N/IMPL!w{#SPILL!#SPILL!#ÜBERLAUF!#SPILL!ff#CALC!#CALC!#CALC!#CALC!ff#CIRC!#CIRC!#CIRC!#CIRC!ww#ERROR!#ERROR!#ERROR!#ERROR!ff#NULL!#NULL!#NULL!#NULL!

View File

@@ -1,20 +1,17 @@
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone)] use bitcode::{Decode, Encode};
use once_cell::sync::Lazy;
#[derive(Encode, Decode, Clone)]
pub struct Booleans { pub struct Booleans {
#[serde(rename = "true")] pub r#true: String,
pub true_value: String, pub r#false: String,
#[serde(rename = "false")]
pub false_value: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
pub struct Errors { pub struct Errors {
#[serde(rename = "ref")] pub r#ref: String,
pub ref_value: String,
pub name: String, pub name: String,
pub value: String, pub value: String,
pub div: String, pub div: String,
@@ -28,14 +25,14 @@ pub struct Errors {
pub null: String, pub null: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
pub struct Language { pub struct Language {
pub booleans: Booleans, pub booleans: Booleans,
pub errors: Errors, pub errors: Errors,
} }
static LANGUAGES: Lazy<HashMap<String, Language>> = Lazy::new(|| { static LANGUAGES: Lazy<HashMap<String, Language>> = Lazy::new(|| {
serde_json::from_str(include_str!("language.json")).expect("Failed parsing language file") bitcode::decode(include_bytes!("language.bin")).expect("Failed parsing language file")
}); });
pub fn get_language(id: &str) -> Result<&Language, String> { pub fn get_language(id: &str) -> Result<&Language, String> {

View File

@@ -58,3 +58,4 @@ 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 user_model::UserModel; pub use user_model::UserModel;
pub use user_model::BorderArea;

BIN
base/src/locale/locales.bin Normal file

Binary file not shown.

View File

@@ -1,32 +1,29 @@
use bitcode::{Decode, Encode};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
pub struct Locale { pub struct Locale {
pub dates: Dates, pub dates: Dates,
pub numbers: NumbersProperties, pub numbers: NumbersProperties,
pub currency: Currency, pub currency: Currency,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
pub struct Currency { pub struct Currency {
pub iso: String, pub iso: String,
pub symbol: String, pub symbol: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
pub struct NumbersProperties { pub struct NumbersProperties {
#[serde(rename = "symbols-numberSystem-latn")]
pub symbols: NumbersSymbols, pub symbols: NumbersSymbols,
#[serde(rename = "decimalFormats-numberSystem-latn")]
pub decimal_formats: DecimalFormats, pub decimal_formats: DecimalFormats,
#[serde(rename = "currencyFormats-numberSystem-latn")]
pub currency_formats: CurrencyFormats, pub currency_formats: CurrencyFormats,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
pub struct Dates { pub struct Dates {
pub day_names: Vec<String>, pub day_names: Vec<String>,
pub day_names_short: Vec<String>, pub day_names_short: Vec<String>,
@@ -35,8 +32,7 @@ pub struct Dates {
pub months_letter: Vec<String>, pub months_letter: Vec<String>,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NumbersSymbols { pub struct NumbersSymbols {
pub decimal: String, pub decimal: String,
pub group: String, pub group: String,
@@ -54,40 +50,26 @@ pub struct NumbersSymbols {
} }
// See: https://cldr.unicode.org/translation/number-currency-formats/number-and-currency-patterns // See: https://cldr.unicode.org/translation/number-currency-formats/number-and-currency-patterns
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
pub struct CurrencyFormats { pub struct CurrencyFormats {
pub standard: String, pub standard: String,
#[serde(rename = "standard-alphaNextToNumber")]
#[serde(skip_serializing_if = "Option::is_none")]
pub standard_alpha_next_to_number: Option<String>, pub standard_alpha_next_to_number: Option<String>,
#[serde(rename = "standard-noCurrency")]
pub standard_no_currency: String, pub standard_no_currency: String,
pub accounting: String, pub accounting: String,
#[serde(rename = "accounting-alphaNextToNumber")]
#[serde(skip_serializing_if = "Option::is_none")]
pub accounting_alpha_next_to_number: Option<String>, pub accounting_alpha_next_to_number: Option<String>,
#[serde(rename = "accounting-noCurrency")]
pub accounting_no_currency: String, pub accounting_no_currency: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Encode, Decode, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DecimalFormats { pub struct DecimalFormats {
pub standard: String, pub standard: String,
} }
static LOCALES: Lazy<HashMap<String, Locale>> = Lazy::new(|| { static LOCALES: Lazy<HashMap<String, Locale>> =
serde_json::from_str(include_str!("locales.json")).expect("Failed parsing locale") Lazy::new(|| bitcode::decode(include_bytes!("locales.bin")).expect("Failed parsing locale"));
});
pub fn get_locale(_id: &str) -> Result<&Locale, String> { pub fn get_locale(id: &str) -> Result<&Locale, String> {
// TODO: pass the locale once we implement locales in Rust // TODO: pass the locale once we implement locales in Rust
let locale = LOCALES.get("en").ok_or("Invalid locale")?;
Ok(locale)
}
// TODO: Remove this function one we implement locales properly
pub fn get_locale_fix(id: &str) -> Result<&Locale, String> {
let locale = LOCALES.get(id).ok_or("Invalid locale")?; let locale = LOCALES.get(id).ok_or("Invalid locale")?;
Ok(locale) Ok(locale)
} }

View File

@@ -118,6 +118,8 @@ pub struct Model {
pub(crate) language: Language, pub(crate) language: Language,
/// The timezone used to evaluate the model /// The timezone used to evaluate the model
pub(crate) tz: Tz, pub(crate) tz: Tz,
/// The view id. A view consist of a selected sheet and ranges.
pub(crate) view_id: u32,
} }
// FIXME: Maybe this should be the same as CellReference // FIXME: Maybe this should be the same as CellReference
@@ -681,6 +683,13 @@ impl Model {
Err(format!("Invalid color: {}", color)) Err(format!("Invalid color: {}", color))
} }
/// Makes the grid lines in the sheet visible (`true`) or hidden (`false`)
pub fn set_show_grid_lines(&mut self, sheet: u32, show_grid_lines: bool) -> Result<(), String> {
let worksheet = self.workbook.worksheet_mut(sheet)?;
worksheet.show_grid_lines = show_grid_lines;
Ok(())
}
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 {
@@ -798,7 +807,7 @@ impl Model {
None None
} }
/// Returns a model from a String representation of a workbook /// Returns a model from an internal binary representation of a workbook
/// ///
/// # Examples /// # Examples
/// ///
@@ -816,9 +825,12 @@ impl Model {
/// # Ok(()) /// # Ok(())
/// # } /// # }
/// ``` /// ```
///
/// See also:
/// * [Model::to_bytes]
pub fn from_bytes(s: &[u8]) -> Result<Model, String> { pub fn from_bytes(s: &[u8]) -> Result<Model, String> {
let workbook: Workbook = let workbook: Workbook =
bitcode::decode(s).map_err(|_| "Error parsing workbook".to_string())?; bitcode::decode(s).map_err(|e| format!("Error parsing workbook: {e}"))?;
Model::from_workbook(workbook) Model::from_workbook(workbook)
} }
@@ -883,6 +895,7 @@ impl Model {
language, language,
locale, locale,
tz, tz,
view_id: 0,
}; };
model.parse_formulas(); model.parse_formulas();
@@ -1760,7 +1773,10 @@ impl Model {
.get_style(self.get_cell_style_index(sheet, row, column)) .get_style(self.get_cell_style_index(sheet, row, column))
} }
/// Returns a JSON string of the workbook /// Returns an internal binary representation of the workbook
///
/// See also:
/// * [Model::from_bytes]
pub fn to_bytes(&self) -> Vec<u8> { pub fn to_bytes(&self) -> Vec<u8> {
bitcode::encode(&self.workbook) bitcode::encode(&self.workbook)
} }

View File

@@ -6,14 +6,18 @@ use crate::{
calc_result::Range, calc_result::Range,
expressions::{ expressions::{
lexer::LexerMode, lexer::LexerMode,
parser::stringify::{rename_sheet_in_node, to_rc_format}, parser::{
parser::Parser, stringify::{rename_sheet_in_node, to_rc_format},
Parser,
},
types::CellReferenceRC, types::CellReferenceRC,
}, },
language::get_language, language::get_language,
locale::get_locale, locale::get_locale,
model::{get_milliseconds_since_epoch, Model, ParsedDefinedName}, model::{get_milliseconds_since_epoch, Model, ParsedDefinedName},
types::{Metadata, SheetState, Workbook, WorkbookSettings, Worksheet}, types::{
Metadata, SheetState, Workbook, WorkbookSettings, WorkbookView, Worksheet, WorksheetView,
},
utils::ParsedReference, utils::ParsedReference,
}; };
@@ -33,7 +37,20 @@ fn is_valid_sheet_name(name: &str) -> bool {
impl Model { impl Model {
/// Creates a new worksheet. Note that it does not check if the name or the sheet_id exists /// Creates a new worksheet. Note that it does not check if the name or the sheet_id exists
fn new_empty_worksheet(name: &str, sheet_id: u32) -> Worksheet { fn new_empty_worksheet(name: &str, sheet_id: u32, view_ids: &[&u32]) -> Worksheet {
let mut views = HashMap::new();
for id in view_ids {
views.insert(
**id,
WorksheetView {
row: 1,
column: 1,
range: [1, 1, 1, 1],
top_row: 1,
left_column: 1,
},
);
}
Worksheet { Worksheet {
cols: vec![], cols: vec![],
rows: vec![], rows: vec![],
@@ -48,6 +65,8 @@ impl Model {
color: Default::default(), color: Default::default(),
frozen_columns: 0, frozen_columns: 0,
frozen_rows: 0, frozen_rows: 0,
show_grid_lines: true,
views,
} }
} }
@@ -122,7 +141,7 @@ impl Model {
self.parsed_defined_names = parsed_defined_names; self.parsed_defined_names = parsed_defined_names;
} }
// Reparses all formulas and defined names /// Reparses all formulas and defined names
pub(crate) fn reset_parsed_structures(&mut self) { pub(crate) fn reset_parsed_structures(&mut self) {
self.parser self.parser
.set_worksheets(self.workbook.get_worksheet_names()); .set_worksheets(self.workbook.get_worksheet_names());
@@ -153,7 +172,8 @@ impl Model {
let sheet_name = format!("{}{}", base_name, index); let sheet_name = format!("{}{}", base_name, index);
// Now we need a sheet_id // Now we need a sheet_id
let sheet_id = self.get_new_sheet_id(); let sheet_id = self.get_new_sheet_id();
let worksheet = Model::new_empty_worksheet(&sheet_name, sheet_id); let view_ids: Vec<&u32> = self.workbook.views.keys().collect();
let worksheet = Model::new_empty_worksheet(&sheet_name, sheet_id, &view_ids);
self.workbook.worksheets.push(worksheet); self.workbook.worksheets.push(worksheet);
self.reset_parsed_structures(); self.reset_parsed_structures();
(sheet_name, self.workbook.worksheets.len() as u32 - 1) (sheet_name, self.workbook.worksheets.len() as u32 - 1)
@@ -184,7 +204,8 @@ impl Model {
Some(id) => id, Some(id) => id,
None => self.get_new_sheet_id(), None => self.get_new_sheet_id(),
}; };
let worksheet = Model::new_empty_worksheet(sheet_name, sheet_id); let view_ids: Vec<&u32> = self.workbook.views.keys().collect();
let worksheet = Model::new_empty_worksheet(sheet_name, sheet_id, &view_ids);
if sheet_index as usize > self.workbook.worksheets.len() { if sheet_index as usize > self.workbook.worksheets.len() {
return Err("Sheet index out of range".to_string()); return Err("Sheet index out of range".to_string());
} }
@@ -331,11 +352,21 @@ impl Model {
// "2020-08-06T21:20:53Z // "2020-08-06T21:20:53Z
let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(); let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut views = HashMap::new();
views.insert(
0,
WorkbookView {
sheet: 0,
window_width: 800,
window_height: 600,
},
);
// String versions of the locale are added here to simplify the serialize/deserialize logic // String versions of the locale are added here to simplify the serialize/deserialize logic
let workbook = Workbook { let workbook = Workbook {
shared_strings: vec![], shared_strings: vec![],
defined_names: vec![], defined_names: vec![],
worksheets: vec![Model::new_empty_worksheet("Sheet1", 1)], worksheets: vec![Model::new_empty_worksheet("Sheet1", 1, &[&0])],
styles: Default::default(), styles: Default::default(),
name: name.to_string(), name: name.to_string(),
settings: WorkbookSettings { settings: WorkbookSettings {
@@ -351,6 +382,7 @@ impl Model {
last_modified: now, last_modified: now,
}, },
tables: HashMap::new(), tables: HashMap::new(),
views,
}; };
let parsed_formulas = Vec::new(); let parsed_formulas = Vec::new();
let worksheets = &workbook.worksheets; let worksheets = &workbook.worksheets;
@@ -371,6 +403,7 @@ impl Model {
locale, locale,
language, language,
tz, tz,
view_id: 0,
}; };
model.parse_formulas(); model.parse_formulas();
Ok(model) Ok(model)

View File

@@ -76,10 +76,16 @@ fn fn_imconjugate() {
fn fn_imcos() { fn fn_imcos() {
let mut model = new_empty_model(); let mut model = new_empty_model();
model._set("A1", r#"=IMCOS("4+3i")"#); model._set("A1", r#"=IMCOS("4+3i")"#);
// In macos non intel this is "-6.58066304055116+7.58155274274655i"
model._set("A2", r#"=COMPLEX(-6.58066304055116, 7.58155274274654)"#);
model._set("A3", r#"=IMABS(IMSUB(A1, A2)) < G1"#);
// small number
model._set("G1", "0.0000001");
model.evaluate(); model.evaluate();
assert_eq!(model._get_text("A1"), "-6.58066304055116+7.58155274274654i"); assert_eq!(model._get_text("A3"), "TRUE");
} }
#[test] #[test]

View File

@@ -53,4 +53,5 @@ mod test_frozen_rows_and_columns;
mod test_get_cell_content; mod test_get_cell_content;
mod test_percentage; mod test_percentage;
mod test_today; mod test_today;
mod test_types;
mod user_model; mod user_model;

View File

@@ -0,0 +1,24 @@
#![allow(clippy::unwrap_used)]
use crate::types::{Alignment, HorizontalAlignment, VerticalAlignment};
#[test]
fn alignment_default() {
let alignment = Alignment::default();
assert_eq!(
alignment,
Alignment {
horizontal: HorizontalAlignment::General,
vertical: VerticalAlignment::Bottom,
wrap_text: false
}
);
let s = serde_json::to_string(&alignment).unwrap();
// defaults stringifies as an empty object
assert_eq!(s, "{}");
let a: Alignment = serde_json::from_str("{}").unwrap();
assert_eq!(a, alignment)
}

View File

@@ -1,10 +1,14 @@
mod test_add_delete_sheets; mod test_add_delete_sheets;
mod test_autofill_columns;
mod test_autofill_rows;
mod test_clear_cells; mod test_clear_cells;
mod test_diff_queue; mod test_diff_queue;
mod test_evaluation; mod test_evaluation;
mod test_general; mod test_general;
mod test_grid_lines;
mod test_rename_sheet; mod test_rename_sheet;
mod test_row_column; mod test_row_column;
mod test_styles; mod test_styles;
mod test_to_from_bytes; mod test_to_from_bytes;
mod test_undo_redo; mod test_undo_redo;
mod test_view;

View File

@@ -0,0 +1,404 @@
#![allow(clippy::unwrap_used)]
use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::types::Area;
use crate::test::util::new_empty_model;
use crate::UserModel;
#[test]
fn basic_tests() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// This is cell A3
model.set_user_input(0, 3, 1, "alpha").unwrap();
// We autofill from A3 to C3
model
.auto_fill_columns(
&Area {
sheet: 0,
row: 3,
column: 1,
width: 1,
height: 1,
},
5,
)
.unwrap();
// B3
assert_eq!(
model.get_formatted_cell_value(0, 3, 2),
Ok("alpha".to_string())
);
// C3
assert_eq!(
model.get_formatted_cell_value(0, 3, 3),
Ok("alpha".to_string())
);
}
#[test]
fn one_cell_right() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_user_input(0, 1, 1, "23").unwrap();
model
.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
},
2,
)
.unwrap();
// B1
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("23".to_string())
);
}
#[test]
fn alpha_beta_gamma() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A1:B3
model.set_user_input(0, 1, 1, "Alpher").unwrap(); // A1
model.set_user_input(0, 1, 2, "Bethe").unwrap(); // B1
model.set_user_input(0, 1, 3, "Gamow").unwrap(); // C1
model.set_user_input(0, 2, 1, "=A1").unwrap(); // A2
model.set_user_input(0, 2, 2, "=B1").unwrap(); // B2
model.set_user_input(0, 2, 3, "=C1").unwrap(); // C2
// We autofill from A1:C2 to I2
model
.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 3,
height: 2,
},
9,
)
.unwrap();
// D1
assert_eq!(
model.get_formatted_cell_value(0, 1, 4),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 5),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 6),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 7),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 8),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 9),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 2, 4),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 2, 5),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 2, 6),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 2, 7),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 2, 8),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 2, 9),
Ok("Gamow".to_string())
);
assert_eq!(model.get_cell_content(0, 2, 4), Ok("=D1".to_string()));
}
#[test]
fn styles() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A1:C1
model.set_user_input(0, 1, 1, "Alpher").unwrap();
model.set_user_input(0, 2, 1, "Bethe").unwrap();
model.set_user_input(0, 3, 1, "Gamow").unwrap();
let b1 = Area {
sheet: 0,
row: 1,
column: 2,
width: 1,
height: 1,
};
let c1 = Area {
sheet: 0,
row: 1,
column: 3,
width: 1,
height: 1,
};
model.update_range_style(&b1, "font.i", "true").unwrap();
model
.update_range_style(&c1, "fill.bg_color", "#334455")
.unwrap();
model
.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 3,
height: 1,
},
9,
)
.unwrap();
// Check that cell E1 has B1 style
let style = model.get_cell_style(0, 1, 5).unwrap();
assert!(style.font.i);
// A6 would have the style of A3
let style = model.get_cell_style(0, 1, 6).unwrap();
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
model.undo().unwrap();
assert_eq!(model.get_cell_content(0, 1, 4), Ok("".to_string()));
// Check that cell A5 has A2 style
let style = model.get_cell_style(0, 1, 5).unwrap();
assert!(!style.font.i);
// A6 would have the style of A3
let style = model.get_cell_style(0, 1, 6).unwrap();
assert_eq!(style.fill.bg_color, None);
model.redo().unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 4),
Ok("Alpher".to_string())
);
// Check that cell A5 has A2 style
let style = model.get_cell_style(0, 1, 5).unwrap();
assert!(style.font.i);
// A6 would have the style of A3
let style = model.get_cell_style(0, 1, 6).unwrap();
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
}
#[test]
fn left() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A10:A12
model.set_user_input(0, 1, 10, "Alpher").unwrap();
model.set_user_input(0, 1, 11, "Bethe").unwrap();
model.set_user_input(0, 1, 12, "Gamow").unwrap();
// We fill upwards to row 5
model
.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: 10,
width: 3,
height: 1,
},
5,
)
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 9),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 8),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 7),
Ok("Alpher".to_string())
);
}
#[test]
fn left_4() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A10:A13
model.set_user_input(0, 1, 10, "Margaret Burbidge").unwrap();
model.set_user_input(0, 1, 11, "Geoffrey Burbidge").unwrap();
model.set_user_input(0, 1, 12, "Willy Fowler").unwrap();
model.set_user_input(0, 1, 13, "Fred Hoyle").unwrap();
// We fill left to row 5
model
.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: 10,
width: 4,
height: 1,
},
5,
)
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 9),
Ok("Fred Hoyle".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 8),
Ok("Willy Fowler".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 1, 5),
Ok("Fred Hoyle".to_string())
);
}
#[test]
fn errors() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_user_input(0, 1, 4, "Margaret Burbidge").unwrap();
// Invalid sheet
assert_eq!(
model.auto_fill_columns(
&Area {
sheet: 3,
row: 1,
column: 4,
width: 1,
height: 1,
},
10,
),
Err("Invalid worksheet index: '3'".to_string())
);
// invalid column
assert_eq!(
model.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: -1,
width: 1,
height: 1,
},
10,
),
Err("Invalid column: '-1'".to_string())
);
// invalid column
assert_eq!(
model.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: LAST_COLUMN - 1,
width: 10,
height: 1,
},
10,
),
Err("Invalid column: '16392'".to_string())
);
assert_eq!(
model.auto_fill_columns(
&Area {
sheet: 0,
row: LAST_ROW + 1,
column: 1,
width: 10,
height: 1,
},
10,
),
Err("Invalid row: '1048577'".to_string())
);
assert_eq!(
model.auto_fill_columns(
&Area {
sheet: 0,
row: LAST_ROW - 2,
column: 1,
width: 1,
height: 10,
},
10,
),
Err("Invalid row: '1048583'".to_string())
);
assert_eq!(
model.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: 5,
width: 10,
height: 1,
},
-10,
),
Err("Invalid row: '-10'".to_string())
);
}
#[test]
fn invalid_parameters() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_user_input(0, 1, 1, "23").unwrap();
assert_eq!(
model.auto_fill_columns(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 2,
height: 1,
},
2,
),
Err("Invalid parameters for autofill".to_string())
);
}

View File

@@ -0,0 +1,399 @@
#![allow(clippy::unwrap_used)]
use crate::constants::{LAST_COLUMN, LAST_ROW};
use crate::expressions::types::Area;
use crate::test::util::new_empty_model;
use crate::UserModel;
#[test]
fn basic_tests() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// This is cell A3
model.set_user_input(0, 3, 1, "alpha").unwrap();
// We autofill from A3 to A5
model
.auto_fill_rows(
&Area {
sheet: 0,
row: 3,
column: 1,
width: 1,
height: 1,
},
5,
)
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 4, 1),
Ok("alpha".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 5, 1),
Ok("alpha".to_string())
);
}
#[test]
fn one_cell_down() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_user_input(0, 1, 1, "23").unwrap();
model
.auto_fill_rows(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
},
2,
)
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 2, 1),
Ok("23".to_string())
);
}
#[test]
fn alpha_beta_gamma() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A1:B3
model.set_user_input(0, 1, 1, "Alpher").unwrap();
model.set_user_input(0, 2, 1, "Bethe").unwrap();
model.set_user_input(0, 3, 1, "Gamow").unwrap();
model.set_user_input(0, 1, 2, "=A1").unwrap();
model.set_user_input(0, 2, 2, "=A2").unwrap();
model.set_user_input(0, 3, 2, "=A3").unwrap();
// We autofill from A1:B3 to A9
model
.auto_fill_rows(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 2,
height: 3,
},
9,
)
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 4, 1),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 5, 1),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 6, 1),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 7, 1),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 8, 1),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 9, 1),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 4, 2),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 5, 2),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 6, 2),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 7, 2),
Ok("Alpher".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 8, 2),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 9, 2),
Ok("Gamow".to_string())
);
assert_eq!(model.get_cell_content(0, 4, 2), Ok("=A4".to_string()));
}
#[test]
fn styles() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A1:B3
model.set_user_input(0, 1, 1, "Alpher").unwrap();
model.set_user_input(0, 2, 1, "Bethe").unwrap();
model.set_user_input(0, 3, 1, "Gamow").unwrap();
let a2 = Area {
sheet: 0,
row: 2,
column: 1,
width: 1,
height: 1,
};
let a3 = Area {
sheet: 0,
row: 3,
column: 1,
width: 1,
height: 1,
};
model.update_range_style(&a2, "font.i", "true").unwrap();
model
.update_range_style(&a3, "fill.bg_color", "#334455")
.unwrap();
model
.auto_fill_rows(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 3,
},
9,
)
.unwrap();
// Check that cell A5 has A2 style
let style = model.get_cell_style(0, 5, 1).unwrap();
assert!(style.font.i);
// A6 would have the style of A3
let style = model.get_cell_style(0, 6, 1).unwrap();
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
model.undo().unwrap();
assert_eq!(model.get_cell_content(0, 4, 1), Ok("".to_string()));
// Check that cell A5 has A2 style
let style = model.get_cell_style(0, 5, 1).unwrap();
assert!(!style.font.i);
// A6 would have the style of A3
let style = model.get_cell_style(0, 6, 1).unwrap();
assert_eq!(style.fill.bg_color, None);
model.redo().unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 4, 1),
Ok("Alpher".to_string())
);
// Check that cell A5 has A2 style
let style = model.get_cell_style(0, 5, 1).unwrap();
assert!(style.font.i);
// A6 would have the style of A3
let style = model.get_cell_style(0, 6, 1).unwrap();
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
}
#[test]
fn upwards() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A10:A12
model.set_user_input(0, 10, 1, "Alpher").unwrap();
model.set_user_input(0, 11, 1, "Bethe").unwrap();
model.set_user_input(0, 12, 1, "Gamow").unwrap();
// We fill upwards to row 5
model
.auto_fill_rows(
&Area {
sheet: 0,
row: 10,
column: 1,
width: 1,
height: 3,
},
5,
)
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 9, 1),
Ok("Gamow".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 8, 1),
Ok("Bethe".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 7, 1),
Ok("Alpher".to_string())
);
}
#[test]
fn upwards_4() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A10:A13
model.set_user_input(0, 10, 1, "Margaret Burbidge").unwrap();
model.set_user_input(0, 11, 1, "Geoffrey Burbidge").unwrap();
model.set_user_input(0, 12, 1, "Willy Fowler").unwrap();
model.set_user_input(0, 13, 1, "Fred Hoyle").unwrap();
// We fill upwards to row 5
model
.auto_fill_rows(
&Area {
sheet: 0,
row: 10,
column: 1,
width: 1,
height: 4,
},
5,
)
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 9, 1),
Ok("Fred Hoyle".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 8, 1),
Ok("Willy Fowler".to_string())
);
assert_eq!(
model.get_formatted_cell_value(0, 5, 1),
Ok("Fred Hoyle".to_string())
);
}
#[test]
fn errors() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// cells A10:A13
model.set_user_input(0, 4, 1, "Margaret Burbidge").unwrap();
// Invalid sheet
assert_eq!(
model.auto_fill_rows(
&Area {
sheet: 3,
row: 4,
column: 1,
width: 1,
height: 1,
},
10,
),
Err("Invalid worksheet index: '3'".to_string())
);
// invalid row
assert_eq!(
model.auto_fill_rows(
&Area {
sheet: 0,
row: -1,
column: 1,
width: 1,
height: 1,
},
10,
),
Err("Invalid row: '-1'".to_string())
);
// invalid row
assert_eq!(
model.auto_fill_rows(
&Area {
sheet: 0,
row: LAST_ROW - 1,
column: 1,
width: 1,
height: 10,
},
10,
),
Err("Invalid row: '1048584'".to_string())
);
assert_eq!(
model.auto_fill_rows(
&Area {
sheet: 0,
row: 1,
column: LAST_COLUMN + 1,
width: 1,
height: 10,
},
10,
),
Err("Invalid column: '16385'".to_string())
);
assert_eq!(
model.auto_fill_rows(
&Area {
sheet: 0,
row: 1,
column: LAST_COLUMN - 2,
width: 10,
height: 1,
},
10,
),
Err("Invalid column: '16391'".to_string())
);
assert_eq!(
model.auto_fill_rows(
&Area {
sheet: 0,
row: 5,
column: 1,
width: 1,
height: 10,
},
-10,
),
Err("Invalid row: '-10'".to_string())
);
}
#[test]
fn invalid_parameters() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_user_input(0, 1, 1, "23").unwrap();
assert_eq!(
model.auto_fill_rows(
&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 2,
},
2,
),
Err("Invalid parameters for autofill".to_string())
);
}

View File

@@ -25,7 +25,7 @@ fn send_queue() {
#[test] #[test]
fn apply_external_diffs_wrong_str() { fn apply_external_diffs_wrong_str() {
let mut model1 = UserModel::from_model(new_empty_model()); let mut model1 = UserModel::from_model(new_empty_model());
assert!(model1.apply_external_diffs("invalid").is_err()); assert!(model1.apply_external_diffs("invalid".as_bytes()).is_err());
} }
#[test] #[test]
@@ -155,5 +155,7 @@ fn new_sheet() {
#[test] #[test]
fn wrong_diffs_handled() { fn wrong_diffs_handled() {
let mut model = UserModel::from_model(new_empty_model()); let mut model = UserModel::from_model(new_empty_model());
assert!(model.apply_external_diffs("Hello world").is_err()); assert!(model
.apply_external_diffs("Hello world".as_bytes())
.is_err());
} }

View File

@@ -0,0 +1,42 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
use crate::UserModel;
#[test]
fn basic_tests() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.new_sheet();
// default sheet has show_grid_lines = true
assert_eq!(model.get_show_grid_lines(0), Ok(true));
// default new sheet has show_grid_lines = true
assert_eq!(model.get_show_grid_lines(1), Ok(true));
// wrong sheet number
assert_eq!(
model.get_show_grid_lines(2),
Err("Invalid sheet index".to_string())
);
// we can set it
model.set_show_grid_lines(1, false).unwrap();
assert_eq!(model.get_show_grid_lines(1), Ok(false));
assert_eq!(model.get_show_grid_lines(0), Ok(true));
model.undo().unwrap();
assert_eq!(model.get_show_grid_lines(1), Ok(true));
assert_eq!(model.get_show_grid_lines(0), Ok(true));
model.redo().unwrap();
let send_queue = model.flush_send_queue();
let mut model2 = UserModel::from_model(new_empty_model());
model2.apply_external_diffs(&send_queue).unwrap();
assert_eq!(model2.get_show_grid_lines(1), Ok(false));
assert_eq!(model2.get_show_grid_lines(0), Ok(true));
}

View File

@@ -144,13 +144,18 @@ fn basic_fill() {
let style = model.get_cell_style(0, 1, 1).unwrap(); let style = model.get_cell_style(0, 1, 1).unwrap();
assert_eq!(style.fill.bg_color, None); assert_eq!(style.fill.bg_color, None);
assert_eq!(style.fill.fg_color, None);
// bg_color // bg_color
model model
.update_range_style(&range, "fill.bg_color", "#F2F2F2") .update_range_style(&range, "fill.bg_color", "#F2F2F2")
.unwrap(); .unwrap();
model
.update_range_style(&range, "fill.fg_color", "#F3F4F5")
.unwrap();
let style = model.get_cell_style(0, 1, 1).unwrap(); let style = model.get_cell_style(0, 1, 1).unwrap();
assert_eq!(style.fill.bg_color, Some("#F2F2F2".to_owned())); assert_eq!(style.fill.bg_color, Some("#F2F2F2".to_owned()));
assert_eq!(style.fill.fg_color, Some("#F3F4F5".to_owned()));
let send_queue = model.flush_send_queue(); let send_queue = model.flush_send_queue();
@@ -159,6 +164,7 @@ fn basic_fill() {
let style = model2.get_cell_style(0, 1, 1).unwrap(); let style = model2.get_cell_style(0, 1, 1).unwrap();
assert_eq!(style.fill.bg_color, Some("#F2F2F2".to_owned())); assert_eq!(style.fill.bg_color, Some("#F2F2F2".to_owned()));
assert_eq!(style.fill.fg_color, Some("#F3F4F5".to_owned()));
} }
#[test] #[test]
@@ -171,9 +177,15 @@ fn fill_errors() {
width: 1, width: 1,
height: 1, height: 1,
}; };
assert!(model assert_eq!(
.update_range_style(&range, "fill.bg_color", "#FFF") model.update_range_style(&range, "fill.bg_color", "#FFF"),
.is_err()); Err("Invalid color: '#FFF'.".to_string())
);
assert_eq!(
model.update_range_style(&range, "fill.fg_color", "#FFF"),
Err("Invalid color: '#FFF'.".to_string())
);
} }
#[test] #[test]

View File

@@ -25,6 +25,6 @@ fn errors() {
let model_bytes = "Early in the morning, late in the century, Cricklewood Broadway.".as_bytes(); let model_bytes = "Early in the morning, late in the century, Cricklewood Broadway.".as_bytes();
assert_eq!( assert_eq!(
&UserModel::from_bytes(model_bytes).unwrap_err(), &UserModel::from_bytes(model_bytes).unwrap_err(),
"Error parsing workbook" "Error parsing workbook: invalid packing"
); );
} }

View File

@@ -0,0 +1,216 @@
#![allow(clippy::unwrap_used)]
use std::collections::HashMap;
use crate::{
constants::{LAST_COLUMN, LAST_ROW},
test::util::new_empty_model,
user_model::SelectedView,
UserModel,
};
#[test]
fn initial_view() {
let model = new_empty_model();
let model = UserModel::from_model(model);
assert_eq!(model.get_selected_sheet(), 0);
assert_eq!(model.get_selected_cell(), (0, 1, 1));
assert_eq!(
model.get_selected_view(),
SelectedView {
sheet: 0,
row: 1,
column: 1,
range: [1, 1, 1, 1],
top_row: 1,
left_column: 1
}
);
}
#[test]
fn set_the_cell_sets_the_range() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_selected_cell(5, 4).unwrap();
assert_eq!(model.get_selected_sheet(), 0);
assert_eq!(model.get_selected_cell(), (0, 5, 4));
assert_eq!(
model.get_selected_view(),
SelectedView {
sheet: 0,
row: 5,
column: 4,
range: [5, 4, 5, 4],
top_row: 1,
left_column: 1
}
);
}
#[test]
fn set_the_range_does_not_set_the_cell() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_selected_range(5, 4, 10, 6).unwrap();
assert_eq!(model.get_selected_sheet(), 0);
assert_eq!(model.get_selected_cell(), (0, 1, 1));
assert_eq!(
model.get_selected_view(),
SelectedView {
sheet: 0,
row: 1,
column: 1,
range: [5, 4, 10, 6],
top_row: 1,
left_column: 1
}
);
}
#[test]
fn add_new_sheet_and_back() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.new_sheet();
assert_eq!(model.get_selected_sheet(), 0);
model.set_selected_cell(5, 4).unwrap();
model.set_selected_sheet(1).unwrap();
assert_eq!(model.get_selected_cell(), (1, 1, 1));
model.set_selected_sheet(0).unwrap();
assert_eq!(model.get_selected_cell(), (0, 5, 4));
}
#[test]
fn set_selected_cell_errors() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
assert_eq!(
model.set_selected_cell(-5, 4),
Err("Invalid row: '-5'".to_string())
);
assert_eq!(
model.set_selected_cell(5, -4),
Err("Invalid column: '-4'".to_string())
);
assert_eq!(
model.set_selected_range(-1, 1, 1, 1),
Err("Invalid row: '-1'".to_string())
);
assert_eq!(
model.set_selected_range(1, 0, 1, 1),
Err("Invalid column: '0'".to_string())
);
assert_eq!(
model.set_selected_range(1, 1, LAST_ROW + 1, 1),
Err("Invalid row: '1048577'".to_string())
);
assert_eq!(
model.set_selected_range(1, 1, 1, LAST_COLUMN + 1),
Err("Invalid column: '16385'".to_string())
);
}
#[test]
fn set_selected_cell_errors_wrong_sheet() {
let mut model = new_empty_model();
// forcefully set a wrong index
model.workbook.views.get_mut(&0).unwrap().sheet = 2;
let mut model = UserModel::from_model(model);
// It's returning the wrong number
assert_eq!(model.get_selected_sheet(), 2);
// But we can't set the selected cell anymore
assert_eq!(
model.set_selected_cell(3, 4),
Err("Invalid worksheet index 2".to_string())
);
assert_eq!(
model.set_selected_range(3, 4, 5, 6),
Err("Invalid worksheet index 2".to_string())
);
assert_eq!(
model.set_top_left_visible_cell(3, 4),
Err("Invalid worksheet index 2".to_string())
);
// we can fix it by setting the right cell
model.set_selected_sheet(0).unwrap();
model.set_selected_cell(3, 4).unwrap();
}
#[test]
fn set_visible_cell() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_top_left_visible_cell(100, 12).unwrap();
assert_eq!(
model.get_selected_view(),
SelectedView {
sheet: 0,
row: 1,
column: 1,
range: [1, 1, 1, 1],
top_row: 100,
left_column: 12
}
);
let s = serde_json::to_string(&model.get_selected_view()).unwrap();
assert_eq!(
serde_json::from_str::<SelectedView>(&s).unwrap(),
SelectedView {
sheet: 0,
row: 1,
column: 1,
range: [1, 1, 1, 1],
top_row: 100,
left_column: 12
}
);
}
#[test]
fn set_visible_cell_errors() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
assert_eq!(
model.set_top_left_visible_cell(-100, 12),
Err("Invalid row: '-100'".to_string())
);
assert_eq!(
model.set_top_left_visible_cell(100, -12),
Err("Invalid column: '-12'".to_string())
);
}
#[test]
fn errors_no_views() {
let mut model = new_empty_model();
// forcefully remove the view
model.workbook.views = HashMap::new();
// also in the sheet
model.workbook.worksheets[0].views = HashMap::new();
let mut model = UserModel::from_model(model);
// get methods will return defaults
assert_eq!(model.get_selected_sheet(), 0);
assert_eq!(model.get_selected_cell(), (0, 1, 1));
assert_eq!(
model.get_selected_view(),
SelectedView {
sheet: 0,
row: 1,
column: 1,
range: [1, 1, 1, 1],
top_row: 1,
left_column: 1
}
);
// set methods won't complain. but won't work either
model.set_selected_sheet(0).unwrap();
model.set_selected_cell(5, 6).unwrap();
assert_eq!(model.get_selected_cell(), (0, 1, 1));
}

View File

@@ -4,37 +4,15 @@ use std::{collections::HashMap, fmt::Display};
use crate::expressions::token::Error; use crate::expressions::token::Error;
// Useful for `#[serde(default = "default_as_true")]`
fn default_as_true() -> bool {
true
}
fn default_as_false() -> bool { fn default_as_false() -> bool {
false false
} }
// Useful for `#[serde(skip_serializing_if = "is_true")]`
fn is_true(b: &bool) -> bool {
*b
}
fn is_false(b: &bool) -> bool { fn is_false(b: &bool) -> bool {
!*b !*b
} }
fn is_zero(num: &i32) -> bool { #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
*num == 0
}
fn is_default_alignment(o: &Option<Alignment>) -> bool {
o.is_none() || *o == Some(Alignment::default())
}
fn hashmap_is_empty(h: &HashMap<String, Table>) -> bool {
h.values().len() == 0
}
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct Metadata { pub struct Metadata {
pub application: String, pub application: String,
pub app_version: String, pub app_version: String,
@@ -44,14 +22,25 @@ pub struct Metadata {
pub last_modified: String, //"2020-11-20T16:24:35" pub last_modified: String, //"2020-11-20T16:24:35"
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct WorkbookSettings { pub struct WorkbookSettings {
pub tz: String, pub tz: String,
pub locale: String, pub locale: String,
} }
/// A Workbook View tracks of the selected sheet for each view
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
pub struct WorkbookView {
/// The index of the currently selected sheet.
pub sheet: u32,
/// The current width of the window
pub window_width: i64,
/// The current heigh of the window
pub window_height: i64,
}
/// An internal representation of an IronCalc Workbook /// An internal representation of an IronCalc Workbook
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Clone)]
#[serde(deny_unknown_fields)]
pub struct Workbook { pub struct Workbook {
pub shared_strings: Vec<String>, pub shared_strings: Vec<String>,
pub defined_names: Vec<DefinedName>, pub defined_names: Vec<DefinedName>,
@@ -60,28 +49,22 @@ pub struct Workbook {
pub name: String, pub name: String,
pub settings: WorkbookSettings, pub settings: WorkbookSettings,
pub metadata: Metadata, pub metadata: Metadata,
#[serde(default)]
#[serde(skip_serializing_if = "hashmap_is_empty")]
pub tables: HashMap<String, Table>, pub tables: HashMap<String, Table>,
pub views: HashMap<u32, WorkbookView>,
} }
/// 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
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct DefinedName { pub struct DefinedName {
pub name: String, pub name: String,
pub formula: String, pub formula: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sheet_id: Option<u32>, pub sheet_id: Option<u32>,
} }
// TODO: Move to worksheet.rs make frozen_rows/columns private and u32
/// Internal representation of a worksheet Excel object
/// * state: /// * state:
/// 18.18.68 ST_SheetState (Sheet Visibility Types) /// 18.18.68 ST_SheetState (Sheet Visibility Types)
/// hidden, veryHidden, visible /// hidden, veryHidden, visible
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
#[serde(rename_all = "lowercase")]
pub enum SheetState { pub enum SheetState {
Visible, Visible,
Hidden, Hidden,
@@ -98,8 +81,25 @@ impl Display for SheetState {
} }
} }
/// Represents the state of the worksheet as seen by the user. This includes
/// details such as the currently selected cell, the visible range, and the
/// position of the viewport.
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
pub struct WorksheetView {
/// The row index of the currently selected cell.
pub row: i32,
/// The column index of the currently selected cell.
pub column: i32,
/// The selected range in the worksheet, specified as [start_row, start_column, end_row, end_column].
pub range: [i32; 4],
/// The row index of the topmost visible cell in the worksheet view.
pub top_row: i32,
/// The column index of the leftmost visible cell in the worksheet view.
pub left_column: i32,
}
/// Internal representation of a worksheet Excel object /// Internal representation of a worksheet Excel object
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Clone)]
pub struct Worksheet { pub struct Worksheet {
pub dimension: String, pub dimension: String,
pub cols: Vec<Col>, pub cols: Vec<Col>,
@@ -109,16 +109,14 @@ pub struct Worksheet {
pub shared_formulas: Vec<String>, pub shared_formulas: Vec<String>,
pub sheet_id: u32, pub sheet_id: u32,
pub state: SheetState, pub state: SheetState,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>, pub color: Option<String>,
pub merge_cells: Vec<String>, pub merge_cells: Vec<String>,
pub comments: Vec<Comment>, pub comments: Vec<Comment>,
#[serde(default)]
#[serde(skip_serializing_if = "is_zero")]
pub frozen_rows: i32, pub frozen_rows: i32,
#[serde(default)]
#[serde(skip_serializing_if = "is_zero")]
pub frozen_columns: i32, pub frozen_columns: i32,
pub views: HashMap<u32, WorksheetView>,
/// Whether or not to show the grid lines in the worksheet
pub show_grid_lines: bool,
} }
/// Internal representation of Excel's sheet_data /// Internal representation of Excel's sheet_data
@@ -126,7 +124,7 @@ pub struct Worksheet {
pub type SheetData = HashMap<i32, HashMap<i32, Cell>>; pub type SheetData = HashMap<i32, HashMap<i32, Cell>>;
// ECMA-376-1:2016 section 18.3.1.73 // ECMA-376-1:2016 section 18.3.1.73
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Clone)]
pub struct Row { pub struct Row {
/// Row index /// Row index
pub r: i32, pub r: i32,
@@ -134,23 +132,19 @@ pub struct Row {
pub custom_format: bool, pub custom_format: bool,
pub custom_height: bool, pub custom_height: bool,
pub s: i32, pub s: i32,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool, pub hidden: bool,
} }
// ECMA-376-1:2016 section 18.3.1.13 // ECMA-376-1:2016 section 18.3.1.13
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Clone)]
pub struct Col { pub struct Col {
// Column definitions are defined on ranges, unlike rows which store unique, per-row entries. // Column definitions are defined on ranges, unlike rows which store unique, per-row entries.
/// First column affected by this record. Settings apply to column in \[min, max\] range. /// First column affected by this record. Settings apply to column in \[min, max\] range.
pub min: i32, pub min: i32,
/// Last column affected by this record. Settings apply to column in \[min, max\] range. /// Last column affected by this record. Settings apply to column in \[min, max\] range.
pub max: i32, pub max: i32,
pub width: f64, pub width: f64,
pub custom_width: bool, pub custom_width: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<i32>, pub style: Option<i32>,
} }
@@ -165,32 +159,55 @@ pub enum CellType {
CompoundData = 128, CompoundData = 128,
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, Clone, PartialEq)] #[derive(Encode, Decode, Debug, Clone, PartialEq)]
#[serde(tag = "t", deny_unknown_fields)]
pub enum Cell { pub enum Cell {
#[serde(rename = "empty")] EmptyCell {
EmptyCell { s: i32 }, s: i32,
#[serde(rename = "b")] },
BooleanCell { v: bool, s: i32 },
#[serde(rename = "n")] BooleanCell {
NumberCell { v: f64, s: i32 }, v: bool,
s: i32,
},
NumberCell {
v: f64,
s: i32,
},
// Maybe we should not have this type. In Excel this is just a string // Maybe we should not have this type. In Excel this is just a string
#[serde(rename = "e")] ErrorCell {
ErrorCell { ei: Error, s: i32 }, ei: Error,
s: i32,
},
// Always a shared string // Always a shared string
#[serde(rename = "s")] SharedString {
SharedString { si: i32, s: i32 }, si: i32,
s: i32,
},
// Non evaluated Formula // Non evaluated Formula
#[serde(rename = "u")] CellFormula {
CellFormula { f: i32, s: i32 }, f: i32,
#[serde(rename = "fb")] s: i32,
CellFormulaBoolean { f: i32, v: bool, s: i32 }, },
#[serde(rename = "fn")]
CellFormulaNumber { f: i32, v: f64, s: i32 }, CellFormulaBoolean {
f: i32,
v: bool,
s: i32,
},
CellFormulaNumber {
f: i32,
v: f64,
s: i32,
},
// always inline string // always inline string
#[serde(rename = "str")] CellFormulaString {
CellFormulaString { f: i32, v: String, s: i32 }, f: i32,
#[serde(rename = "fe")] v: String,
s: i32,
},
CellFormulaError { CellFormulaError {
f: i32, f: i32,
ei: Error, ei: Error,
@@ -209,17 +226,16 @@ impl Default for Cell {
} }
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct Comment { pub struct Comment {
pub text: String, pub text: String,
pub author_name: String, pub author_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_id: Option<String>, pub author_id: Option<String>,
pub cell_ref: String, pub cell_ref: String,
} }
// ECMA-376-1:2016 section 18.5.1.2 // ECMA-376-1:2016 section 18.5.1.2
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct Table { pub struct Table {
pub name: String, pub name: String,
pub display_name: String, pub display_name: String,
@@ -227,34 +243,24 @@ pub struct Table {
pub reference: String, pub reference: String,
pub totals_row_count: u32, pub totals_row_count: u32,
pub header_row_count: u32, pub header_row_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub header_row_dxf_id: Option<u32>, pub header_row_dxf_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_dxf_id: Option<u32>, pub data_dxf_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totals_row_dxf_id: Option<u32>, pub totals_row_dxf_id: Option<u32>,
pub columns: Vec<TableColumn>, pub columns: Vec<TableColumn>,
pub style_info: TableStyleInfo, pub style_info: TableStyleInfo,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub has_filters: bool, pub has_filters: bool,
} }
// totals_row_label vs totals_row_function might be mutually exclusive. Use an enum? // totals_row_label vs totals_row_function might be mutually exclusive. Use an enum?
// the totals_row_function is an enum not String methinks // the totals_row_function is an enum not String methinks
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct TableColumn { pub struct TableColumn {
pub id: u32, pub id: u32,
pub name: String, pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub totals_row_label: Option<String>, pub totals_row_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub header_row_dxf_id: Option<u32>, pub header_row_dxf_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_dxf_id: Option<u32>, pub data_dxf_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totals_row_dxf_id: Option<u32>, pub totals_row_dxf_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totals_row_function: Option<String>, pub totals_row_function: Option<String>,
} }
@@ -272,25 +278,16 @@ impl Default for TableColumn {
} }
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone, Default)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Default)]
pub struct TableStyleInfo { pub struct TableStyleInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub show_first_column: bool, pub show_first_column: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub show_last_column: bool, pub show_last_column: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub show_row_stripes: bool, pub show_row_stripes: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub show_column_stripes: bool, pub show_column_stripes: bool,
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct Styles { pub struct Styles {
pub num_fmts: Vec<NumFmt>, pub num_fmts: Vec<NumFmt>,
pub fonts: Vec<Font>, pub fonts: Vec<Font>,
@@ -326,7 +323,7 @@ pub struct Style {
pub quote_prefix: bool, pub quote_prefix: bool,
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct NumFmt { pub struct NumFmt {
pub num_fmt_id: i32, pub num_fmt_id: i32,
pub format_code: String, pub format_code: String,
@@ -516,29 +513,17 @@ pub struct Alignment {
pub wrap_text: bool, pub wrap_text: bool,
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct CellStyleXfs { pub struct CellStyleXfs {
pub num_fmt_id: i32, pub num_fmt_id: i32,
pub font_id: i32, pub font_id: i32,
pub fill_id: i32, pub fill_id: i32,
pub border_id: i32, pub border_id: i32,
#[serde(default = "default_as_true")]
#[serde(skip_serializing_if = "is_true")]
pub apply_number_format: bool, pub apply_number_format: bool,
#[serde(default = "default_as_true")]
#[serde(skip_serializing_if = "is_true")]
pub apply_border: bool, pub apply_border: bool,
#[serde(default = "default_as_true")]
#[serde(skip_serializing_if = "is_true")]
pub apply_alignment: bool, pub apply_alignment: bool,
#[serde(default = "default_as_true")]
#[serde(skip_serializing_if = "is_true")]
pub apply_protection: bool, pub apply_protection: bool,
#[serde(default = "default_as_true")]
#[serde(skip_serializing_if = "is_true")]
pub apply_font: bool, pub apply_font: bool,
#[serde(default = "default_as_true")]
#[serde(skip_serializing_if = "is_true")]
pub apply_fill: bool, pub apply_fill: bool,
} }
@@ -559,39 +544,24 @@ impl Default for CellStyleXfs {
} }
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone, Default)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Default)]
pub struct CellXfs { pub struct CellXfs {
pub xf_id: i32, pub xf_id: i32,
pub num_fmt_id: i32, pub num_fmt_id: i32,
pub font_id: i32, pub font_id: i32,
pub fill_id: i32, pub fill_id: i32,
pub border_id: i32, pub border_id: i32,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub apply_number_format: bool, pub apply_number_format: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub apply_border: bool, pub apply_border: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub apply_alignment: bool, pub apply_alignment: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub apply_protection: bool, pub apply_protection: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub apply_font: bool, pub apply_font: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub apply_fill: bool, pub apply_fill: bool,
#[serde(default = "default_as_false")]
#[serde(skip_serializing_if = "is_false")]
pub quote_prefix: bool, pub quote_prefix: bool,
#[serde(skip_serializing_if = "is_default_alignment")]
pub alignment: Option<Alignment>, pub alignment: Option<Alignment>,
} }
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct CellStyles { pub struct CellStyles {
pub name: String, pub name: String,
pub xf_id: i32, pub xf_id: i32,

View File

@@ -12,164 +12,35 @@ use crate::{
}, },
model::Model, model::Model,
types::{ types::{
Alignment, BorderItem, BorderStyle, Cell, CellType, Col, HorizontalAlignment, Row, Alignment, BorderItem, BorderStyle, CellType, Col, HorizontalAlignment, SheetProperties,
SheetProperties, Style, VerticalAlignment, Style, VerticalAlignment,
}, },
utils::is_valid_hex_color, utils::is_valid_hex_color,
}; };
#[derive(Clone, Serialize, Deserialize)] use crate::user_model::history::{
struct RowData { ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData,
row: Option<Row>, };
data: HashMap<i32, Cell>,
#[derive(Serialize, Deserialize)]
pub enum BorderType {
All,
Inner,
Outer,
Top,
Right,
Bottom,
Left,
CenterH,
CenterV,
None,
} }
#[derive(Clone, Serialize, Deserialize)] /// This is the struct for a border area
struct ColumnData { #[derive(Serialize, Deserialize)]
column: Option<Col>, pub struct BorderArea {
data: HashMap<i32, Cell>, item: BorderItem,
} r#type: BorderType,
#[derive(Clone, Serialize, Deserialize)]
enum Diff {
// Cell diffs
SetCellValue {
sheet: u32,
row: i32,
column: i32,
new_value: String,
old_value: Box<Option<Cell>>,
},
CellClearContents {
sheet: u32,
row: i32,
column: i32,
old_value: Box<Option<Cell>>,
},
CellClearAll {
sheet: u32,
row: i32,
column: i32,
old_value: Box<Option<Cell>>,
old_style: Box<Style>,
},
SetCellStyle {
sheet: u32,
row: i32,
column: i32,
old_value: Box<Style>,
new_value: Box<Style>,
},
// Column and Row diffs
SetColumnWidth {
sheet: u32,
column: i32,
new_value: f64,
old_value: f64,
},
SetRowHeight {
sheet: u32,
row: i32,
new_value: f64,
old_value: f64,
},
InsertRow {
sheet: u32,
row: i32,
},
DeleteRow {
sheet: u32,
row: i32,
old_data: Box<RowData>,
},
InsertColumn {
sheet: u32,
column: i32,
},
DeleteColumn {
sheet: u32,
column: i32,
old_data: Box<ColumnData>,
},
SetFrozenRowsCount {
sheet: u32,
new_value: i32,
old_value: i32,
},
SetFrozenColumnsCount {
sheet: u32,
new_value: i32,
old_value: i32,
},
DeleteSheet {
sheet: u32,
},
NewSheet {
index: u32,
name: String,
},
RenameSheet {
index: u32,
old_value: String,
new_value: String,
},
SetSheetColor {
index: u32,
old_value: String,
new_value: String,
},
}
type DiffList = Vec<Diff>;
#[derive(Default)]
struct History {
undo_stack: Vec<DiffList>,
redo_stack: Vec<DiffList>,
}
impl History {
fn push(&mut self, diff_list: DiffList) {
self.undo_stack.push(diff_list);
self.redo_stack = vec![];
}
fn undo(&mut self) -> Option<Vec<Diff>> {
match self.undo_stack.pop() {
Some(diff_list) => {
self.redo_stack.push(diff_list.clone());
Some(diff_list)
}
None => None,
}
}
fn redo(&mut self) -> Option<Vec<Diff>> {
match self.redo_stack.pop() {
Some(diff_list) => {
self.undo_stack.push(diff_list.clone());
Some(diff_list)
}
None => None,
}
}
fn clear(&mut self) {
self.redo_stack = vec![];
self.undo_stack = vec![];
}
}
#[derive(Clone, Serialize, Deserialize)]
enum DiffType {
Undo,
Redo,
}
#[derive(Clone, Serialize, Deserialize)]
struct QueueDiffs {
r#type: DiffType,
list: DiffList,
} }
fn boolean(value: &str) -> Result<bool, String> { fn boolean(value: &str) -> Result<bool, String> {
@@ -249,7 +120,7 @@ fn vertical(value: &str) -> Result<VerticalAlignment, String> {
} }
/// # A wrapper around [`Model`] for a spreadsheet end user. /// # A wrapper around [`Model`] for a spreadsheet end user.
/// UserModel is a wrapper around Model with undo/redo history, _diffs_ and automatic evaluation. /// UserModel is a wrapper around Model with undo/redo history, _diffs_, automatic evaluation and view management.
/// ///
/// A diff in this context (or more correctly a _user diff_) is a change created by a user. /// A diff in this context (or more correctly a _user diff_) is a change created by a user.
/// ///
@@ -275,7 +146,7 @@ fn vertical(value: &str) -> Result<VerticalAlignment, String> {
/// # } /// # }
/// ``` /// ```
pub struct UserModel { pub struct UserModel {
model: Model, pub(crate) model: Model,
history: History, history: History,
send_queue: Vec<QueueDiffs>, send_queue: Vec<QueueDiffs>,
pause_evaluation: bool, pause_evaluation: bool,
@@ -408,9 +279,9 @@ impl UserModel {
/// ///
/// See also: /// See also:
/// * [UserModel::apply_external_diffs] /// * [UserModel::apply_external_diffs]
pub fn flush_send_queue(&mut self) -> String { pub fn flush_send_queue(&mut self) -> Vec<u8> {
// This can never fail :O: // This can never fail :O:
let q = serde_json::to_string(&self.send_queue).unwrap(); let q = bitcode::encode(&self.send_queue);
self.send_queue = vec![]; self.send_queue = vec![];
q q
} }
@@ -421,8 +292,8 @@ impl UserModel {
/// ///
/// See also: /// See also:
/// * [UserModel::flush_send_queue] /// * [UserModel::flush_send_queue]
pub fn apply_external_diffs(&mut self, diff_list_str: &str) -> Result<(), String> { pub fn apply_external_diffs(&mut self, diff_list_str: &[u8]) -> Result<(), String> {
if let Ok(queue_diffs_list) = serde_json::from_str::<Vec<QueueDiffs>>(diff_list_str) { if let Ok(queue_diffs_list) = bitcode::decode::<Vec<QueueDiffs>>(diff_list_str) {
for queue_diff in queue_diffs_list { for queue_diff in queue_diffs_list {
if matches!(queue_diff.r#type, DiffType::Redo) { if matches!(queue_diff.r#type, DiffType::Redo) {
self.apply_diff_list(&queue_diff.list)?; self.apply_diff_list(&queue_diff.list)?;
@@ -811,6 +682,154 @@ impl UserModel {
self.model.set_frozen_columns(sheet, frozen_columns) self.model.set_frozen_columns(sheet, frozen_columns)
} }
/// Paste `styles` in the selected area
pub fn on_paste_styles(&mut self, styles: &[Vec<Style>]) -> Result<(), String> {
let styles_heigh = styles.len() as i32;
let styles_width = styles[0].len() as i32;
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
return Ok(());
};
let range = if let Ok(worksheet) = self.model.workbook.worksheet(sheet) {
if let Some(view) = worksheet.views.get(&self.model.view_id) {
view.range
} else {
return Ok(());
}
} else {
return Ok(());
};
// If the pasted area is smaller than the selected area we increase it
let [row_start, column_start, row_end, column_end] = range;
let last_row = row_end.max(row_start + styles_heigh - 1);
let last_column = column_end.max(column_start + styles_width - 1);
let mut diff_list = Vec::new();
for row in row_start..=last_row {
for column in column_start..=last_column {
let row_index = ((row - row_start) % styles_heigh) as usize;
let column_index = ((column - column_start) % styles_width) as usize;
let style = &styles[row_index][column_index];
let old_value = self.model.get_style_for_cell(sheet, row, column);
self.model.set_cell_style(sheet, row, column, style)?;
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(old_value),
new_value: Box::new(style.clone()),
});
}
}
self.push_diff_list(diff_list);
// select the pasted range
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.range = [row_start, column_start, last_row, last_column];
}
}
Ok(())
}
/// Sets the border
pub fn set_area_with_border(
&mut self,
range: &Area,
border_area: &BorderArea,
) -> Result<(), String> {
let sheet = range.sheet;
let mut diff_list = Vec::new();
let last_row = range.row + range.height - 1;
let last_column = range.column + range.width - 1;
for row in range.row..=last_row {
for column in range.column..=last_column {
let old_value = self.model.get_style_for_cell(sheet, row, column);
let mut style = old_value.clone();
// First remove all existing borders
style.border.top = None;
style.border.right = None;
style.border.bottom = None;
style.border.left = None;
match border_area.r#type {
BorderType::All => {
style.border.top = Some(border_area.item.clone());
style.border.right = Some(border_area.item.clone());
style.border.bottom = Some(border_area.item.clone());
style.border.left = Some(border_area.item.clone());
}
BorderType::Inner => {
if row != range.row {
style.border.top = Some(border_area.item.clone());
}
if row != last_row {
style.border.bottom = Some(border_area.item.clone());
}
if column != range.column {
style.border.left = Some(border_area.item.clone());
}
if column != last_column {
style.border.right = Some(border_area.item.clone());
}
}
BorderType::Outer => {
if row == range.row {
style.border.top = Some(border_area.item.clone());
}
if row == last_row {
style.border.bottom = Some(border_area.item.clone());
}
if column == range.column {
style.border.left = Some(border_area.item.clone());
}
if column == last_column {
style.border.right = Some(border_area.item.clone());
}
}
BorderType::Top => style.border.top = Some(border_area.item.clone()),
BorderType::Right => style.border.right = Some(border_area.item.clone()),
BorderType::Bottom => style.border.bottom = Some(border_area.item.clone()),
BorderType::Left => style.border.left = Some(border_area.item.clone()),
BorderType::CenterH => {
if row != range.row {
style.border.top = Some(border_area.item.clone());
}
if row != last_row {
style.border.bottom = Some(border_area.item.clone());
}
}
BorderType::CenterV => {
if column != range.column {
style.border.left = Some(border_area.item.clone());
}
if column != last_column {
style.border.right = Some(border_area.item.clone());
}
}
BorderType::None => {
// noop, we already removed all the borders
}
}
self.model.set_cell_style(sheet, row, column, &style)?;
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(old_value),
new_value: Box::new(style),
});
}
}
self.push_diff_list(diff_list);
Ok(())
}
/// Updates the range with a cell style. /// Updates the range with a cell style.
/// See also: /// See also:
/// * [Model::set_cell_style] /// * [Model::set_cell_style]
@@ -845,8 +864,11 @@ impl UserModel {
"fill.bg_color" => { "fill.bg_color" => {
style.fill.bg_color = color(value)?; style.fill.bg_color = color(value)?;
} }
"fill.fg_color" => {
style.fill.fg_color = color(value)?;
}
"num_fmt" => { "num_fmt" => {
style.num_fmt = value.to_owned(); value.clone_into(&mut style.num_fmt);
} }
"border.left" => { "border.left" => {
style.border.left = border(value)?; style.border.left = border(value)?;
@@ -907,7 +929,7 @@ impl UserModel {
column, column,
old_value: Box::new(old_value), old_value: Box::new(old_value),
new_value: Box::new(style), new_value: Box::new(style),
}) });
} }
} }
self.push_diff_list(diff_list); self.push_diff_list(diff_list);
@@ -923,6 +945,208 @@ impl UserModel {
Ok(self.model.get_style_for_cell(sheet, row, column)) Ok(self.model.get_style_for_cell(sheet, row, column))
} }
/// Fills the cells from `source_area` until `to_row`.
/// This simulates the user clicking on the cell outline handle and dragging it downwards (or upwards)
pub fn auto_fill_rows(&mut self, source_area: &Area, to_row: i32) -> Result<(), String> {
let mut diff_list = Vec::new();
let sheet = source_area.sheet;
let row1 = source_area.row;
let column1 = source_area.column;
let width1 = source_area.width;
let height1 = source_area.height;
// Check first all parameters are valid
if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index: '{sheet}'"));
}
if !is_valid_column_number(column1) {
return Err(format!("Invalid column: '{column1}'"));
}
if !is_valid_row(row1) {
return Err(format!("Invalid row: '{row1}'"));
}
if !is_valid_column_number(column1 + width1 - 1) {
return Err(format!("Invalid column: '{}'", column1 + width1 - 1));
}
if !is_valid_row(row1 + height1 - 1) {
return Err(format!("Invalid row: '{}'", row1 + height1 - 1));
}
if !is_valid_row(to_row) {
return Err(format!("Invalid row: '{to_row}'"));
}
// anchor_row is the first row that repeats in each case.
let anchor_row;
let sign;
// this is the range of rows we are going to fill
let row_range: Vec<i32>;
if to_row >= row1 + height1 {
// we go downwards, we start from `row1 + height1` to `to_row`,
anchor_row = row1;
sign = 1;
row_range = (row1 + height1..to_row + 1).collect();
} else if to_row < row1 {
// we go upwards, starting from `row1 - `` all the way to `to_row`
anchor_row = row1 + height1 - 1;
sign = -1;
row_range = (to_row..row1).rev().collect();
} else {
return Err("Invalid parameters for autofill".to_string());
}
for column in column1..column1 + width1 {
let mut index = 0;
for row_ref in &row_range {
// Save value and style first
let row = *row_ref;
let old_value = self
.model
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned();
let old_style = self.model.get_style_for_cell(sheet, row, column);
// compute the new value and set it
let source_row = anchor_row + index;
let target_value = self
.model
.extend_to(sheet, source_row, column, row, column)?;
self.model
.set_user_input(sheet, row, column, target_value.to_string());
// Compute the new style and set it
let new_style = self.model.get_style_for_cell(sheet, source_row, column);
self.model.set_cell_style(sheet, row, column, &new_style)?;
// Add the diffs
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(old_style),
new_value: Box::new(new_style),
});
diff_list.push(Diff::SetCellValue {
sheet,
row,
column,
new_value: target_value.to_string(),
old_value: Box::new(old_value),
});
index = (index + sign) % height1;
}
}
self.push_diff_list(diff_list);
self.evaluate();
Ok(())
}
/// Fills the cells from `source_area` until `to_column`.
/// This simulates the user clicking on the cell outline handle and dragging it to the right (or to the left)
pub fn auto_fill_columns(&mut self, source_area: &Area, to_column: i32) -> Result<(), String> {
let mut diff_list = Vec::new();
let sheet = source_area.sheet;
let row1 = source_area.row;
let column1 = source_area.column;
let width1 = source_area.width;
let height1 = source_area.height;
// Check first all parameters are valid
if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index: '{sheet}'"));
}
if !is_valid_column_number(column1) {
return Err(format!("Invalid column: '{column1}'"));
}
if !is_valid_row(row1) {
return Err(format!("Invalid row: '{row1}'"));
}
if !is_valid_column_number(column1 + width1 - 1) {
return Err(format!("Invalid column: '{}'", column1 + width1 - 1));
}
if !is_valid_row(row1 + height1 - 1) {
return Err(format!("Invalid row: '{}'", row1 + height1 - 1));
}
if !is_valid_row(to_column) {
return Err(format!("Invalid row: '{to_column}'"));
}
// anchor_column is the first column that repeats in each case.
let anchor_column;
let sign;
// this is the range of columns we are going to fill
let column_range: Vec<i32>;
if to_column >= column1 + width1 {
// we go right, we start from `1 + width` to `to_column`,
anchor_column = column1;
sign = 1;
column_range = (column1 + width1..to_column + 1).collect();
} else if to_column < column1 {
// we go left, starting from `column1 - `` all the way to `to_column`
anchor_column = column1 + width1 - 1;
sign = -1;
column_range = (to_column..column1).rev().collect();
} else {
return Err("Invalid parameters for autofill".to_string());
}
for row in row1..row1 + height1 {
let mut index = 0;
for column_ref in &column_range {
let column = *column_ref;
// Save value and style first
let old_value = self
.model
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned();
let old_style = self.model.get_style_for_cell(sheet, row, column);
// compute the new value and set it
let source_column = anchor_column + index;
let target_value = self
.model
.extend_to(sheet, row, source_column, row, column)?;
self.model
.set_user_input(sheet, row, column, target_value.to_string());
// Compute the new style and set it
let new_style = self.model.get_style_for_cell(sheet, row, source_column);
self.model.set_cell_style(sheet, row, column, &new_style)?;
// Add the diffs
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(old_style),
new_value: Box::new(new_style),
});
diff_list.push(Diff::SetCellValue {
sheet,
row,
column,
new_value: target_value.to_string(),
old_value: Box::new(old_value),
});
index = (index + sign) % width1;
}
}
self.push_diff_list(diff_list);
self.evaluate();
Ok(())
}
/// Returns information about the sheets /// Returns information about the sheets
/// ///
/// See also: /// See also:
@@ -932,6 +1156,24 @@ impl UserModel {
self.model.get_worksheets_properties() self.model.get_worksheets_properties()
} }
/// Set the gid lines in the worksheet to visible (`true`) or hidden (`false`)
pub fn set_show_grid_lines(&mut self, sheet: u32, show_grid_lines: bool) -> Result<(), String> {
let old_value = self.model.workbook.worksheet(sheet)?.show_grid_lines;
self.model.set_show_grid_lines(sheet, show_grid_lines)?;
self.push_diff_list(vec![Diff::SetShowGridLines {
sheet,
new_value: show_grid_lines,
old_value,
}]);
Ok(())
}
/// Returns true in the grid lines for
pub fn get_show_grid_lines(&self, sheet: u32) -> Result<bool, String> {
Ok(self.model.workbook.worksheet(sheet)?.show_grid_lines)
}
// **** Private methods ****** // // **** Private methods ****** //
fn push_diff_list(&mut self, diff_list: DiffList) { fn push_diff_list(&mut self, diff_list: DiffList) {
@@ -1095,6 +1337,13 @@ impl UserModel {
} => { } => {
self.model.set_sheet_color(*index, old_value)?; self.model.set_sheet_color(*index, old_value)?;
} }
Diff::SetShowGridLines {
sheet,
old_value,
new_value: _,
} => {
self.model.set_show_grid_lines(*sheet, *old_value)?;
}
} }
} }
if needs_evaluation { if needs_evaluation {
@@ -1215,6 +1464,13 @@ impl UserModel {
} => { } => {
self.model.set_sheet_color(*index, new_value)?; self.model.set_sheet_color(*index, new_value)?;
} }
Diff::SetShowGridLines {
sheet,
old_value: _,
new_value,
} => {
self.model.set_show_grid_lines(*sheet, *new_value)?;
}
} }
} }
@@ -1229,7 +1485,7 @@ impl UserModel {
mod tests { mod tests {
use crate::{ use crate::{
types::{HorizontalAlignment, VerticalAlignment}, types::{HorizontalAlignment, VerticalAlignment},
user_model::{horizontal, vertical}, user_model::common::{horizontal, vertical},
}; };
#[test] #[test]

View File

@@ -0,0 +1,164 @@
use std::collections::HashMap;
use bitcode::{Decode, Encode};
use crate::types::{Cell, Col, Row, Style};
#[derive(Clone, Encode, Decode)]
pub(crate) struct RowData {
pub(crate) row: Option<Row>,
pub(crate) data: HashMap<i32, Cell>,
}
#[derive(Clone, Encode, Decode)]
pub(crate) struct ColumnData {
pub(crate) column: Option<Col>,
pub(crate) data: HashMap<i32, Cell>,
}
#[derive(Clone, Encode, Decode)]
pub(crate) enum Diff {
// Cell diffs
SetCellValue {
sheet: u32,
row: i32,
column: i32,
new_value: String,
old_value: Box<Option<Cell>>,
},
CellClearContents {
sheet: u32,
row: i32,
column: i32,
old_value: Box<Option<Cell>>,
},
CellClearAll {
sheet: u32,
row: i32,
column: i32,
old_value: Box<Option<Cell>>,
old_style: Box<Style>,
},
SetCellStyle {
sheet: u32,
row: i32,
column: i32,
old_value: Box<Style>,
new_value: Box<Style>,
},
// Column and Row diffs
SetColumnWidth {
sheet: u32,
column: i32,
new_value: f64,
old_value: f64,
},
SetRowHeight {
sheet: u32,
row: i32,
new_value: f64,
old_value: f64,
},
InsertRow {
sheet: u32,
row: i32,
},
DeleteRow {
sheet: u32,
row: i32,
old_data: Box<RowData>,
},
InsertColumn {
sheet: u32,
column: i32,
},
DeleteColumn {
sheet: u32,
column: i32,
old_data: Box<ColumnData>,
},
SetFrozenRowsCount {
sheet: u32,
new_value: i32,
old_value: i32,
},
SetFrozenColumnsCount {
sheet: u32,
new_value: i32,
old_value: i32,
},
DeleteSheet {
sheet: u32,
},
NewSheet {
index: u32,
name: String,
},
RenameSheet {
index: u32,
old_value: String,
new_value: String,
},
SetSheetColor {
index: u32,
old_value: String,
new_value: String,
},
SetShowGridLines {
sheet: u32,
old_value: bool,
new_value: bool,
}, // FIXME: we are missing SetViewDiffs
}
pub(crate) type DiffList = Vec<Diff>;
#[derive(Default)]
pub(crate) struct History {
pub(crate) undo_stack: Vec<DiffList>,
pub(crate) redo_stack: Vec<DiffList>,
}
impl History {
pub fn push(&mut self, diff_list: DiffList) {
self.undo_stack.push(diff_list);
self.redo_stack = vec![];
}
pub fn undo(&mut self) -> Option<Vec<Diff>> {
match self.undo_stack.pop() {
Some(diff_list) => {
self.redo_stack.push(diff_list.clone());
Some(diff_list)
}
None => None,
}
}
pub fn redo(&mut self) -> Option<Vec<Diff>> {
match self.redo_stack.pop() {
Some(diff_list) => {
self.undo_stack.push(diff_list.clone());
Some(diff_list)
}
None => None,
}
}
pub fn clear(&mut self) {
self.redo_stack = vec![];
self.undo_stack = vec![];
}
}
#[derive(Clone, Encode, Decode)]
pub enum DiffType {
Undo,
Redo,
}
#[derive(Clone, Encode, Decode)]
pub struct QueueDiffs {
pub r#type: DiffType,
pub list: DiffList,
}

View File

@@ -0,0 +1,12 @@
#![deny(missing_docs)]
mod common;
mod history;
mod ui;
pub use common::UserModel;
#[cfg(test)]
pub use ui::SelectedView;
pub use common::BorderArea;

671
base/src/user_model/ui.rs Normal file
View File

@@ -0,0 +1,671 @@
#![deny(missing_docs)]
use serde::{Deserialize, Serialize};
use crate::expressions::utils::{is_valid_column_number, is_valid_row};
use super::common::UserModel;
#[derive(Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Debug))]
pub struct SelectedView {
pub sheet: u32,
pub row: i32,
pub column: i32,
pub range: [i32; 4],
pub top_row: i32,
pub left_column: i32,
}
impl UserModel {
/// Returns the selected sheet index
pub fn get_selected_sheet(&self) -> u32 {
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
0
}
}
/// Returns the selected cell
pub fn get_selected_cell(&self) -> (u32, i32, i32) {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
0
};
if let Ok(worksheet) = self.model.workbook.worksheet(sheet) {
if let Some(view) = worksheet.views.get(&self.model.view_id) {
return (sheet, view.row, view.column);
}
}
// return a safe default
(0, 1, 1)
}
/// Returns selected view
pub fn get_selected_view(&self) -> SelectedView {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
0
};
if let Ok(worksheet) = self.model.workbook.worksheet(sheet) {
if let Some(view) = worksheet.views.get(&self.model.view_id) {
return SelectedView {
sheet,
row: view.row,
column: view.column,
range: view.range,
top_row: view.top_row,
left_column: view.left_column,
};
}
}
// return a safe default
SelectedView {
sheet: 0,
row: 1,
column: 1,
range: [1, 1, 1, 1],
top_row: 1,
left_column: 1,
}
}
/// Sets the the selected sheet
pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<(), String> {
if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {}", sheet));
}
if let Some(view) = self.model.workbook.views.get_mut(&0) {
view.sheet = sheet;
}
Ok(())
}
/// Sets the selected cell
pub fn set_selected_cell(&mut self, row: i32, column: i32) -> Result<(), String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
0
};
if !is_valid_column_number(column) {
return Err(format!("Invalid column: '{column}'"));
}
if !is_valid_row(row) {
return Err(format!("Invalid row: '{row}'"));
}
if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {}", sheet));
}
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&0) {
view.row = row;
view.column = column;
view.range = [row, column, row, column];
}
}
Ok(())
}
/// Sets the selected range
pub fn set_selected_range(
&mut self,
start_row: i32,
start_column: i32,
end_row: i32,
end_column: i32,
) -> Result<(), String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
0
};
if !is_valid_column_number(start_column) {
return Err(format!("Invalid column: '{start_column}'"));
}
if !is_valid_row(start_row) {
return Err(format!("Invalid row: '{start_row}'"));
}
if !is_valid_column_number(end_column) {
return Err(format!("Invalid column: '{end_column}'"));
}
if !is_valid_row(end_row) {
return Err(format!("Invalid row: '{end_row}'"));
}
if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {}", sheet));
}
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&0) {
view.range = [start_row, start_column, end_row, end_column];
}
}
Ok(())
}
/// The selected range is expanded with the keyboard
pub fn on_expand_selected_range(&mut self, key: &str) -> Result<(), String> {
let (sheet, window_width, window_height) =
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
(
view.sheet,
view.window_width as f64,
view.window_height as f64,
)
} else {
return Ok(());
};
let (selected_row, selected_column, range, top_row, left_column) =
if let Ok(worksheet) = self.model.workbook.worksheet(sheet) {
if let Some(view) = worksheet.views.get(&self.model.view_id) {
(
view.row,
view.column,
view.range,
view.top_row,
view.left_column,
)
} else {
return Ok(());
}
} else {
return Ok(());
};
let [row_start, column_start, row_end, column_end] = range;
match key {
"ArrowRight" => {
if selected_column > column_start {
let new_column = column_start + 1;
if !(is_valid_column_number(new_column)) {
return Ok(());
}
self.set_selected_range(row_start, new_column, row_end, column_end)?;
} else {
let new_column = column_end + 1;
if !is_valid_column_number(new_column) {
return Ok(());
}
// if the column is not fully visible we 'scroll' right until it is
let mut width = 0.0;
let mut c = left_column;
while c <= new_column {
width += self.model.get_column_width(sheet, c)?;
c += 1;
}
if width > window_width {
self.set_top_left_visible_cell(top_row, left_column + 1)?;
}
self.set_selected_range(row_start, column_start, row_end, column_end + 1)?;
}
}
"ArrowLeft" => {
if selected_column < column_end {
let new_column = column_end - 1;
if !is_valid_column_number(new_column) {
return Ok(());
}
if new_column < left_column {
self.set_top_left_visible_cell(top_row, new_column)?;
}
self.set_selected_range(row_start, column_start, row_end, new_column)?;
} else {
let new_column = column_start - 1;
if !is_valid_column_number(new_column) {
return Ok(());
}
if new_column < left_column {
self.set_top_left_visible_cell(top_row, new_column)?;
}
self.set_selected_range(row_start, new_column, row_end, column_end)?;
}
}
"ArrowUp" => {
if selected_row < row_end {
let new_row = row_end - 1;
if !is_valid_row(new_row) {
return Ok(());
}
self.set_selected_range(row_start, column_start, new_row, column_end)?;
} else {
let new_row = row_start - 1;
if !is_valid_row(new_row) {
return Ok(());
}
if new_row < top_row {
self.set_top_left_visible_cell(new_row, left_column)?;
}
self.set_selected_range(new_row, column_start, row_end, column_end)?;
}
}
"ArrowDown" => {
if selected_row > row_start {
let new_row = row_start + 1;
if !is_valid_row(new_row) {
return Ok(());
}
self.set_selected_range(new_row, column_start, row_end, column_end)?;
} else {
let new_row = row_end + 1;
if !is_valid_row(new_row) {
return Ok(());
}
let mut height = 0.0;
let mut r = top_row;
while r <= new_row + 1 {
height += self.model.get_row_height(sheet, r)?;
r += 1;
}
if height >= window_height {
self.set_top_left_visible_cell(top_row + 1, left_column)?;
}
self.set_selected_range(row_start, column_start, new_row, column_end)?;
}
}
_ => {}
}
Ok(())
}
/// Sets the value of the first visible cell
pub fn set_top_left_visible_cell(
&mut self,
top_row: i32,
left_column: i32,
) -> Result<(), String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
0
};
if !is_valid_column_number(left_column) {
return Err(format!("Invalid column: '{left_column}'"));
}
if !is_valid_row(top_row) {
return Err(format!("Invalid row: '{top_row}'"));
}
if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {}", sheet));
}
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&0) {
view.top_row = top_row;
view.left_column = left_column;
}
}
Ok(())
}
/// Sets the width of the window
pub fn set_window_width(&mut self, window_width: f64) {
if let Some(view) = self.model.workbook.views.get_mut(&self.model.view_id) {
view.window_width = window_width as i64;
};
}
/// Gets the width of the window
pub fn get_window_width(&mut self) -> Result<i64, String> {
if let Some(view) = self.model.workbook.views.get_mut(&self.model.view_id) {
return Ok(view.window_width);
};
Err("View not found".to_string())
}
/// Sets the height of the window
pub fn set_window_height(&mut self, window_height: f64) {
if let Some(view) = self.model.workbook.views.get_mut(&self.model.view_id) {
view.window_height = window_height as i64;
};
}
/// Gets the height of the window
pub fn get_window_height(&mut self) -> Result<i64, String> {
if let Some(view) = self.model.workbook.views.get_mut(&self.model.view_id) {
return Ok(view.window_height);
};
Err("View not found".to_string())
}
/// User presses right arrow
pub fn on_arrow_right(&mut self) -> Result<(), String> {
let (sheet, window_width) =
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
(view.sheet, view.window_width)
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let new_column = view.column + 1;
if !is_valid_column_number(new_column) {
return Ok(());
}
// if the column is not fully visible we 'scroll' right until it is
let mut width = 0.0;
let mut column = view.left_column;
while column <= new_column {
width += self.model.get_column_width(sheet, column)?;
column += 1;
}
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.column = new_column;
view.range = [view.row, new_column, view.row, new_column];
if width > window_width as f64 {
view.left_column += 1;
}
}
}
Ok(())
}
/// User presses left arrow
pub fn on_arrow_left(&mut self) -> Result<(), String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let new_column = view.column - 1;
if !is_valid_column_number(new_column) {
return Ok(());
}
// if the column is not fully visible we 'scroll' right until it is
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.column = new_column;
view.range = [view.row, new_column, view.row, new_column];
if new_column < view.left_column {
view.left_column = new_column;
}
}
}
Ok(())
}
/// User presses up arrow key
pub fn on_arrow_up(&mut self) -> Result<(), String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let new_row = view.row - 1;
if !is_valid_row(new_row) {
return Ok(());
}
// if the column is not fully visible we 'scroll' right until it is
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.row = new_row;
view.range = [new_row, view.column, new_row, view.column];
if new_row < view.top_row {
view.top_row = new_row;
}
}
}
Ok(())
}
/// User presses down arrow key
pub fn on_arrow_down(&mut self) -> Result<(), String> {
let (sheet, window_height) =
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
(view.sheet, view.window_height)
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let new_row = view.row + 1;
if !is_valid_row(new_row) {
return Ok(());
}
// if the row is not fully visible we 'scroll' down until it is
let mut height = 0.0;
let mut row = view.top_row;
while row <= new_row + 1 {
height += self.model.get_row_height(sheet, row)?;
row += 1;
}
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.row = new_row;
view.range = [new_row, view.column, new_row, view.column];
if height > window_height as f64 {
view.top_row += 1;
}
}
}
Ok(())
}
// TODO: This function should be memoized
/// Returns the x-coordinate of the cell in the top left corner
pub fn get_scroll_x(&self) -> Result<f64, String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let mut scroll_x = 0.0;
for column in 1..view.left_column {
scroll_x += self.model.get_column_width(sheet, column)?;
}
Ok(scroll_x)
}
// TODO: This function should be memoized
/// Returns the y-coordinate of the cell in the top left corner
pub fn get_scroll_y(&self) -> Result<f64, String> {
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
view.sheet
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let mut scroll_y = 0.0;
for row in 1..view.top_row {
scroll_y += self.model.get_row_height(sheet, row)?;
}
Ok(scroll_y)
}
/// User presses page down
pub fn on_page_down(&mut self) -> Result<(), String> {
let (sheet, window_height) =
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
(view.sheet, view.window_height)
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let mut height = 0.0;
let mut last_row = view.top_row;
while height <= window_height as f64 {
height += self.model.get_row_height(sheet, last_row)?;
last_row += 1;
}
if !is_valid_row(last_row) {
return Ok(());
}
let row_delta = view.row - view.top_row;
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.top_row = last_row;
view.row = view.top_row + row_delta;
view.range = [view.row, view.column, view.row, view.column];
}
}
Ok(())
}
/// On page up
pub fn on_page_up(&mut self) -> Result<(), String> {
let (sheet, window_height) =
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
(view.sheet, view.window_height)
} else {
return Err("View not found".to_string());
};
let worksheet = match self.model.workbook.worksheet(sheet) {
Ok(s) => s,
Err(_) => return Err("Worksheet not found".to_string()),
};
let view = match worksheet.views.get(&self.model.view_id) {
Some(s) => s,
None => return Err("View not found".to_string()),
};
let mut height = 0.0;
let mut last_row = view.top_row;
while height <= window_height as f64 && last_row > 1 {
height += self.model.get_row_height(sheet, last_row)?;
last_row -= 1;
}
let row_delta = view.row - view.top_row;
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.top_row = last_row;
view.row = view.top_row + row_delta;
view.range = [view.row, view.column, view.row, view.column];
}
}
Ok(())
}
/// We extend the selection to cell (target_row, target_column)
pub fn on_area_selecting(&mut self, target_row: i32, target_column: i32) -> Result<(), String> {
let (sheet, window_width, window_height) =
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
(
view.sheet,
view.window_width as f64,
view.window_height as f64,
)
} else {
return Ok(());
};
let (selected_row, selected_column, range, top_row, left_column) =
if let Ok(worksheet) = self.model.workbook.worksheet(sheet) {
if let Some(view) = worksheet.views.get(&self.model.view_id) {
(
view.row,
view.column,
view.range,
view.top_row,
view.left_column,
)
} else {
return Ok(());
}
} else {
return Ok(());
};
let [row_start, column_start, _row_end, _column_end] = range;
let mut new_left_column = left_column;
if target_column >= selected_column {
let mut width = 0.0;
let mut column = left_column;
while column <= target_column {
width += self.model.get_column_width(sheet, column)?;
column += 1;
}
while width > window_width {
width -= self.model.get_column_width(sheet, new_left_column)?;
new_left_column += 1;
}
} else if target_column < new_left_column {
new_left_column = target_column;
}
let mut new_top_row = top_row;
if target_row >= selected_row {
let mut height = 0.0;
let mut row = top_row;
while row <= target_row {
height += self.model.get_row_height(sheet, row)?;
row += 1;
}
while height > window_height {
height -= self.model.get_row_height(sheet, new_top_row)?;
new_top_row += 1;
}
} else if target_row < new_top_row {
new_top_row = target_row;
}
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
view.range = [row_start, column_start, target_row, target_column];
if new_top_row != top_row {
view.top_row = new_top_row;
}
if new_left_column != left_column {
view.left_column = new_left_column;
}
}
}
Ok(())
}
}

View File

@@ -1,9 +1,12 @@
all: all:
wasm-pack build --target web --scope ironcalc wasm-pack build --target web --scope ironcalc --release
cp README.pkg.md pkg/README.md cp README.pkg.md pkg/README.md
tsc types.ts --target esnext --module esnext tsc types.ts --target esnext --module esnext
python fix_types.py python fix_types.py
tests:
wasm-pack build --target nodejs && node tests/test.mjs
lint: lint:
cargo check cargo check
cargo fmt -- --check cargo fmt -- --check

View File

@@ -14,9 +14,9 @@ export function getTokens(formula: string): any;
""".strip() """.strip()
get_tokens_str_types = r""" get_tokens_str_types = r"""
* @returns {TokenType[]} * @returns {MarkedToken[]}
*/ */
export function getTokens(formula: string): TokenType[]; export function getTokens(formula: string): MarkedToken[];
""".strip() """.strip()
update_style_str = r""" update_style_str = r"""
@@ -63,15 +63,97 @@ style_types = r"""
getCellStyle(sheet: number, row: number, column: number): CellStyle; getCellStyle(sheet: number, row: number, column: number): CellStyle;
""".strip() """.strip()
view = r"""
* @returns {any}
*/
getSelectedView(): any;
""".strip()
view_types = r"""
* @returns {CellStyle}
*/
getSelectedView(): SelectedView;
""".strip()
autofill_rows = r"""
/**
* @param {any} source_area
* @param {number} to_row
*/
autoFillRows(source_area: any, to_row: number): void;
"""
autofill_rows_types = r"""
/**
* @param {Area} source_area
* @param {number} to_row
*/
autoFillRows(source_area: Area, to_row: number): void;
"""
autofill_columns = r"""
/**
* @param {any} source_area
* @param {number} to_column
*/
autoFillColumns(source_area: any, to_column: number): void;
"""
autofill_columns_types = r"""
/**
* @param {Area} source_area
* @param {number} to_column
*/
autoFillColumns(source_area: Area, to_column: number): void;
"""
set_cell_style = r"""
/**
* @param {any} styles
*/
onPasteStyles(styles: any): void;
"""
set_cell_style_types = r"""
/**
* @param {CellStyle[][]} styles
*/
onPasteStyles(styles: CellStyle[][]): void;
"""
set_area_border = r"""
/**
* @param {any} area
* @param {any} border_area
*/
setAreaWithBorder(area: any, border_area: any): void;
"""
set_area_border_types = r"""
/**
* @param {Area} area
* @param {BorderArea} border_area
*/
setAreaWithBorder(area: Area, border_area: BorderArea): void;
"""
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)
text = text.replace(properties, properties_types) text = text.replace(properties, properties_types)
text = text.replace(style, style_types) text = text.replace(style, style_types)
text = text.replace(view, view_types)
text = text.replace(autofill_rows, autofill_rows_types)
text = text.replace(autofill_columns, autofill_columns_types)
text = text.replace(set_cell_style, set_cell_style_types)
text = text.replace(set_area_border, set_area_border_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)
text = text.replace(header, header_types) text = text.replace(header, header_types)
if text.find("any") != -1:
print("There are 'unfixed' types. Please check.")
exit(1)
return text return text

View File

@@ -5,8 +5,8 @@ use wasm_bindgen::{
use ironcalc_base::{ use ironcalc_base::{
expressions::{lexer::util::get_tokens as tokenizer, types::Area}, expressions::{lexer::util::get_tokens as tokenizer, types::Area},
types::CellType, types::{CellType, Style},
UserModel as BaseModel, BorderArea, UserModel as BaseModel,
}; };
fn to_js_error(error: String) -> JsError { fn to_js_error(error: String) -> JsError {
@@ -71,12 +71,12 @@ impl Model {
} }
#[wasm_bindgen(js_name = "flushSendQueue")] #[wasm_bindgen(js_name = "flushSendQueue")]
pub fn flush_send_queue(&mut self) -> String { pub fn flush_send_queue(&mut self) -> Vec<u8> {
self.model.flush_send_queue() self.model.flush_send_queue()
} }
#[wasm_bindgen(js_name = "applyExternalDiffs")] #[wasm_bindgen(js_name = "applyExternalDiffs")]
pub fn apply_external_diffs(&mut self, diffs: &str) -> Result<(), JsError> { pub fn apply_external_diffs(&mut self, diffs: &[u8]) -> Result<(), JsError> {
self.model.apply_external_diffs(diffs).map_err(to_js_error) self.model.apply_external_diffs(diffs).map_err(to_js_error)
} }
@@ -102,6 +102,13 @@ impl Model {
self.model.rename_sheet(sheet, name).map_err(to_js_error) self.model.rename_sheet(sheet, name).map_err(to_js_error)
} }
#[wasm_bindgen(js_name = "setSheetColor")]
pub fn set_sheet_color(&mut self, sheet: u32, color: &str) -> Result<(), JsError> {
self.model
.set_sheet_color(sheet, color)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "rangeClearAll")] #[wasm_bindgen(js_name = "rangeClearAll")]
pub fn range_clear_all( pub fn range_clear_all(
&mut self, &mut self,
@@ -264,6 +271,12 @@ impl Model {
.map(|x| serde_wasm_bindgen::to_value(&x).unwrap()) .map(|x| serde_wasm_bindgen::to_value(&x).unwrap())
} }
#[wasm_bindgen(js_name = "onPasteStyles")]
pub fn on_paste_styles(&mut self, styles: JsValue) -> Result<(), JsError> {
let styles: &Vec<Vec<Style>> = &serde_wasm_bindgen::from_value(styles).unwrap();
self.model.on_paste_styles(styles).map_err(to_js_error)
}
#[wasm_bindgen(js_name = "getCellType")] #[wasm_bindgen(js_name = "getCellType")]
pub fn get_cell_type(&self, sheet: u32, row: i32, column: i32) -> Result<i32, JsError> { pub fn get_cell_type(&self, sheet: u32, row: i32, column: i32) -> Result<i32, JsError> {
Ok( Ok(
@@ -286,4 +299,178 @@ impl Model {
pub fn get_worksheets_properties(&self) -> JsValue { pub fn get_worksheets_properties(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.model.get_worksheets_properties()).unwrap() serde_wasm_bindgen::to_value(&self.model.get_worksheets_properties()).unwrap()
} }
#[wasm_bindgen(js_name = "getSelectedSheet")]
pub fn get_selected_sheet(&self) -> u32 {
self.model.get_selected_sheet()
}
#[wasm_bindgen(js_name = "getSelectedCell")]
pub fn get_selected_cell(&self) -> Vec<i32> {
let (sheet, row, column) = self.model.get_selected_cell();
vec![sheet as i32, row, column]
}
#[wasm_bindgen(js_name = "getSelectedView")]
pub fn get_selected_view(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.model.get_selected_view()).unwrap()
}
#[wasm_bindgen(js_name = "setSelectedSheet")]
pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<(), JsError> {
self.model.set_selected_sheet(sheet).map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setSelectedCell")]
pub fn set_selected_cell(&mut self, row: i32, column: i32) -> Result<(), JsError> {
self.model
.set_selected_cell(row, column)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setSelectedRange")]
pub fn set_selected_range(
&mut self,
start_row: i32,
start_column: i32,
end_row: i32,
end_column: i32,
) -> Result<(), JsError> {
self.model
.set_selected_range(start_row, start_column, end_row, end_column)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setTopLeftVisibleCell")]
pub fn set_top_left_visible_cell(
&mut self,
top_row: i32,
top_column: i32,
) -> Result<(), JsError> {
self.model
.set_top_left_visible_cell(top_row, top_column)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setShowGridLines")]
pub fn set_show_grid_lines(
&mut self,
sheet: u32,
show_grid_lines: bool,
) -> Result<(), JsError> {
self.model
.set_show_grid_lines(sheet, show_grid_lines)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "getShowGridLines")]
pub fn get_show_grid_lines(&mut self, sheet: u32) -> Result<bool, JsError> {
self.model.get_show_grid_lines(sheet).map_err(to_js_error)
}
#[wasm_bindgen(js_name = "autoFillRows")]
pub fn auto_fill_rows(&mut self, source_area: JsValue, to_row: i32) -> Result<(), JsError> {
let area: Area =
serde_wasm_bindgen::from_value(source_area).map_err(|e| to_js_error(e.to_string()))?;
self.model
.auto_fill_rows(&area, to_row)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "autoFillColumns")]
pub fn auto_fill_columns(
&mut self,
source_area: JsValue,
to_column: i32,
) -> Result<(), JsError> {
let area: Area =
serde_wasm_bindgen::from_value(source_area).map_err(|e| to_js_error(e.to_string()))?;
self.model
.auto_fill_columns(&area, to_column)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onArrowRight")]
pub fn on_arrow_right(&mut self) -> Result<(), JsError> {
self.model.on_arrow_right().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onArrowLeft")]
pub fn on_arrow_left(&mut self) -> Result<(), JsError> {
self.model.on_arrow_left().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onArrowUp")]
pub fn on_arrow_up(&mut self) -> Result<(), JsError> {
self.model.on_arrow_up().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onArrowDown")]
pub fn on_arrow_down(&mut self) -> Result<(), JsError> {
self.model.on_arrow_down().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onPageDown")]
pub fn on_page_down(&mut self) -> Result<(), JsError> {
self.model.on_page_down().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onPageUp")]
pub fn on_page_up(&mut self) -> Result<(), JsError> {
self.model.on_page_up().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setWindowWidth")]
pub fn set_window_width(&mut self, window_width: f64) {
self.model.set_window_width(window_width);
}
#[wasm_bindgen(js_name = "setWindowHeight")]
pub fn set_window_height(&mut self, window_height: f64) {
self.model.set_window_height(window_height);
}
#[wasm_bindgen(js_name = "getScrollX")]
pub fn get_scroll_x(&self) -> Result<f64, JsError> {
self.model.get_scroll_x().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "getScrollY")]
pub fn get_scroll_y(&self) -> Result<f64, JsError> {
self.model.get_scroll_y().map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onExpandSelectedRange")]
pub fn on_expand_selected_range(&mut self, key: &str) -> Result<(), JsError> {
self.model
.on_expand_selected_range(key)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "onAreaSelecting")]
pub fn on_area_selecting(
&mut self,
target_row: i32,
target_column: i32,
) -> Result<(), JsError> {
self.model
.on_area_selecting(target_row, target_column)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setAreaWithBorder")]
pub fn set_area_with_border(
&mut self,
area: JsValue,
border_area: JsValue,
) -> Result<(), JsError> {
let range: Area =
serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?;
let border: BorderArea =
serde_wasm_bindgen::from_value(border_area).map_err(|e| to_js_error(e.to_string()))?;
self.model
.set_area_with_border(&range, &border)
.map_err(|e| to_js_error(e.to_string()))?;
Ok(())
}
} }

View File

@@ -119,5 +119,14 @@ test("floating column numbers get truncated", () => {
assert.strictEqual(model.getRowHeight(0, 5), 100.5); assert.strictEqual(model.getRowHeight(0, 5), 100.5);
}); });
test("autofill", () => {
const model = new Model('en', 'UTC');
model.setUserInput(0, 1, 1, "23");
model.autoFillRows({sheet: 0, row: 1, column: 1, width: 1, height: 1}, 2);
const result = model.getFormattedCellValue(0, 2, 1);
assert.strictEqual(result, "23");
});

View File

@@ -6,6 +6,24 @@ export interface Area {
height: number; height: number;
} }
export enum BorderType {
All = "All",
Inner = "Inner",
Outer = "Outer",
Top = "Top",
Right = "Right",
Bottom = "Bottom",
Left = "Left",
CenterH = "CenterH",
CenterV = "CenterV",
None = "None",
}
export interface BorderArea {
item: BorderItem;
type: BorderType;
}
type ErrorType = type ErrorType =
| "REF" | "REF"
| "NAME" | "NAME"
@@ -115,19 +133,19 @@ interface CellStyleFont {
scheme: string; scheme: string;
} }
export enum BorderType { // export enum BorderType {
BorderAll, // BorderAll,
BorderInner, // BorderInner,
BorderCenterH, // BorderCenterH,
BorderCenterV, // BorderCenterV,
BorderOuter, // BorderOuter,
BorderNone, // BorderNone,
BorderTop, // BorderTop,
BorderRight, // BorderRight,
BorderBottom, // BorderBottom,
BorderLeft, // BorderLeft,
None, // None,
} // }
export interface BorderOptions { export interface BorderOptions {
color: string; color: string;
@@ -192,3 +210,12 @@ export interface CellStyle {
num_fmt: string; num_fmt: string;
alignment?: Alignment; alignment?: Alignment;
} }
export interface SelectedView {
sheet: number;
row: number;
column: number;
range: [number, number, number, number];
top_row: number;
left_column: number;
}

3
webapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/*
dist/*
example.json

21
webapp/.storybook/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
'@storybook/addon-mdx-gfm',
'@chromatic-com/storybook'
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
};
export default config;

View File

@@ -0,0 +1,29 @@
import type { Preview } from '@storybook/react';
import i18n from '../src/i18n';
import { I18nextProvider } from 'react-i18next';
import React from 'react';
const withI18next = (Story: any) => {
return (
<I18nextProvider i18n={i18n}>
<Story />
</I18nextProvider>
);
};
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export const decorators = [withI18next];
export default preview;

21
webapp/README.md Normal file
View File

@@ -0,0 +1,21 @@
# IronCalc Web App
## Build
```bash
npm install
```
## Local development
```bash
npm run dev
```
# Deploy
```bash
npm install
npm run build
```

BIN
webapp/example.ic Normal file

Binary file not shown.

16
webapp/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <meta name="theme-color" content="#1bb566"> -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2994A" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
<title>IronCalc Spreadsheet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

15
webapp/jest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Config } from "jest";
// import {defaults} from 'jest-config';
const config: Config = {
// testMatch:["**.jest.mjs"],
moduleFileExtensions: ["js", "ts", "mts", "mjs"],
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
moduleNameMapper: {
"^@ironcalc/wasm$": "<rootDir>/node_modules/@ironcalc/nodejs/"
},
};
export default config;

16992
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
webapp/package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "frontend",
"private": true,
"version": "0.1.3",
"type": "module",
"scripts": {
"restore": "cp node_modules/@ironcalc/wasm/wasm_bg.wasm node_modules/.vite/deps/",
"dev": "vite",
"test": "jest",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@ironcalc/wasm": "file:../bindings/wasm/pkg",
"@mui/material": "^5.15.15",
"@storybook/test": "^8.0.8",
"i18next": "^23.11.1",
"lucide-react": "^0.375.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-i18next": "^13.5.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.3.2",
"@storybook/addon-essentials": "^8.0.8",
"@storybook/addon-interactions": "^8.0.8",
"@storybook/addon-links": "^8.0.8",
"@storybook/addon-mdx-gfm": "^8.0.8",
"@storybook/addon-onboarding": "^8.0.8",
"@storybook/blocks": "^8.0.8",
"@storybook/react": "^8.0.8",
"@storybook/react-vite": "^8.0.8",
"@types/jest": "^29.5.12",
"@types/react": "^18.2.75",
"@types/react-dom": "^18.2.24",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"eslint-plugin-storybook": "^0.6.15",
"jest": "^29.7.0",
"storybook": "^8.0.8",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vite-plugin-svgr": "^4.2.0"
}
}

6
webapp/src/App.css Normal file
View File

@@ -0,0 +1,6 @@
#root {
position: absolute;
inset: 10px;
border: 1px solid #AAA;
border-radius: 4px;
}

37
webapp/src/App.tsx Normal file
View File

@@ -0,0 +1,37 @@
import "./App.css";
import Workbook from "./components/workbook";
import "./i18n";
import { useEffect, useState } from "react";
import init, { Model } from "@ironcalc/wasm";
import { WorkbookState } from "./components/workbookState";
function App() {
const [model, setModel] = useState<Model | null>(null);
const [workbookState, setWorkbookState] = useState<WorkbookState | null>(
null
);
useEffect(() => {
async function start() {
await init();
const model_bytes = new Uint8Array(await (await fetch("./example.ic")).arrayBuffer());
const _model = Model.from_bytes(model_bytes);
// const _model = new Model("en", "UTC");
if (!model) setModel(_model);
if (!workbookState) setWorkbookState(new WorkbookState());
}
start();
}, []);
if (!model || !workbookState) {
return <div>Loading</div>;
}
// We could use context for model, but the problem is that it should initialized to null.
// Passing the property down makes sure it is always defined.
return (
<Workbook model={model} workbookState={workbookState} />
);
}
export default App;

View File

@@ -0,0 +1,12 @@
# Keyboard and mouse events architecture
This document describes the architecture of the keyboard navigation and mouse events in IronCalc Web
There are two modes for mouse events:
* Normal mode: clicking a cell selects it, clicking on a sheet opens it
* Browse mode: clicking on a cell updates the formula, etc
While in browse mode some mouse events might end the browse mode
We follow Excel's way of navigating a spreadsheet

View File

@@ -0,0 +1,18 @@
export const headerCornerBackground = '#FFF';
export const headerTextColor = '#333';
export const headerBackground = '#FFF';
export const headerGlobalSelectorColor = '#EAECF4';
export const headerSelectedBackground = '#EEEEEE';
export const headerFullSelectedBackground = '#D3D6E9';
export const headerSelectedColor = '#333';
export const headerBorderColor = '#DEE0EF';
export const gridColor = '#D3D6E9';
export const gridSeparatorColor = '#D3D6E9';
export const defaultTextColor = '#2E414D';
export const outlineColor = '#F2994A';
export const outlineBackgroundColor = '#F2994A1A';
export const LAST_COLUMN = 16_384;
export const LAST_ROW = 1_048_576;

View File

@@ -0,0 +1,23 @@
export interface Cell {
row: number;
column: number;
}
export interface Area {
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
}
export interface SheetArea extends Area {
sheet: number;
color: string;
}
interface AreaWithBorderInterface extends Area {
border: "left" | "top" | "right" | "bottom";
}
export type AreaWithBorder = AreaWithBorderInterface | null;

View File

@@ -0,0 +1,396 @@
const letters = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
interface Reference {
row: number;
column: number;
absoluteRow: boolean;
absoluteColumn: boolean;
}
export function referenceToString(rf: Reference): string {
const absC = rf.absoluteColumn ? '$' : '';
const absR = rf.absoluteRow ? '$' : '';
return absC + columnNameFromNumber(rf.column) + absR + rf.row;
}
export function columnNameFromNumber(column: number): string {
let columnName = '';
let index = column;
while (index > 0) {
columnName = `${letters[(index - 1) % 26]}${columnName}`;
index = Math.floor((index - 1) / 26);
}
return columnName;
}
export function columnNumberFromName(columnName: string): number {
let column = 0;
for (const character of columnName) {
const index = (character.codePointAt(0) ?? 0) - 64;
column = column * 26 + index;
}
return column;
}
// EqualTo Color Palette
export function getColor(index: number, alpha = 1): string {
const colors = [
{
name: 'Cyan',
rgba: [89, 185, 188, 1],
hex: '#59B9BC',
},
{
name: 'Flamingo',
rgba: [236, 87, 83, 1],
hex: '#EC5753',
},
{
hex: '#3358B7',
rgba: [51, 88, 183, 1],
name: 'Blue',
},
{
hex: '#F8CD3C',
rgba: [248, 205, 60, 1],
name: 'Yellow',
},
{
hex: '#3BB68A',
rgba: [59, 182, 138, 1],
name: 'Emerald',
},
{
hex: '#523E93',
rgba: [82, 62, 147, 1],
name: 'Violet',
},
{
hex: '#A23C52',
rgba: [162, 60, 82, 1],
name: 'Burgundy',
},
{
hex: '#8CB354',
rgba: [162, 60, 82, 1],
name: 'Wasabi',
},
{
hex: '#D03627',
rgba: [208, 54, 39, 1],
name: 'Red',
},
{
hex: '#1B717E',
rgba: [27, 113, 126, 1],
name: 'Teal',
},
];
if (alpha === 1) {
return colors[index % 10].hex;
}
const { rgba } = colors[index % 10];
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})`;
}
export function mergedAreas(area1: Area, area2: Area): Area {
return {
rowStart: Math.min(area1.rowStart, area2.rowStart, area1.rowEnd, area2.rowEnd),
rowEnd: Math.max(area1.rowStart, area2.rowStart, area1.rowEnd, area2.rowEnd),
columnStart: Math.min(area1.columnStart, area2.columnStart, area1.columnEnd, area2.columnEnd),
columnEnd: Math.max(area1.columnStart, area2.columnStart, area1.columnEnd, area2.columnEnd),
};
}
export function getExpandToArea(area: Area, cell: Cell): AreaWithBorder {
let { rowStart, rowEnd, columnStart, columnEnd } = area;
if (rowStart > rowEnd) {
[rowStart, rowEnd] = [rowEnd, rowStart];
}
if (columnStart > columnEnd) {
[columnStart, columnEnd] = [columnEnd, columnStart];
}
const { row, column } = cell;
if (row <= rowEnd && row >= rowStart && column >= columnStart && column <= columnEnd) {
return null;
}
// Two rules:
// * The extendTo area must be larger than the selected area
// * The extendTo area must be of the same width or the same height as the selected area
if (row >= rowEnd && column >= columnStart) {
// Normal case: we are expanding down and right
if (row - rowEnd > column - columnEnd) {
// Expanding by rows (down)
return {
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
border: 'top',
};
}
// expanding by columns (right)
return {
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
border: 'left',
};
}
if (row >= rowEnd && column <= columnStart) {
// We are expanding down and left
if (row - rowEnd > columnStart - column) {
// Expanding by rows (down)
return {
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
border: 'top',
};
}
// Expanding by columns (left)
return {
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart - 1,
border: 'right',
};
}
if (row <= rowEnd && column >= columnEnd) {
// We are expanding up and right
if (rowStart - row > column - columnEnd) {
// Expanding by rows (up)
return {
rowStart: row,
rowEnd: rowStart - 1,
columnStart,
columnEnd,
border: 'bottom',
};
}
// Expanding by columns (right)
return {
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
border: 'left',
};
}
if (row <= rowEnd && column <= columnStart) {
// We are expanding up and left
if (rowStart - row > columnStart - column) {
// Expanding by rows (up)
return {
rowStart: row,
rowEnd: rowStart - 1,
columnStart,
columnEnd,
border: 'bottom',
};
}
// Expanding by columns (left)
return {
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart - 1,
border: 'right',
};
}
return null;
}
/**
* Returns true if the keypress should start editing
*/
export function isEditingKey(key: string): boolean {
if (key.length !== 1) {
return false;
}
const code = key.codePointAt(0) ?? 0;
if (code > 0 && code < 255) {
return true;
}
return false;
}
// / Common types
export interface Area {
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
}
interface AreaWithBorderInterface extends Area {
border: 'left' | 'top' | 'right' | 'bottom';
}
export type AreaWithBorder = AreaWithBorderInterface | null;
export interface Cell {
row: number;
column: number;
}
export interface ScrollPosition {
left: number;
top: number;
}
export interface StateSettings {
selectedCell: Cell;
selectedArea: Area;
scrollPosition: ScrollPosition;
extendToArea: AreaWithBorder;
}
export type Dispatch<A> = (value: A) => void;
export type SetStateAction<S> = S | ((prevState: S) => S);
export enum FocusType {
Cell = 'cell',
FormulaBar = 'formula-bar',
}
/**
* In Excel there are two "modes" of editing
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
* * `edit`: If you double click on a cell or click in the cell while editing.
* In this mode arrow keys will move within the cell.
*
* In a formula bar mode is always `edit`.
*/
export type CellEditMode = 'init' | 'edit';
export interface CellEditingType {
/**
* ID of cell editing. Useful when one edit transforms into another and some code needs to run
* when target changes.
*
* Due to problems with focus management (see #339) it's possible to start a new cell editing
* without properly cleaning up previous one (lose focus in workbook, regain focus NOT in
* the input and then use the keyboard.
*/
id: number;
sheet: number;
row: number;
column: number;
text: string;
base: string;
mode: CellEditMode;
focus: FocusType;
}
export type NavigationKey = 'ArrowRight' | 'ArrowLeft' | 'ArrowDown' | 'ArrowUp' | 'Home' | 'End';
export const isNavigationKey = (key: string): key is NavigationKey =>
['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key);
function nameNeedsQuoting(name: string): boolean {
const chars = [' ', '(', ')', "'", '$', ',', ';', '-', '+', '{', '}'];
const l = chars.length;
for (let index = 0; index < l; index += 1) {
if (name.includes(chars[index])) {
return true;
}
}
return false;
}
// FIXME: We should use the function of a similar name in the rust code.
export const quoteSheetName = (name: string): string => {
if (nameNeedsQuoting(name)) {
return `'${name.replace("'", "''")}'`;
}
return name;
};
export function cellReprToRowColumn(cellRepr: string): { row: number; column: number } {
let row = 0;
let column = 0;
for (const character of cellRepr) {
if (Number.isNaN(Number.parseInt(character, 10))) {
column *= 26;
const characterCode = character.codePointAt(0);
const ACharacterCode = 'A'.codePointAt(0);
if (typeof characterCode === 'undefined' || typeof ACharacterCode === 'undefined') {
throw new TypeError('Failed to find character code');
}
const deltaCodes = characterCode - ACharacterCode;
if (deltaCodes < 0) {
throw new Error('Incorrect character');
}
column += deltaCodes + 1;
} else {
row *= 10;
row += Number.parseInt(character, 10);
}
}
return { row, column };
}
export const getMessageCellText = (
cell: string,
getMessageSheetNumber: (sheet: string) => number | undefined,
getCellText?: (sheet: number, row: number, column: number) => string | undefined,
) => {
const messageMatch = /^=?(?<sheet>\w+)!(?<cell>\w+)/.exec(cell);
if (messageMatch && messageMatch.groups) {
const messageSheet = getMessageSheetNumber(messageMatch.groups.sheet);
const dynamicIconCell = cellReprToRowColumn(messageMatch.groups.cell);
if (messageSheet !== undefined && getCellText) {
return getCellText(messageSheet, dynamicIconCell.row, dynamicIconCell.column) || '';
}
}
return '';
};
export const getCellAddress = (selectedArea: Area, selectedCell?: Cell) => {
const isSingleCell =
selectedArea.rowStart === selectedArea.rowEnd &&
selectedArea.columnEnd === selectedArea.columnStart;
return isSingleCell && selectedCell
? `${columnNameFromNumber(selectedCell.column)}${selectedCell.row}`
: `${columnNameFromNumber(selectedArea.columnStart)}${
selectedArea.rowStart
}:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`;
};
export enum Border {
Top = 'top',
Bottom = 'bottom',
Right = 'right',
Left = 'left',
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BorderBottomIcon,
BorderCenterHIcon,
BorderCenterVIcon,
BorderInnerIcon,
BorderLeftIcon,
BorderOuterIcon,
BorderRightIcon,
BorderTopIcon,
BorderNoneIcon,
BorderStyleIcon,
} from "../icons";
import ColorPicker from "./colorPicker";
import Popover, { PopoverOrigin } from "@mui/material/Popover";
import {
Check,
ChevronDown,
Grid2X2 as BorderAllIcon,
PencilLine,
ChevronRight,
} from "lucide-react";
import { styled } from "@mui/material/styles";
import { theme } from "../theme";
import { BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm";
type BorderPickerProps = {
className?: string;
onChange: (border: BorderOptions) => void;
anchorEl: React.RefObject<HTMLElement>;
anchorOrigin?: PopoverOrigin;
transformOrigin?: PopoverOrigin;
open: boolean;
};
const BorderPicker = (properties: BorderPickerProps) => {
const { t } = useTranslation();
const [borderSelected, setBorderSelected] = useState(BorderType.None);
const [borderColor, setBorderColor] = useState("#000000");
const [borderStyle, setBorderStyle] = useState(BorderStyle.Thin);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const [stylePickerOpen, setStylePickerOpen] = useState(false);
const closePicker = (): void => {
properties.onChange({
color: borderColor,
style: borderStyle,
border: borderSelected,
});
};
const borderColorButton = useRef(null);
const borderStyleButton = useRef(null);
return (
<>
<StyledPopover
open={properties.open}
onClose={(): void => closePicker()}
anchorEl={properties.anchorEl.current}
anchorOrigin={
properties.anchorOrigin || { vertical: "bottom", horizontal: "left" }
}
transformOrigin={
properties.transformOrigin || { vertical: "top", horizontal: "left" }
}
>
<BorderPickerDialog>
<Borders>
<Line>
<Button
type="button"
$pressed={borderSelected === BorderType.All}
onClick={() => {
if (borderSelected === BorderType.All) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.All);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderAllIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.Inner}
onClick={() => {
if (borderSelected === BorderType.Inner) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.Inner);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderInnerIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.CenterH}
onClick={() => {
if (borderSelected === BorderType.CenterH) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.CenterH);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderCenterHIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.CenterV}
onClick={() => {
if (borderSelected === BorderType.CenterV) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.CenterV);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderCenterVIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.Outer}
onClick={() => {
if (borderSelected === BorderType.Outer) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.Outer);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderOuterIcon />
</Button>
</Line>
<Line>
<Button
type="button"
$pressed={borderSelected === BorderType.None}
onClick={() => {
if (borderSelected === BorderType.None) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.None);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderNoneIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.Top}
onClick={() => {
if (borderSelected === BorderType.Top) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.Top);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderTopIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.Right}
onClick={() => {
if (borderSelected === BorderType.Right) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.Right);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderRightIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.Bottom}
onClick={() => {
if (borderSelected === BorderType.Bottom) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.Bottom);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderBottomIcon />
</Button>
<Button
type="button"
$pressed={borderSelected === BorderType.Left}
onClick={() => {
if (borderSelected === BorderType.Left) {
setBorderSelected(BorderType.None);
} else {
setBorderSelected(BorderType.Left);
}
}}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderLeftIcon />
</Button>
</Line>
</Borders>
<Divider />
<Styles>
<ButtonWrapper onClick={() => setColorPickerOpen(true)}>
<Button
type="button"
$pressed={false}
disabled={false}
ref={borderColorButton}
title={t("workbook.toolbar.borders_button_title")}
>
<PencilLine />
</Button>
<div style={{flexGrow:2}}>Border color</div>
<ChevronRightStyled />
</ButtonWrapper>
<ButtonWrapper onClick={() => setStylePickerOpen(true)} ref={borderStyleButton}>
<Button
type="button"
$pressed={false}
disabled={false}
title={t("workbook.toolbar.borders_button_title")}
>
<BorderStyleIcon />
</Button>
<div style={{flexGrow:2}}>Border style</div>
<ChevronRightStyled />
</ButtonWrapper>
</Styles>
</BorderPickerDialog>
<ColorPicker
color={borderColor}
onChange={(color): void => {
setBorderColor(color);
setColorPickerOpen(false);
}}
anchorEl={borderColorButton}
open={colorPickerOpen}
/>
<StyledPopover
open={stylePickerOpen}
onClose={(): void => {
setStylePickerOpen(false);
}}
anchorEl={borderStyleButton.current}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: 38, horizontal: -6 }}
>
<BorderStyleDialog>
<LineWrapper
onClick={() => {
setBorderStyle(BorderStyle.Dashed);
setStylePickerOpen(false);
}}
$checked={borderStyle === BorderStyle.None}
>
<BorderDescription>None</BorderDescription>
<NoneLine />
</LineWrapper>
<LineWrapper
onClick={() => {
setBorderStyle(BorderStyle.Thin);
setStylePickerOpen(false);
}}
$checked={borderStyle === BorderStyle.Thin}
>
<BorderDescription>Thin</BorderDescription>
<SolidLine />
</LineWrapper>
<LineWrapper
onClick={() => {
setBorderStyle(BorderStyle.Medium);
setStylePickerOpen(false);
}}
$checked={borderStyle === BorderStyle.Medium}
>
<BorderDescription>Medium</BorderDescription>
<MediumLine />
</LineWrapper>
<LineWrapper
onClick={() => {
setBorderStyle(BorderStyle.Thick);
setStylePickerOpen(false);
}}
$checked={borderStyle === BorderStyle.Thick}
>
<BorderDescription>Thick</BorderDescription>
<ThickLine />
</LineWrapper>
<LineWrapper
onClick={() => {
setBorderStyle(BorderStyle.Dotted);
setStylePickerOpen(false);
}}
$checked={borderStyle === BorderStyle.Dotted}
>
<BorderDescription>Dotted</BorderDescription>
<DottedLine />
</LineWrapper>
<LineWrapper
onClick={() => {
setBorderStyle(BorderStyle.Dashed);
setStylePickerOpen(false);
}}
$checked={borderStyle === BorderStyle.Dashed}
>
<BorderDescription>Dashed</BorderDescription>
<DashedLine />
</LineWrapper>
<LineWrapper
onClick={() => {
setBorderStyle(BorderStyle.Dashed);
setStylePickerOpen(false);
}}
$checked={borderStyle === BorderStyle.Double}
>
<BorderDescription>Double</BorderDescription>
<DoubleLine />
</LineWrapper>
</BorderStyleDialog>
</StyledPopover>
</StyledPopover>
</>
);
};
type LineWrapperProperties = { $checked: boolean };
const LineWrapper = styled("div")<LineWrapperProperties>`
display: flex;
flex-direction: row;
align-items: center;
background-color: ${({ $checked }): string => {
if ($checked) {
return '#EEEEEE;';
} else {
return 'inherit;';
}
}};
&:hover {
border: 1px solid #EEEEEE;
}
padding:8px;
cursor: pointer;
border-radius: 4px;
border: 1px solid white;
`;
const CheckIconWrapper = styled("div")`
width: 12px;
`;
type CheckIconProperties = { $checked: boolean };
const CheckIcon = styled("div")<CheckIconProperties>`
width: 2px;
background-color: #EEE;
height: 28px;
visibility: ${({ $checked }): string => {
if ($checked) {
return "visible";
}
return "hidden";
}};
`;
const NoneLine = styled("div")`
width: 68px;
border-top: 1px solid #E0E0E0;
`;
const SolidLine = styled("div")`
width: 68px;
border-top: 1px solid #333333;
`;
const MediumLine = styled("div")`
width: 68px;
border-top: 2px solid #333333;
`;
const ThickLine = styled("div")`
width: 68px;
border-top: 3px solid #333333;
`;
const DashedLine = styled("div")`
width: 68px;
border-top: 1px dashed #333333;
`;
const DottedLine = styled("div")`
width: 68px;
border-top: 1px dotted #333333;
`;
const DoubleLine = styled('div')`
width: 68px;
border-top: 3px double #333333;
`;
const Divider = styled("div")`
display: inline-flex;
heigh: 1px;
border-bottom: 1px solid #EEE;
margin-left: 0px;
margin-right: 0px;
`;
const Borders = styled("div")`
display: flex;
flex-direction: column;
padding-bottom: 4px;
`;
const Styles = styled("div")`
display: flex;
flex-direction: column;
`;
const Line = styled("div")`
display: flex;
flex-direction: row;
align-items: center;
`;
const ButtonWrapper = styled("div")`
display: flex;
flex-direction: row;
align-items: center;
&:hover {
background-color: #EEE;
border-top-color: ${(): string => theme.palette.grey["400"]};
}
cursor: pointer;
padding: 8px
`;
const BorderStyleDialog = styled("div")`
background: ${({ theme }): string => theme.palette.background.default};
padding: 4px;
display: flex;
flex-direction: column;
align-items: center;
`;
const StyledPopover = styled(Popover)`
.MuiPopover-paper {
border-radius: 10px;
border: 0px solid ${({ theme }): string => theme.palette.background.default};
box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
}
.MuiPopover-padding {
padding: 0px;
}
.MuiList-padding {
padding: 0px;
}
font-family: ${({ theme }) => theme.typography.fontFamily};
font-size: 13px;
`;
const BorderPickerDialog = styled("div")`
background: ${({ theme }): string => theme.palette.background.default};
padding: 4px;
display: flex;
flex-direction: column;
`;
const BorderDescription = styled("div")`
width: 70px;
`;
// type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
// const Button = styled.button<TypeButtonProperties>`
// width: 23px;
// height: 23px;
// display: inline-flex;
// align-items: center;
// justify-content: center;
// font-size: 14px;
// border-radius: 2px;
// margin-right: 5px;
// transition: all 0.2s;
// ${({ theme, disabled, $pressed, $underlinedColor }): string => {
// if (disabled) {
// return `
// color: ${theme.palette.grey['600']};
// cursor: default;
// `;
// }
// return `
// border-top: ${$underlinedColor ? '3px solid #FFF' : 'none'};
// border-bottom: ${$underlinedColor ? `3px solid ${$underlinedColor}` : 'none'};
// color: ${theme.palette.text.primary};
// background-color: ${$pressed ? theme.palette.grey['600'] : '#FFF'};
// &:hover {
// background-color: ${theme.palette.grey['400']};
// border-top-color: ${theme.palette.grey['400']};
// }
// `;
// }}
// `;
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
const Button = styled("button")<TypeButtonProperties>(
({ disabled, $pressed, $underlinedColor }) => {
let result: Record<string, any> = {
width: "24px",
height: "24px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
// fontSize: "26px",
border: "0px solid #fff",
borderRadius: "2px",
marginRight: "5px",
transition: "all 0.2s",
cursor: "pointer",
padding: "0px",
};
if (disabled) {
result.color = theme.palette.grey["600"];
result.cursor = "default";
} else {
result.borderTop = $underlinedColor ? "3px solid #FFF" : "none";
result.borderBottom = $underlinedColor
? `3px solid ${$underlinedColor}`
: "none";
(result.color = "#21243A"),
(result.backgroundColor = $pressed
? theme.palette.grey["600"]
: "inherit");
result["&:hover"] = {
backgroundColor: "#F1F2F8",
borderTopColor: "#F1F2F8",
};
result["svg"] = {
width: "16px",
height: "16px",
};
}
return result;
}
);
const ChevronRightStyled = styled(ChevronRight)`
width: 16px;
height: 16px;
`;
export default BorderPicker;

View File

@@ -0,0 +1,262 @@
import styled from "@emotion/styled";
import Popover, { PopoverOrigin } from "@mui/material/Popover";
import React, { useEffect, useRef, useState } from "react";
import { HexColorInput, HexColorPicker } from "react-colorful";
import { theme } from "../theme";
type ColorPickerProps = {
className?: string;
color: string;
onChange: (color: string) => void;
anchorEl: React.RefObject<HTMLElement>;
anchorOrigin?: PopoverOrigin;
transformOrigin?: PopoverOrigin;
open: boolean;
};
const colorPickerWidth = 240;
const colorfulHeight = 185; // 150 + 15 + 20
const ColorPicker = (properties: ColorPickerProps) => {
const [color, setColor] = useState<string>(properties.color);
const recentColors = useRef<string[]>([]);
const closePicker = (newColor: string): void => {
const maxRecentColors = 14;
properties.onChange(newColor);
const colors = recentColors.current.filter((c) => c !== newColor);
recentColors.current = [newColor, ...colors].slice(0, maxRecentColors);
};
useEffect(() => {
setColor(properties.color);
}, [properties.color]);
const presetColors = [
"#FFFFFF",
"#1B717E",
"#59B9BC",
"#3BB68A",
"#8CB354",
"#F8CD3C",
"#EC5753",
"#A23C52",
"#D03627",
"#523E93",
"#3358B7",
];
return (
<Popover
open={properties.open}
onClose={(): void => closePicker(color)}
anchorEl={properties.anchorEl.current}
anchorOrigin={
properties.anchorOrigin || { vertical: "bottom", horizontal: "left" }
}
transformOrigin={
properties.transformOrigin || { vertical: "top", horizontal: "left" }
}
>
<ColorPickerDialog>
<HexColorPicker
color={color}
onChange={(newColor): void => {
setColor(newColor);
}}
/>
<ColorPickerInput>
<HexWrapper>
<HexLabel>{"Hex"}</HexLabel>
<HexColorInputBox>
<HashLabel>{"#"}</HashLabel>
<HexColorInput
color={color}
onChange={(newColor): void => {
setColor(newColor);
}}
/>
</HexColorInputBox>
</HexWrapper>
<Swatch $color={color} onClick={(): void => {
closePicker(color);
}} />
</ColorPickerInput>
<HorizontalDivider />
<ColorList>
{presetColors.map((presetColor) => (
<Button
key={presetColor}
$color={presetColor}
onClick={(): void => {
closePicker(presetColor);
}}
/>
))}
</ColorList>
<HorizontalDivider />
<RecentLabel>{"Recent"}</RecentLabel>
<ColorList>
{recentColors.current.map((recentColor) => (
<Button
key={recentColor}
$color={recentColor}
onClick={(): void => {
closePicker(recentColor);
}}
/>
))}
</ColorList>
</ColorPickerDialog>
</Popover>
);
};
const RecentLabel = styled.div`
font-size: 12px;
color: ${theme.palette.text.secondary};
`;
const ColorList = styled.div`
display: flex;
flex-wrap: wrap;
flex-direction: row;
`;
const Button = styled.button<{ $color: string }>`
width: 20px;
height: 20px;
${({ $color }): string => {
if ($color.toUpperCase() === "#FFFFFF") {
return `border: 1px solid ${theme.palette.grey["600"]};`;
}
return `border: 1px solid ${$color};`;
}}
background-color: ${({ $color }): string => {
return $color;
}};
box-sizing: border-box;
margin-top: 10px;
margin-right: 10px;
border-radius: 2px;
`;
const HorizontalDivider = styled.div`
height: 0px;
width: 100%;
border-top: 1px solid ${theme.palette.grey["400"]};
margin-top: 15px;
margin-bottom: 5px;
`;
// const StyledPopover = styled(Popover)`
// .MuiPopover-paper {
// border-radius: 10px;
// border: 0px solid ${theme.palette.background.default};
// box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
// }
// .MuiPopover-padding {
// padding: 0px;
// }
// .MuiList-padding {
// padding: 0px;
// }
// `;
const ColorPickerDialog = styled.div`
background: ${theme.palette.background.default};
width: ${colorPickerWidth}px;
padding: 15px;
display: flex;
flex-direction: column;
& .react-colorful {
height: ${colorfulHeight}px;
width: ${colorPickerWidth}px;
}
& .react-colorful__saturation {
border-bottom: none;
border-radius: 5px;
}
& .react-colorful__hue {
height: 20px;
margin-top: 15px;
border-radius: 5px;
}
& .react-colorful__saturation-pointer {
width: 14px;
height: 14px;
}
& .react-colorful__hue-pointer {
width: 7px;
border-radius: 3px;
}
`;
const HashLabel = styled.div`
margin: auto 0px auto 10px;
font-size: 13px;
color: #7d8ec2;
font-family: ${theme.typography.button.fontFamily};
`;
const HexLabel = styled.div`
margin: auto 10px auto 0px;
font-size: 12px;
display: inline-flex;
font-family: ${theme.typography.button.fontFamily};
`;
const HexColorInputBox = styled.div`
display: inline-flex;
flex-grow: 1;
margin-right: 10px;
width: 140px;
height: 28px;
border: 1px solid ${theme.palette.grey["600"]};
border-radius: 5px;
`;
const HexWrapper = styled.div`
display: flex;
flex-grow: 1;
& input {
min-width: 0px;
border: 0px;
background: ${theme.palette.background.default};
outline: none;
font-family: ${theme.typography.button.fontFamily};
font-size: 12px;
text-transform: uppercase;
text-align: right;
padding-right: 10px;
border-radius: 5px;
}
& input:focus {
border-color: #4298ef;
}
`;
const Swatch = styled.div<{ $color: string }>`
display: inline-flex;
${({ $color }): string => {
if ($color.toUpperCase() === "#FFFFFF") {
return `border: 1px solid ${theme.palette.grey["600"]};`;
}
return `border: 1px solid ${$color};`;
}}
background-color: ${({ $color }): string => $color};
width: 28px;
height: 28px;
border-radius: 5px;
`;
const ColorPickerInput = styled.div`
display: flex;
flex-direction: row;
align-items: center;
margin-top: 15px;
`;
export default ColorPicker;

View File

@@ -0,0 +1,420 @@
import {
CSSProperties,
useCallback,
useEffect,
useState,
KeyboardEvent,
useContext,
} from "react";
import { useRef } from "react";
import EditorContext, { Area } from "./editorContext";
import { getStringRange } from "./util";
/**
* This is the Cell Editor for IronCalc
* I uses a transparent textarea and a styled mask. What you see is the HTML styled content of the mask
* and the caret from the textarea. The alternative would be to have a 'contenteditable' div.
* That turns out to be a much more difficult implementation.
*
* The editor grows horizontally with text if it fits in the screen.
* If it doesn't fit, it wraps and grows vertically. If it doesn't fit vertically it scrolls.
*
* Many keyboard and mouse events are handled gracefully by the textarea in full or in part.
* For example letter key strokes like 'q' or '1' are handled full by the textarea.
* Some keyboard events like "RightArrow" might need to be handled separately and let them bubble up,
* or might be handled by the textarea, depending on the "editor mode".
* Some other like "Enter" we need to intercept and change the normal behaviour.
*/
const commonCSS: CSSProperties = {
fontWeight: "inherit",
fontFamily: "inherit",
fontSize: "inherit",
position: "absolute",
left: 0,
top: 0,
whiteSpace: "pre",
width: "100%",
padding: 0,
};
interface Cell {
sheet: number;
row: number;
column: number;
}
interface EditorOptions {
minimalWidth: number;
minimalHeight: number;
textColor: string;
originalText: string;
getStyledText: (
text: string,
insertRangeText: string
) => {
html: JSX.Element[];
isInReferenceMode: boolean;
};
onEditEnd: (text: string) => void;
display: boolean;
cell: Cell;
sheetNames: string[];
}
// You can either be editing a formula or content.
// When editing content (behaviour is common to Excel and Google Sheets):
// * If you start editing by typing you are in *accept* mode
// * If you start editing by F2 you are in *cruise* mode
// * If you start editing by double click you are in *cruise* mode
// In Google Sheets "Enter" starts editing and puts you in *cruise* mode. We do not do that
// Once you are in cruise mode it is not possible to switch to accept mode
// The only way to go from accept mode to cruise mode is clicking in the content somewhere
// When editing a formula.
// In Google Sheets you are either in insert mode or cruise mode.
// You can get back to accept mode if you delete the whole formula
// In Excel you can be either in insert or accept but if you click in the formula body
// you switch to cruise mode. Once in cruise mode you can go to insert mode by selecting a range.
// Then you are back in accept/insert modes
const Editor = (options: EditorOptions) => {
const {
minimalWidth,
minimalHeight,
textColor,
onEditEnd,
originalText,
display,
cell,
sheetNames,
} = options;
const [width, setWidth] = useState(minimalWidth);
const [height, setHeight] = useState(minimalHeight);
const { editorContext, setEditorContext } = useContext(EditorContext);
const setBaseText = (newText: string) => {
console.log('Calling setBaseText');
setEditorContext((c) => {
return {
...c,
baseText: newText,
};
});
};
const insertRangeText = editorContext.insertRange
? getStringRange(editorContext.insertRange, sheetNames)
: "";
const baseText = editorContext.baseText;
const text = baseText + insertRangeText;
// console.log('baseText', baseText, 'insertRange:', insertRangeText);
const formulaRef = useRef<HTMLDivElement>(null);
const maskRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// useEffect(() => {
// setBaseText(originalText);
// }, [cell]);
const { html: styledFormula, isInReferenceMode } = options.getStyledText(
baseText,
insertRangeText
);
if (display && textareaRef.current) {
textareaRef.current.focus();
}
useEffect(() => {
if (formulaRef.current) {
const scrollWidth = formulaRef.current.scrollWidth;
if (scrollWidth > width) {
setWidth(scrollWidth);
} else if (scrollWidth <= minimalWidth) {
setWidth(minimalWidth);
}
const scrollHeight = formulaRef.current.scrollHeight;
if (scrollHeight > height) {
setHeight(scrollHeight);
}
}
}, [text]);
useEffect(() => {
if (isInReferenceMode) {
setEditorContext((c) => {
return {
...c,
mode: "insert",
};
});
} else {
setEditorContext((c) => {
return {
...c,
mode: "cruise",
insertRange: null,
};
});
}
}, [isInReferenceMode]);
useEffect(() => {
if (display && textareaRef.current) {
textareaRef.current.focus();
}
}, [display]);
// console.log("Ok, this is running", text, editorContext.id);
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
const { key, shiftKey, altKey } = event;
const textarea = textareaRef.current;
const mode = editorContext.mode;
if (!textarea) {
return;
}
switch (key) {
case "Enter": {
if (altKey) {
// new line
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = text.slice(0, start) + "\n" + text.slice(end);
setBaseText(newText);
setTimeout(() => {
textarea.setSelectionRange(start + 1, start + 1);
}, 1);
event.stopPropagation();
event.preventDefault();
return;
} else {
// end edit
onEditEnd(text);
textarea.blur();
// event bubbles up
return;
}
break;
}
case "Escape": {
setBaseText(originalText);
textarea.blur();
event.stopPropagation();
event.preventDefault();
break;
}
case "ArrowLeft": {
if (mode === "accept") {
onEditEnd(text);
textarea.blur();
// event bubbles up
return;
} else if (mode == "insert") {
if (shiftKey) {
// increase the inserted range to the left
if (!editorContext.insertRange) {
setEditorContext((c) => {
return {
...c,
insertRange: {
absoluteColumnEnd: false,
absoluteColumnStart: false,
absoluteRowEnd: false,
absoluteRowStart: false,
sheet: cell.sheet,
rowStart: cell.row,
rowEnd: cell.row,
columnStart: cell.column,
columnEnd: cell.column,
},
};
});
} else {
// const r = insertRage;
// r.columnStart = Math.max(r.columnStart - 1, 1);
// setInsertRange(r);
}
} else {
// move inserted cell to the left
if (!editorContext.insertRange) {
setEditorContext((c) => {
return {
...c,
insertRange: {
absoluteColumnEnd: false,
absoluteColumnStart: false,
absoluteRowEnd: false,
absoluteRowStart: false,
sheet: cell.sheet,
rowStart: cell.row,
rowEnd: cell.row,
columnStart: cell.column,
columnEnd: cell.column,
},
};
});
} else {
setEditorContext((c) => {
const range = c.insertRange as Area;
const row = range.rowStart;
let column = range.columnStart - 1;
if (column < 1) {
column = 1;
}
return {
...c,
insertRange: {
absoluteColumnEnd: false,
absoluteColumnStart: false,
absoluteRowEnd: false,
absoluteRowStart: false,
sheet: range.sheet,
rowStart: row,
rowEnd: row,
columnStart: column,
columnEnd: column,
},
};
});
}
}
event.stopPropagation();
event.preventDefault();
return;
}
// We don't do anything in "cruise mode" and rely on the textarea default behaviour
break;
}
case "ArrowDown": {
if (mode === "accept") {
onEditEnd(text);
textarea.blur();
}
break;
}
case "ArrowRight": {
if (mode === "accept") {
onEditEnd(text);
textarea.blur();
}
break;
}
case "ArrowUp": {
if (mode === "accept") {
onEditEnd(text);
textarea.blur();
}
break;
}
case "Tab": {
onEditEnd(text);
textarea.blur();
// event bubbles up
}
}
if (editorContext.mode === "insert") {
setBaseText(text);
setEditorContext((context) => {
return {
...context,
mode: "cruise",
insertRange: null,
};
});
}
},
[text, editorContext]
);
return (
<div
style={{
position: "relative",
width,
height,
overflow: "hidden",
background: "#FFF",
display: display ? "block" : "none",
}}
onClick={(_event) => {
console.log("Click on wrapper");
}}
onPointerDown={() => {
console.log("On pointer down wrapper");
}}
>
<div
ref={maskRef}
style={{
...commonCSS,
textAlign: "left",
pointerEvents: "none",
height,
}}
onClick={(_event) => {
console.log("Click on mask");
}}
onPointerDown={() => {
console.log("On pointer down mask");
}}
>
<div ref={formulaRef}>{styledFormula}</div>
</div>
<textarea
ref={textareaRef}
rows={1}
style={{
...commonCSS,
color: "transparent",
backgroundColor: "transparent",
caretColor: textColor,
outline: "none",
resize: "none",
border: "none",
height,
}}
spellCheck="false"
value={text}
onChange={(event) => {
console.log("onChange", event.target.value);
setBaseText(event.target.value);
}}
onScroll={() => {
if (maskRef.current && textareaRef.current) {
maskRef.current.style.left = `-${textareaRef.current.scrollLeft}px`;
maskRef.current.style.top = `-${textareaRef.current.scrollTop}px`;
}
}}
onKeyDown={onKeyDown}
onClick={(event) => {
console.log("Setting mode");
setEditorContext((c) => {
return {
...c,
mode: "cruise",
};
});
console.log("here");
// if (display) {
event.stopPropagation();
// }
}}
onBlur={() => {
// on blur
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
></textarea>
</div>
);
};
export default Editor;

View File

@@ -0,0 +1,45 @@
import { Dispatch, SetStateAction, createContext } from "react";
export interface Area {
sheet: number | null;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
absoluteRowStart: boolean;
absoluteRowEnd: boolean;
absoluteColumnStart: boolean;
absoluteColumnEnd: boolean;
}
// Arrow keys behave in different ways depending on the "edit mode":
// * In _cruise_ mode arrowy keys navigate within the editor
// * In _accept_ mode pressing an arrow key will end editing
// * In _insert_ mode arrow keys will change the selected range
export type EditorMode = "cruise" | "accept" | "insert";
export interface EditorState {
mode: EditorMode;
insertRange: null | Area;
baseText: string;
id: number;
}
interface EditorContextType {
editorContext: EditorState;
setEditorContext: Dispatch<
SetStateAction<{ mode: EditorMode; insertRange: null | Area }>
>;
}
const EditorContext = createContext<EditorContextType>({
editorContext: {
mode: "accept",
insertRange: null,
baseText: '',
id: Math.floor(Math.random()*1000),
},
setEditorContext: () => {},
});
export default EditorContext;

View File

@@ -0,0 +1,3 @@
export { default } from './editor';

View File

@@ -0,0 +1,92 @@
type ErrorType =
| 'REF'
| 'NAME'
| 'VALUE'
| 'DIV'
| 'NA'
| 'NUM'
| 'ERROR'
| 'NIMPL'
| 'SPILL'
| 'CALC'
| 'CIRC';
type OpCompareType =
| 'LessThan'
| 'GreaterThan'
| 'Equal'
| 'LessOrEqualThan'
| 'GreaterOrEqualThan'
| 'NonEqual';
type OpSumType = 'Add' | 'Minus';
type OpProductType = 'Times' | 'Divide';
interface ReferenceType {
sheet: string | null;
row: number;
column: number;
absolute_column: boolean;
absolute_row: boolean;
}
interface ParsedReferenceType {
column: number;
row: number;
absolute_column: boolean;
absolute_row: boolean;
}
interface Reference {
Reference: ReferenceType;
}
interface Range {
Range: {
sheet: string | null;
left: ParsedReferenceType;
right: ParsedReferenceType;
};
}
export type TokenType =
| 'Illegal'
| 'Eof'
| { Ident: string }
| { String: string }
| { Boolean: boolean }
| { Number: number }
| { ERROR: ErrorType }
| { COMPARE: OpCompareType }
| { SUM: OpSumType }
| { PRODUCT: OpProductType }
| 'POWER'
| 'LPAREN'
| 'RPAREN'
| 'COLON'
| 'SEMICOLON'
| 'LBRACKET'
| 'RBRACKET'
| 'LBRACE'
| 'RBRACE'
| 'COMMA'
| 'BANG'
| 'PERCENT'
| 'AND'
| Reference
| Range;
export interface MarkedToken {
token: TokenType;
start: number;
end: number;
}
export function tokenIsReferenceType(token: TokenType): token is Reference {
return typeof token === 'object' && 'Reference' in token;
}
export function tokenIsRangeType(token: TokenType): token is Range {
return typeof token === 'object' && 'Range' in token;
}

View File

@@ -0,0 +1,108 @@
import { useCallback, KeyboardEvent } from "react";
import { WorkbookState } from "../workbookState";
import { Model } from "@ironcalc/wasm";
interface Options {
// onMoveCaretToStart: () => void;
// onMoveCaretToEnd: () => void;
// onEditEnd: (delta: { deltaRow: number; deltaColumn: number }) => void;
// onEditEscape: () => void;
// onReferenceCycle: () => void;
// text: string;
// setText: (text: string) => void;
model: Model;
state: WorkbookState;
refresh: () => void;
}
const useEditorKeydown = (
options: Options
): {
onKeyDown: (event: KeyboardEvent) => void;
} => {
const { state, model } = options;
const onKeyDown = useCallback((event: KeyboardEvent) => {
const { key, shiftKey } = event;
const { mode, text } = state.getEditor() ?? { mode: "init", text: "" };
switch (key) {
// case "Enter":
// // options.onEditEnd({ deltaRow: 1, deltaColumn: 0 });
// const { row, column } = state.getSelectedCell();
// const sheet = state.getSelectedSheet();
// model.setUserInput(sheet, row, column, text);
// state.selectCell({ row: row + 1, column });
// event.preventDefault();
// event.stopPropagation();
// options.refresh();
// break;
// case 'ArrowUp': {
// if (mode === 'init') {
// options.onEditEnd({ deltaRow: -1, deltaColumn: 0 });
// } else {
// options.onMoveCaretToStart();
// }
// event.preventDefault();
// event.stopPropagation();
// break;
// }
// case 'ArrowDown': {
// if (mode === 'init') {
// options.onEditEnd({ deltaRow: 1, deltaColumn: 0 });
// } else {
// options.onMoveCaretToEnd();
// }
// event.preventDefault();
// event.stopPropagation();
// break;
// }
// case 'Tab': {
// if (event.shiftKey) {
// options.onEditEnd({ deltaRow: 0, deltaColumn: -1 });
// } else {
// options.onEditEnd({ deltaRow: 0, deltaColumn: 1 });
// }
// event.preventDefault();
// event.stopPropagation();
// break;
// }
// case 'Escape': {
// options.onEditEscape();
// event.preventDefault();
// event.stopPropagation();
// break;
// }
// case 'ArrowLeft': {
// if (mode === 'init') {
// options.onEditEnd({ deltaRow: 0, deltaColumn: -1 });
// event.preventDefault();
// event.stopPropagation();
// }
// break;
// }
// case 'ArrowRight': {
// if (mode === 'init') {
// options.onEditEnd({ deltaRow: 0, deltaColumn: 1 });
// event.preventDefault();
// event.stopPropagation();
// }
// break;
// }
// case 'F4': {
// options.onReferenceCycle();
// event.preventDefault();
// event.stopPropagation();
// break;
// }
default:
break;
}
}, [model, state]);
return { onKeyDown };
};
export default useEditorKeydown;

View File

@@ -0,0 +1,334 @@
import { getTokens } from "@ironcalc/wasm";
import { tokenIsRangeType, tokenIsReferenceType } from "./tokenTypes";
import { Area } from "./editorContext";
const letters = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
];
interface Reference {
row: number;
column: number;
absoluteRow: boolean;
absoluteColumn: boolean;
}
export function referenceToString(rf: Reference): string {
const absC = rf.absoluteColumn ? "$" : "";
const absR = rf.absoluteRow ? "$" : "";
return absC + columnNameFromNumber(rf.column) + absR + rf.row;
}
export function columnNameFromNumber(column: number): string {
let columnName = "";
let index = column;
while (index > 0) {
columnName = `${letters[(index - 1) % 26]}${columnName}`;
index = Math.floor((index - 1) / 26);
}
return columnName;
}
export function columnNumberFromName(columnName: string): number {
let column = 0;
for (const character of columnName) {
const index = (character.codePointAt(0) ?? 0) - 64;
column = column * 26 + index;
}
return column;
}
interface Range {
sheet: number | null;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
absoluteRowStart: boolean;
absoluteRowEnd: boolean;
absoluteColumnStart: boolean;
absoluteColumnEnd: boolean;
}
export function getStringRange(range: Range, sheetNames: string[]) {
const name = range.sheet ? `${sheetNames[range.sheet]}!` : "";
const left = referenceToString({
row: range.rowStart,
column: range.columnStart,
absoluteRow: range.absoluteRowStart,
absoluteColumn: range.absoluteColumnStart,
});
if (
range.rowStart === range.rowEnd &&
range.columnStart === range.columnEnd
) {
return `${name}${left}`;
}
const right = referenceToString({
row: range.rowEnd,
column: range.columnEnd,
absoluteRow: range.absoluteRowEnd,
absoluteColumn: range.absoluteColumnEnd,
});
return `${name}${left}:${right}`;
}
interface ActiveRange {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
color: string;
}
// IronCalc Color Palette
export function getColor(index: number, alpha = 1): string {
const colors = [
{
name: "Cyan",
rgba: [89, 185, 188, 1],
hex: "#59B9BC",
},
{
name: "Flamingo",
rgba: [236, 87, 83, 1],
hex: "#EC5753",
},
{
hex: "#3358B7",
rgba: [51, 88, 183, 1],
name: "Blue",
},
{
hex: "#F8CD3C",
rgba: [248, 205, 60, 1],
name: "Yellow",
},
{
hex: "#3BB68A",
rgba: [59, 182, 138, 1],
name: "Emerald",
},
{
hex: "#523E93",
rgba: [82, 62, 147, 1],
name: "Violet",
},
{
hex: "#A23C52",
rgba: [162, 60, 82, 1],
name: "Burgundy",
},
{
hex: "#8CB354",
rgba: [162, 60, 82, 1],
name: "Wasabi",
},
{
hex: "#D03627",
rgba: [208, 54, 39, 1],
name: "Red",
},
{
hex: "#1B717E",
rgba: [27, 113, 126, 1],
name: "Teal",
},
];
if (alpha === 1) {
return colors[index % 10].hex;
}
const { rgba } = colors[index % 10];
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})`;
}
/**
*
* This function get a formula like `=A1*SUM(B5:C6)` and transforms it to:
*
* `<span>=</span><span>A1</span><span>SUM</span><span>(</span><span>B5:C6</span><span>)</span>`
*
* While also returning the set of ranges [A1, B5:C6] with specific color assignments for each range
*/
export function getFormulaHTML(
text: string,
sheet: number,
sheetList: string[],
insertRage: Area | null,
insertRangeText: string
): {
html: JSX.Element[];
activeRanges: ActiveRange[];
isInReferenceMode: boolean;
} {
let html = [];
const activeRanges: ActiveRange[] = [];
let colorCount = 0;
if (text.startsWith("=")) {
const formula = text.slice(1);
const tokens = getTokens(formula);
const tokenCount = tokens.length;
const usedColors: Record<string, string> = {};
for (let index = 0; index < tokenCount; index += 1) {
const { token, start, end } = tokens[index];
if (tokenIsReferenceType(token)) {
const { sheet: refSheet, row, column } = token.Reference;
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
const key = `${sheetIndex}-${row}-${column}`;
let color = usedColors[key];
if (!color) {
color = getColor(colorCount);
usedColors[key] = color;
colorCount += 1;
}
html.push(
<span key={index} style={{ color }}>
{formula.slice(start, end)}
</span>
);
activeRanges.push({
sheet: sheetIndex,
rowStart: row,
columnStart: column,
rowEnd: row,
columnEnd: column,
color,
});
} else if (tokenIsRangeType(token)) {
let {
sheet: refSheet,
left: { row: rowStart, column: columnStart },
right: { row: rowEnd, column: columnEnd },
} = token.Range;
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
const key = `${sheetIndex}-${rowStart}-${columnStart}:${rowEnd}-${columnEnd}`;
let color = usedColors[key];
if (!color) {
color = getColor(colorCount);
usedColors[key] = color;
colorCount += 1;
}
if (rowStart > rowEnd) {
[rowStart, rowEnd] = [rowEnd, rowStart];
}
if (columnStart > columnEnd) {
[columnStart, columnEnd] = [columnEnd, columnStart];
}
html.push(
<span key={index} style={{ color }}>
{formula.slice(start, end)}
</span>
);
colorCount += 1;
activeRanges.push({
sheet: sheetIndex,
rowStart,
columnStart,
rowEnd,
columnEnd,
color,
});
} else {
html.push(<span key={index}>{formula.slice(start, end)}</span>);
}
}
if (tokenCount > 0) {
const lastToken = tokens[tokens.length - 1];
if (lastToken.end < text.length - 1) {
html.push(
<span key="rest">{text.slice(lastToken.end + 1, text.length)}</span>
);
}
}
html = [<span key="equals">=</span>].concat(html);
} else {
html = [<span key="single">{text}</span>];
}
const isRefMode = isInReferenceMode(text, text.length);
if (isRefMode) {
if (insertRage) {
const color = getColor(colorCount);
activeRanges.push({
sheet: insertRage.sheet || sheet,
rowStart: insertRage.rowStart,
rowEnd: insertRage.rowEnd,
columnStart: insertRage.columnStart,
columnEnd: insertRage.columnEnd,
color,
});
colorCount += 1;
html.push(
<span key="insert-range" style={{ color, textDecoration: "underline" }}>
{insertRangeText}
</span>
);
} else {
html.push(
<span
key="insert-cue"
style={{
border: "1px solid #d5d5d5",
height: "2px",
width: "7px",
borderTop: 0,
display: "inline-block",
}}
/>
);
}
}
// We add a clickable element that spans the rest of the available space
html.push(<span key="spacer" style={{ flexGrow: 1 }}></span>);
return { html, activeRanges, isInReferenceMode: isRefMode };
}
export function isInReferenceMode(text: string, cursor: number): boolean {
// FIXME
// This is a gross oversimplification
// Returns true if both are true:
// 1. Cursor is at the end
// 2. Last char is one of [',', '(', '+', '*', '-', '/', '<', '>', '=', '&']
// This has many false positives like '="1+' and also likely some false negatives
// The right way of doing this is to have a partial parse of the formula tree
// and check if the next token could be a reference
if (!text.startsWith("=")) {
return false;
}
if (text === "=") {
return true;
}
const l = text.length;
const chars = [",", "(", "+", "*", "-", "/", "<", ">", "=", "&"];
if (cursor === l && chars.includes(text[l - 1])) {
return true;
}
return false;
}

View File

@@ -0,0 +1,109 @@
import { useState, useRef, ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { NumberFormats } from './formatUtil';
import { Menu, MenuItem, styled } from '@mui/material';
import FormatPicker from './formatPicker';
type FormatMenuProps = {
children: any; //ReactI18NextChild | Iterable<ReactI18NextChild>;
numFmt: string;
onChange: (numberFmt: string) => void;
onExited?: () => void;
anchorOrigin?: ComponentProps<typeof Menu>['anchorOrigin'];
};
const FormatMenu = (properties: FormatMenuProps) => {
const { t } = useTranslation();
const { onChange } = properties;
const [isMenuOpen, setMenuOpen] = useState(false);
const [isPickerOpen, setPickerOpen] = useState(false);
const anchorElement = useRef<HTMLDivElement>(null);
return (
<>
<ChildrenWrapper onClick={(): void => setMenuOpen(true)} ref={anchorElement}>
{properties.children}
</ChildrenWrapper>
<Menu
open={isMenuOpen}
onClose={(): void => setMenuOpen(false)}
// onExited={properties.onExited}
anchorEl={anchorElement.current}
anchorOrigin={properties.anchorOrigin}
>
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.AUTO)}>
<MenuItemText>{t('toolbar.format_menu.auto')}</MenuItemText>
</MenuItemWrapper>
{/** TODO: Text option that transforms into plain text */}
<MenuDivider />
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.NUMBER)}>
<MenuItemText>{t('toolbar.format_menu.number')}</MenuItemText>
<MenuItemExample>{t('toolbar.format_menu.number_example')}</MenuItemExample>
</MenuItemWrapper>
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.PERCENTAGE)}>
<MenuItemText>{t('toolbar.format_menu.percentage')}</MenuItemText>
<MenuItemExample>{t('toolbar.format_menu.percentage_example')}</MenuItemExample>
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_EUR)}>
<MenuItemText>{t('toolbar.format_menu.currency_eur')}</MenuItemText>
<MenuItemExample>{t('toolbar.format_menu.currency_eur_example')}</MenuItemExample>
</MenuItemWrapper>
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_USD)}>
<MenuItemText>{t('toolbar.format_menu.currency_usd')}</MenuItemText>
<MenuItemExample>{t('toolbar.format_menu.currency_usd_example')}</MenuItemExample>
</MenuItemWrapper>
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_GBP)}>
<MenuItemText>{t('toolbar.format_menu.currency_gbp')}</MenuItemText>
<MenuItemExample>{t('toolbar.format_menu.currency_gbp_example')}</MenuItemExample>
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.DATE_SHORT)}>
<MenuItemText>{t('toolbar.format_menu.date_short')}</MenuItemText>
<MenuItemExample>{t('toolbar.format_menu.date_short_example')}</MenuItemExample>
</MenuItemWrapper>
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.DATE_LONG)}>
<MenuItemText>{t('toolbar.format_menu.date_long')}</MenuItemText>
<MenuItemExample>{t('toolbar.format_menu.date_long_example')}</MenuItemExample>
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper onClick={(): void => setPickerOpen(true)}>
<MenuItemText>{t('toolbar.format_menu.custom')}</MenuItemText>
</MenuItemWrapper>
</Menu>
<FormatPicker
numFmt={properties.numFmt}
onChange={properties.onChange}
open={isPickerOpen}
onClose={(): void => setPickerOpen(false)}
onExited={properties.onExited}
/>
</>
);
};
const MenuItemWrapper = styled(MenuItem)`
display: flex;
justify-content: space-between;
font-size: 14px;
width: 100%;
`;
const ChildrenWrapper = styled('div')`
display: flex;
`;
const MenuDivider = styled('div')``;
const MenuItemText = styled('div')`
color: #000;
`;
const MenuItemExample = styled('div')`
margin-left: 20px;
`;
export default FormatMenu;

View File

@@ -0,0 +1,46 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
type FormatPickerProps = {
className?: string;
open: boolean;
onClose: () => void;
onExited?: () => void;
numFmt: string;
onChange: (numberFmt: string) => void;
};
const FormatPicker = (properties: FormatPickerProps) => {
const { t } = useTranslation();
const [formatCode, setFormatCode] = useState(properties.numFmt);
const onSubmit = (format_code: string): void => {
properties.onChange(format_code);
properties.onClose();
};
return (
<Dialog
open={properties.open}
onClose={properties.onClose}
>
<DialogTitle>{t('num_fmt.title')}</DialogTitle>
<DialogContent dividers>
<TextField
defaultValue={properties.numFmt}
label={t('num_fmt.label')}
name="format_code"
onChange={(event) => setFormatCode(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => onSubmit(formatCode)}>
{t('num_fmt.save')}
</Button>
</DialogActions>
</Dialog>
);
};
export default FormatPicker;

View File

@@ -0,0 +1,36 @@
export function increaseDecimalPlaces(numberFormat: string): string {
// FIXME: Should it be done in the Rust? How should it work?
// Increase decimal places for existing numbers with decimals
const newNumberFormat = numberFormat.replace(/\.0/g, '.00');
// If no decimal places declared, add 0.0
if (!newNumberFormat.includes('.')) {
if (newNumberFormat.includes('0')) {
return newNumberFormat.replace(/0/g, '0.0');
}
if (newNumberFormat.includes('#')) {
return newNumberFormat.replace(/#([^#,]|$)/g, '0.0$1');
}
return '0.0';
}
return newNumberFormat;
}
export function decreaseDecimalPlaces(numberFormat: string): string {
// FIXME: Should it be done in the Rust? How should it work?
// Decrease decimal places for existing numbers with decimals
let newNumberFormat = numberFormat.replace(/\.0/g, '.');
// Fix leftover dots
newNumberFormat = newNumberFormat.replace(/0\.([^0]|$)/, '0$1');
return newNumberFormat;
}
export enum NumberFormats {
AUTO = 'general',
CURRENCY_EUR = '"€"#,##0.00',
CURRENCY_USD = '"$"#,##0.00',
CURRENCY_GBP = '"£"#,##0.00',
DATE_SHORT = 'dd"/"mm"/"yyyy',
DATE_LONG = 'dddd"," mmmm dd"," yyyy',
PERCENTAGE = '0.00%',
NUMBER = '#,##0.00',
}

View File

@@ -0,0 +1,51 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
styled,
TextField,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useState } from "react";
interface FormulaDialogProps {
isOpen: boolean;
close: () => void;
onFormulaChanged: (name: string) => void;
defaultName: string;
}
export const FormulaDialog = (properties: FormulaDialogProps) => {
const { t } = useTranslation();
const [name, setName] = useState(properties.defaultName);
return (
<Dialog open={properties.isOpen} onClose={properties.close}>
<DialogTitle>{t("sheet_rename.title")}</DialogTitle>
<DialogContent dividers>
<TextField
defaultValue={name}
label={t("sheet_rename.label")}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
}}
onChange={(event) => {
setName(event.target.value);
}}
spellCheck="false"
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
properties.onFormulaChanged(name);
}}
>
{t("sheet_rename.rename")}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,132 @@
import { Button, styled } from "@mui/material";
import { ChevronDown } from "lucide-react";
import { Fx } from "../icons";
import { useState } from "react";
import { FormulaDialog } from "./formulaDialog";
type FormulaBarProps = {
cellAddress: string;
formulaValue: string;
onChange: (value: string) => void;
};
const formulaBarHeight = 30;
const headerColumnWidth = 30;
function FormulaBar(properties: FormulaBarProps) {
const [formulaDialogOpen, setFormulaDialogOpen] = useState(false);
const handleCloseFormulaDialog = () => {
setFormulaDialogOpen(false);
};
return (
<Container>
<AddressContainer>
<CellBarAddress>{properties.cellAddress}</CellBarAddress>
<StyledButton>
<ChevronDown />
</StyledButton>
</AddressContainer>
<Divider />
<FormulaContainer>
<FormulaSymbolButton>
<Fx />
</FormulaSymbolButton>
<Editor
onClick={() => {
setFormulaDialogOpen(true);
}}
>
{properties.formulaValue}
</Editor>
</FormulaContainer>
<FormulaDialog
isOpen={formulaDialogOpen}
close={handleCloseFormulaDialog}
defaultName={properties.formulaValue}
onFormulaChanged={(newName) => {
properties.onChange(newName);
setFormulaDialogOpen(false);
}}
/>
</Container>
);
}
const StyledButton = styled(Button)`
width: 15px;
min-width: 0px;
padding: 0px;
color: inherit;
font-weight: inherit;
svg {
width: 15px;
height: 15px;
}
`;
const FormulaSymbolButton = styled(StyledButton)`
margin-right: 8px;
`;
const Divider = styled("div")`
background-color: #e0e0e0;
width: 1px;
height: 20px;
margin-left: 16px;
margin-right: 16px;
`;
const FormulaContainer = styled("div")`
margin-left: 10px;
line-height: 22px;
font-weight: normal;
width: 100%;
height: 22px;
display: flex;
`;
const Container = styled("div")`
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
background: ${(properties): string =>
properties.theme.palette.background.default};
height: ${formulaBarHeight}px;
`;
const AddressContainer = styled("div")`
padding-left: 16px;
color: #333;
font-style: normal;
font-weight: normal;
font-size: 11px;
display: flex;
font-weight: 600;
flex-grow: row;
min-width: ${headerColumnWidth}px;
`;
const CellBarAddress = styled("div")`
width: 100%;
text-align: "center";
`;
const Editor = styled("div")`
position: relative;
width: 100%;
padding: 0px;
border-width: 0px;
outline: none;
resize: none;
white-space: pre-wrap;
vertical-align: bottom;
overflow: hidden;
text-align: left;
span {
min-width: 1px;
}
`;
export default FormulaBar;

View File

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

View File

@@ -0,0 +1,122 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
styled,
TextField,
} from "@mui/material";
import { SheetOptions } from "./types";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { useTranslation } from "react-i18next";
import { useState } from "react";
interface SheetRenameDialogProps {
isOpen: boolean;
close: () => void;
onNameChanged: (name: string) => void;
defaultName: string;
}
export const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
const { t } = useTranslation();
const [name, setName] = useState(properties.defaultName);
return (
<Dialog open={properties.isOpen} onClose={properties.close}>
<DialogTitle>{t("sheet_rename.title")}</DialogTitle>
<DialogContent dividers>
<TextField
defaultValue={name}
label={t("sheet_rename.label")}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
}}
onChange={(event) => {
setName(event.target.value);
}}
spellCheck="false"
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
properties.onNameChanged(name);
}}
>
{t("sheet_rename.rename")}
</Button>
</DialogActions>
</Dialog>
);
};
interface SheetListMenuProps {
isOpen: boolean;
close: () => void;
anchorEl: HTMLButtonElement | null;
onSheetSelected: (index: number) => void;
sheetOptionsList: SheetOptions[];
}
const SheetListMenu = (properties: SheetListMenuProps) => {
const { isOpen, close, anchorEl, onSheetSelected, sheetOptionsList } =
properties;
return (
<StyledMenu
open={isOpen}
onClose={close}
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: 6,
}}
>
{sheetOptionsList.map((tab, index) => (
<StyledMenuItem
key={tab.sheetId}
onClick={(): void => onSheetSelected(index)}
>
<ItemColor style={{ backgroundColor: tab.color }} />
<ItemName>{tab.name}</ItemName>
</StyledMenuItem>
))}
</StyledMenu>
);
};
const StyledMenu = styled(Menu)({
"& .MuiPaper-root": {
borderRadius: 8,
padding: 4,
},
"& .MuiList-padding": {
padding: 0,
},
});
const StyledMenuItem = styled(MenuItem)({
padding: 8,
borderRadius: 4,
});
const ItemColor = styled("div")`
width: 12px;
height: 12px;
border-radius: 4px;
margin-right: 8px;
`;
const ItemName = styled("div")`
font-size: 13px;
color: #333;
`;
export default SheetListMenu;

View File

@@ -0,0 +1,141 @@
import { styled } from "@mui/material";
import { ChevronLeft, ChevronRight, Menu, Plus } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SheetOptions } from "./types";
import SheetListMenu, { SheetRenameDialog } from "./menus";
import Sheet from "./sheet";
import { StyledButton } from "../toolbar";
export interface NavigationProps {
sheets: SheetOptions[];
selectedIndex: number;
onSheetSelected: (index: number) => void;
onAddBlankSheet: () => void;
onSheetColorChanged: (hex: string) => void;
onSheetRenamed: (name: string) => void;
onSheetDeleted: () => void;
}
function Navigation(props: NavigationProps) {
const { t } = useTranslation();
const { onSheetSelected, sheets, selectedIndex } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<Container>
<StyledButton title={t("navigation.add_sheet")} $pressed={false}>
<Plus />
</StyledButton>
<StyledButton
onClick={handleClick}
title={t("navigation.sheet_list")}
$pressed={false}
>
<Menu />
</StyledButton>
<Sheets>
<SheetInner>
{sheets.map((tab, index) => (
<Sheet
key={tab.sheetId}
name={tab.name}
color={tab.color}
selected={index === selectedIndex}
onSelected={() => onSheetSelected(index)}
onColorChanged={function (hex: string): void {
props.onSheetColorChanged(hex);
}}
onRenamed={function (name: string): void {
props.onSheetRenamed(name);
}}
onDeleted={function (): void {
props.onSheetDeleted();
}}
/>
))}
</SheetInner>
</Sheets>
<LeftDivider />
<ChevronLeftStyled />
<ChevronRightStyled />
<RightDivider />
<Advert>ironcalc.com</Advert>
<SheetListMenu
anchorEl={anchorEl}
isOpen={open}
close={handleClose}
sheetOptionsList={sheets}
onSheetSelected={onSheetSelected}
/>
</Container>
);
}
const ChevronLeftStyled = styled(ChevronLeft)`
color: #333333;
width: 16px;
height: 16px;
padding: 4px;
cursor: pointer;
`;
const ChevronRightStyled = styled(ChevronRight)`
color: #333333;
width: 16px;
height: 16px;
padding: 4px;
cursor: pointer;
`;
// Note I have to specify the font-family in every component that can be considered stand-alone
const Container = styled("div")`
position: absolute;
bottom: 0px;
left: 0px;
right: 0px;
display: flex;
height: 40px;
align-items: center;
padding-left: 12px;
font-family: Inter;
background-color: #fff;
`;
const Sheets = styled("div")`
flex-grow: 2;
overflow: hidden;
`;
const SheetInner = styled("div")`
display: flex;
`;
const LeftDivider = styled("div")`
height: 10px;
width: 1px;
background-color: #eee;
margin: 0px 10px 0px 0px;
`;
const RightDivider = styled("div")`
height: 10px;
width: 1px;
background-color: #eee;
margin: 0px 20px 0px 10px;
`;
const Advert = styled("div")`
color: #f2994a;
margin-right: 12px;
font-size: 12px;
`;
export default Navigation;

View File

@@ -0,0 +1,127 @@
import { Button, Menu, MenuItem, styled } from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react";
import { SheetRenameDialog } from "./menus";
import ColorPicker from "../colorPicker";
interface SheetProps {
name: string;
color: string;
selected: boolean;
onSelected: () => void;
onColorChanged: (hex: string) => void;
onRenamed: (name: string) => void;
onDeleted: () => void;
}
function Sheet(props: SheetProps) {
const { name, color, selected, onSelected } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorButton = useRef(null);
const open = Boolean(anchorEl);
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const handleCloseRenameDialog = () => {
setRenameDialogOpen(false);
};
const handleOpenRenameDialog = () => {
setRenameDialogOpen(true);
};
return (
<Wrapper
style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }}
onClick={onSelected}
ref={colorButton}
>
<Name>{name}</Name>
<StyledButton onClick={handleOpen}>
<ChevronDown />
</StyledButton>
<StyledMenu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: 6,
}}
>
<MenuItem
onClick={() => {
handleOpenRenameDialog();
handleClose();
}}
>
Rename
</MenuItem>
<MenuItem
onClick={() => {
setColorPickerOpen(true);
handleClose();
}}
>
Change Color
</MenuItem>
<MenuItem onClick={() => props.onDeleted()}> Delete</MenuItem>
</StyledMenu>
<SheetRenameDialog
isOpen={renameDialogOpen}
close={handleCloseRenameDialog}
defaultName={name}
onNameChanged={(newName) => {
props.onRenamed(newName);
setRenameDialogOpen(false);
}}
/>
<ColorPicker
color={color}
onChange={(color): void => {
props.onColorChanged(color);
setColorPickerOpen(false);
}}
anchorEl={colorButton}
open={colorPickerOpen}
/>
</Wrapper>
);
}
const StyledMenu = styled(Menu)``;
const StyledButton = styled(Button)`
width: 15px;
height: 24px;
min-width: 0px;
padding: 0px;
color: inherit;
font-weight: inherit;
svg {
width: 15px;
height: 15px;
}
`;
const Wrapper = styled("div")`
display: flex;
margin-left: 20px;
border-bottom: 3px solid;
border-top: 3px solid white;
line-height: 34px;
align-items: center;
`;
const Name = styled("div")`
font-size: 12px;
margin-right: 5px;
text-wrap: nowrap;
`;
export default Sheet;

View File

@@ -0,0 +1,5 @@
export interface SheetOptions {
name: string;
color: string;
sheetId: number;
}

View File

@@ -0,0 +1,445 @@
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
ChevronDown,
Euro,
Italic,
PaintBucket,
Paintbrush2,
Percent,
Redo2,
Strikethrough,
Underline,
Undo2,
Grid2X2,
Type,
ArrowDownToLine,
ArrowUpToLine,
Grid2x2Check,
Grid2x2X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRef, useState } from "react";
import ColorPicker from "./colorPicker";
import BorderPicker from "./borderPicker";
import {
ArrowMiddleFromLine,
DecimalPlacesDecreaseIcon,
DecimalPlacesIncreaseIcon,
} from "../icons";
import {
NumberFormats,
decreaseDecimalPlaces,
increaseDecimalPlaces,
} from "./formatUtil";
import FormatMenu from "./formatMenu";
import { styled } from "@mui/material/styles";
import { theme } from "../theme";
import {
BorderOptions,
HorizontalAlignment,
VerticalAlignment,
} from "@ironcalc/wasm";
type ToolbarProperties = {
canUndo: boolean;
canRedo: boolean;
onRedo: () => void;
onUndo: () => void;
onToggleUnderline: (u: boolean) => void;
onToggleBold: (v: boolean) => void;
onToggleItalic: (v: boolean) => void;
onToggleStrike: (v: boolean) => void;
onToggleHorizontalAlign: (v: string) => void;
onToggleVerticalAlign: (v: string) => void;
onCopyStyles: () => void;
onTextColorPicked: (hex: string) => void;
onFillColorPicked: (hex: string) => void;
onNumberFormatPicked: (numberFmt: string) => void;
onBorderChanged: (border: BorderOptions) => void;
fillColor: string;
fontColor: string;
bold: boolean;
underline: boolean;
italic: boolean;
strike: boolean;
horizontalAlign: HorizontalAlignment;
verticalAlign: VerticalAlignment;
canEdit: boolean;
numFmt: string;
showGridLines: boolean;
onToggleShowGridLines: (show: boolean) => void;
};
function Toolbar(properties: ToolbarProperties) {
const [fontColorPickerOpen, setFontColorPickerOpen] = useState(false);
const [fillColorPickerOpen, setFillColorPickerOpen] = useState(false);
const [borderPickerOpen, setBorderPickerOpen] = useState(false);
const fontColorButton = useRef(null);
const fillColorButton = useRef(null);
const borderButton = useRef(null);
const { t } = useTranslation();
const { canEdit } = properties;
return (
<ToolbarContainer>
<StyledButton
type="button"
$pressed={false}
onClick={properties.onUndo}
disabled={!properties.canUndo}
title={t("toolbar.undo")}
>
<Undo2 />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
onClick={properties.onRedo}
disabled={!properties.canRedo}
title={t("toolbar.redo")}
>
<Redo2 />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
onClick={properties.onCopyStyles}
title={t("toolbar.copy_styles")}
>
<Paintbrush2 />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
onClick={(): void => {
properties.onNumberFormatPicked(NumberFormats.CURRENCY_EUR);
}}
disabled={!canEdit}
title={t("toolbar.euro")}
>
<Euro />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
onClick={(): void => {
properties.onNumberFormatPicked(NumberFormats.PERCENTAGE);
}}
disabled={!canEdit}
title={t("toolbar.percentage")}
>
<Percent />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
onClick={(): void => {
properties.onNumberFormatPicked(
decreaseDecimalPlaces(properties.numFmt)
);
}}
disabled={!canEdit}
title={t("toolbar.decimal_places_decrease")}
>
<DecimalPlacesDecreaseIcon />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
onClick={(): void => {
properties.onNumberFormatPicked(
increaseDecimalPlaces(properties.numFmt)
);
}}
disabled={!canEdit}
title={t("toolbar.decimal_places_increase")}
>
<DecimalPlacesIncreaseIcon />
</StyledButton>
<FormatMenu
numFmt={properties.numFmt}
onChange={(numberFmt): void => {
properties.onNumberFormatPicked(numberFmt);
}}
onExited={(): void => {}}
anchorOrigin={{
horizontal: 20, // Aligning the menu to the middle of FormatButton
vertical: "bottom",
}}
>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
title={t("toolbar.format_number")}
sx={{
width: "40px", // Keep in sync with anchorOrigin in FormatMenu above
fontSize: "13px",
fontWeight: 400,
}}
>
{"123"}
<ChevronDown />
</StyledButton>
</FormatMenu>
<Divider />
<StyledButton
type="button"
$pressed={properties.bold}
onClick={() => properties.onToggleBold(!properties.bold)}
disabled={!canEdit}
title={t("toolbar.bold")}
>
<Bold />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.italic}
onClick={() => properties.onToggleItalic(!properties.italic)}
disabled={!canEdit}
title={t("toolbar.italic")}
>
<Italic />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.underline}
onClick={() => properties.onToggleUnderline(!properties.underline)}
disabled={!canEdit}
title={t("toolbar.underline")}
>
<Underline />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.strike}
onClick={() => properties.onToggleStrike(!properties.strike)}
disabled={!canEdit}
title={t("toolbar.strike_trough")}
>
<Strikethrough />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
title={t("toolbar.font_color")}
ref={fontColorButton}
$underlinedColor={properties.fontColor}
onClick={() => setFontColorPickerOpen(true)}
>
<Type />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
title={t("toolbar.fill_color")}
ref={fillColorButton}
$underlinedColor={properties.fillColor}
onClick={() => setFillColorPickerOpen(true)}
>
<PaintBucket />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={properties.horizontalAlign === "left"}
onClick={() =>
properties.onToggleHorizontalAlign(
properties.horizontalAlign === "left" ? "general" : "left"
)
}
disabled={!canEdit}
title={t("toolbar.align_left")}
>
<AlignLeft />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.horizontalAlign === "center"}
onClick={() =>
properties.onToggleHorizontalAlign(
properties.horizontalAlign === "center" ? "general" : "center"
)
}
disabled={!canEdit}
title={t("toolbar.align_center")}
>
<AlignCenter />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.horizontalAlign === "right"}
onClick={() =>
properties.onToggleHorizontalAlign(
properties.horizontalAlign === "right" ? "general" : "right"
)
}
disabled={!canEdit}
title={t("toolbar.align_right")}
>
<AlignRight />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.verticalAlign === "top"}
onClick={() =>
properties.onToggleVerticalAlign(
properties.verticalAlign === "top" ? "bottom" : "top"
)
}
disabled={!canEdit}
title={t("toolbar.vertical_align_top")}
>
<ArrowUpToLine />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.verticalAlign === "center"}
onClick={() =>
properties.onToggleVerticalAlign(
properties.verticalAlign === "center" ? "bottom" : "center"
)
}
disabled={!canEdit}
title={t("toolbar.vertical_align_center")}
>
<ArrowMiddleFromLine />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.verticalAlign === "bottom"}
onClick={() => properties.onToggleVerticalAlign("bottom")}
disabled={!canEdit}
title={t("toolbar.vertical_align_bottom")}
>
<ArrowDownToLine />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
onClick={() => setBorderPickerOpen(true)}
ref={borderButton}
disabled={!canEdit}
title={t("toolbar.borders")}
>
<Grid2X2 />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
onClick={() => properties.onToggleShowGridLines(!properties.showGridLines)}
disabled={!canEdit}
title={t("toolbar.show_hide_grid_lines")}
>
{properties.showGridLines ? <Grid2x2Check /> : <Grid2x2X />}
</StyledButton>
<ColorPicker
color={properties.fontColor}
onChange={(color): void => {
properties.onTextColorPicked(color);
setFontColorPickerOpen(false);
}}
anchorEl={fontColorButton}
open={fontColorPickerOpen}
/>
<ColorPicker
color={properties.fillColor}
onChange={(color): void => {
properties.onFillColorPicked(color);
setFillColorPickerOpen(false);
}}
anchorEl={fillColorButton}
open={fillColorPickerOpen}
/>
<BorderPicker
onChange={(border): void => {
properties.onBorderChanged(border);
setBorderPickerOpen(false);
}}
anchorEl={borderButton}
open={borderPickerOpen}
/>
</ToolbarContainer>
);
}
const toolbarHeight = 40;
const ToolbarContainer = styled("div")`
display: flex;
flex-shrink: 0;
align-items: center;
background: ${({ }) => theme.palette.background.paper};
height: ${toolbarHeight}px;
line-height: ${toolbarHeight}px;
border-bottom: 1px solid ${({}) => theme.palette.grey["600"]};
font-family: Inter;
border-radius: 4px 4px 0px 0px;
overflow-x: auto;
`;
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
export const StyledButton = styled("button")<TypeButtonProperties>(({
disabled,
$pressed,
$underlinedColor,
}) => {
let result: Record<string, any> = {
width: "24px",
height: "24px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: "26px",
border: "0px solid #fff",
borderRadius: "2px",
marginRight: "5px",
transition: "all 0.2s",
cursor: "pointer",
backgroundColor: "white",
padding: "0px",
};
if (disabled) {
result.color = theme.palette.grey["600"];
result.cursor = "default";
} else {
result.borderTop = $underlinedColor ? "3px solid #FFF" : "none";
result.borderBottom = $underlinedColor
? `3px solid ${$underlinedColor}`
: "none";
(result.color = "#21243A"), //theme.palette.text.primary;
(result.backgroundColor = $pressed ? "#EEE" : "#FFF");
result["&:hover"] = {
backgroundColor: "#F1F2F8",
borderTopColor: "#F1F2F8",
};
}
result["svg"] = {
width: "16px",
height: "16px",
};
return result;
});
const Divider = styled("div")({
width: "0px",
height: "10px",
borderLeft: "1px solid #D3D6E9",
marginLeft: "5px",
marginRight: "10px",
});
export default Toolbar;

View File

@@ -0,0 +1,229 @@
import { useCallback, KeyboardEvent, RefObject } from "react";
import {
isEditingKey,
isNavigationKey,
NavigationKey,
} from "./WorksheetCanvas/util";
export enum Border {
Top = "top",
Bottom = "bottom",
Right = "right",
Left = "left",
}
interface Options {
onCellsDeleted: () => void;
onExpandAreaSelectedKeyboard: (
key: "ArrowRight" | "ArrowLeft" | "ArrowUp" | "ArrowDown"
) => void;
onEditKeyPressStart: (initText: string) => void;
onCellEditStart: () => void;
onBold: () => void;
onItalic: () => void;
onUnderline: () => void;
onNavigationToEdge: (direction: NavigationKey) => void;
onPageDown: () => void;
onPageUp: () => void;
onArrowDown: () => void;
onArrowUp: () => void;
onArrowLeft: () => void;
onArrowRight: () => void;
onKeyHome: () => void;
onKeyEnd: () => void;
onUndo: () => void;
onRedo: () => void;
onNextSheet: () => void;
onPreviousSheet: () => void;
root: RefObject<HTMLDivElement>;
}
// # IronCalc Keyboard accessibility:
// * ArrowKeys: navigation
// * Enter: ArrowDown (Excel behaviour not g-sheets)
// * Tab: arrow right
// * Shift+Tab: arrow left
// * Home/End: First/last column
// * Shift+Arrows: selection
// * Ctrl+Arrows: navigating to edge
// * Ctrl+Home/End: navigation to end
// * PagDown/Up scroll Down/Up
// * Alt+ArrowDown/Up: next/previous sheet
// (NB: Excel uses Ctrl+PageUp/Down for this but that highjacks a browser behaviour,
// go to next/previous tab)
// * Ctrl+u/i/b: style
// * Ctrl+z/y: undo/redo
// * F2: start editing
// References:
// In Google Sheets: Ctrl+/ shows the list of keyboard shortcuts
// https://support.google.com/docs/answer/181110
// https://support.microsoft.com/en-us/office/keyboard-shortcuts-in-excel-1798d9d5-842a-42b8-9c99-9b7213f0040f
const useKeyboardNavigation = (
options: Options
): { onKeyDown: (event: KeyboardEvent) => void } => {
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
const { key } = event;
const { root } = options;
console.log(key);
// Silence the linter
if (!root.current) {
return;
}
if (event.target !== root.current) {
return;
}
if (event.metaKey || event.ctrlKey) {
switch (key) {
case "z": {
options.onUndo();
event.stopPropagation();
event.preventDefault();
break;
}
case "y": {
options.onRedo();
event.stopPropagation();
event.preventDefault();
break;
}
case "b": {
options.onBold();
event.stopPropagation();
event.preventDefault();
break;
}
case "i": {
options.onItalic();
event.stopPropagation();
event.preventDefault();
break;
}
case "u": {
options.onUnderline();
event.stopPropagation();
event.preventDefault();
break;
}
// No default
}
if (isNavigationKey(key)) {
// Ctrl+Arrows, Ctrl+Home/End
options.onNavigationToEdge(key);
event.stopPropagation();
event.preventDefault();
}
return;
} else if (event.altKey) {
switch (key) {
case "ArrowDown": {
// select next sheet
options.onNextSheet();
event.stopPropagation();
event.preventDefault();
break;
}
case "ArrowUp": {
// select previous sheet
options.onPreviousSheet();
event.stopPropagation();
event.preventDefault();
break;
}
}
}
if (key === "F2") {
options.onCellEditStart();
event.stopPropagation();
event.preventDefault();
return;
}
if (isEditingKey(key) || key === "Backspace") {
const initText = key === "Backspace" ? "" : key;
options.onEditKeyPressStart(initText);
event.stopPropagation();
event.preventDefault();
return;
}
// Worksheet Navigation
if (event.shiftKey) {
if (
key === "ArrowRight" ||
key === "ArrowLeft" ||
key === "ArrowUp" ||
key === "ArrowDown"
) {
options.onExpandAreaSelectedKeyboard(key);
} else if (key === "Tab") {
options.onArrowLeft();
event.stopPropagation();
event.preventDefault();
}
return;
}
switch (key) {
case "ArrowRight":
case "Tab": {
options.onArrowRight();
break;
}
case "ArrowLeft": {
options.onArrowLeft();
break;
}
case "ArrowDown":
case "Enter": {
options.onArrowDown();
break;
}
case "ArrowUp": {
options.onArrowUp();
break;
}
case "End": {
options.onKeyEnd();
break;
}
case "Home": {
options.onKeyHome();
break;
}
case "Delete": {
options.onCellsDeleted();
break;
}
case "PageDown": {
options.onPageDown();
break;
}
case "PageUp": {
options.onPageUp();
break;
}
// No default
}
event.stopPropagation();
event.preventDefault();
},
[options]
);
return { onKeyDown };
};
export default useKeyboardNavigation;

View File

@@ -0,0 +1,227 @@
import { useCallback, RefObject, PointerEvent, useRef } from 'react';
import WorksheetCanvas, { headerColumnWidth, headerRowHeight } from './WorksheetCanvas/worksheetCanvas';
import { Cell } from './WorksheetCanvas/util';
interface PointerSettings {
canvasElement: RefObject<HTMLCanvasElement>;
worksheetCanvas: RefObject<WorksheetCanvas | null>;
worksheetElement: RefObject<HTMLDivElement>;
// rowContextMenuAnchorElement: RefObject<HTMLDivElement>;
// columnContextMenuAnchorElement: RefObject<HTMLDivElement>;
onCellSelected: (cell: Cell, event: React.MouseEvent) => void;
onAreaSelecting: (cell: Cell) => void;
onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void;
onExtendToEnd: () => void;
// onRowContextMenu: (row: number) => void;
// onColumnContextMenu: (column: number) => void;
}
interface PointerEvents {
onPointerDown: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void;
onPointerUp: (event: PointerEvent) => void;
onPointerHandleDown: (event: PointerEvent) => void;
// onContextMenu: (event: React.MouseEvent) => void;
}
const usePointer = (options: PointerSettings): PointerEvents => {
const isSelecting = useRef(false);
const isExtending = useRef(false);
// const onContextMenu = useCallback(
// (event: React.MouseEvent): void => {
// let x = event.clientX;
// let y = event.clientY;
// const {
// canvasElement,
// worksheetElement,
// worksheetCanvas,
// onRowContextMenu,
// rowContextMenuAnchorElement,
// onColumnContextMenu,
// columnContextMenuAnchorElement,
// } = options;
// const worksheet = worksheetCanvas.current;
// const canvas = canvasElement.current;
// const worksheetWrapper = worksheetElement.current;
// // Silence the linter
// if (!canvas || !worksheet || !worksheetWrapper) {
// return;
// }
// const canvasRect = canvas.getBoundingClientRect();
// x -= canvasRect.x;
// y -= canvasRect.y;
// const menuAnchorOffsetY = 10;
// if (x > 0 && x < headerColumnWidth && y > headerRowHeight && y < canvasRect.height) {
// // Click on a row number
// const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
// if (cell) {
// event.preventDefault();
// event.stopPropagation();
// if (rowContextMenuAnchorElement.current) {
// const scrollPosition = worksheet.getScrollPosition();
// rowContextMenuAnchorElement.current.style.left = `${x + scrollPosition.left}px`;
// rowContextMenuAnchorElement.current.style.top = `${
// y + scrollPosition.top + menuAnchorOffsetY
// }px`;
// }
// options.onPointerDownAtCell(cell, event);
// onRowContextMenu(cell.row);
// }
// }
// if (x > headerColumnWidth && x < canvas.width && y > 0 && y < headerRowHeight) {
// // Click on a column number
// const cell = worksheet.getCellByCoordinates(x, headerRowHeight);
// if (cell) {
// event.preventDefault();
// event.stopPropagation();
// if (columnContextMenuAnchorElement.current) {
// const scrollPosition = worksheet.getScrollPosition();
// columnContextMenuAnchorElement.current.style.left = `${x + scrollPosition.left}px`;
// columnContextMenuAnchorElement.current.style.top = `${
// y + scrollPosition.top + menuAnchorOffsetY
// }px`;
// }
// options.onPointerDownAtCell(cell, event);
// onColumnContextMenu(cell.column);
// }
// }
// },
// [options],
// );
const onPointerMove = useCallback(
(event: PointerEvent): void => {
// Range selections are disabled on non-mouse devices. Use touch move only
// to scroll for now.
if (event.pointerType !== 'mouse') {
return;
}
if (isSelecting.current) {
const { canvasElement, worksheetCanvas } = options;
const canvas = canvasElement.current;
const worksheet = worksheetCanvas.current;
// Silence the linter
if (!worksheet || !canvas) {
return;
}
let x = event.clientX;
let y = event.clientY;
const canvasRect = canvas.getBoundingClientRect();
x -= canvasRect.x;
y -= canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (cell) {
options.onAreaSelecting(cell);
} else {
console.log('Failed');
}
} else if (isExtending.current) {
const { canvasElement, worksheetCanvas } = options;
const canvas = canvasElement.current;
const worksheet = worksheetCanvas.current;
// Silence the linter
if (!worksheet || !canvas) {
return;
}
let x = event.clientX;
let y = event.clientY;
const canvasRect = canvas.getBoundingClientRect();
x -= canvasRect.x;
y -= canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (!cell) {
return;
}
options.onExtendToCell(cell);
}
},
[options],
);
const onPointerUp = useCallback(
(event: PointerEvent): void => {
if (isSelecting.current) {
const { worksheetElement } = options;
isSelecting.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onAreaSelected();
} else if (isExtending.current) {
const { worksheetElement } = options;
isExtending.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onExtendToEnd();
}
},
[options],
);
const onPointerDown = useCallback(
(event: PointerEvent) => {
let x = event.clientX;
let y = event.clientY;
const { canvasElement, worksheetElement, worksheetCanvas } = options;
const worksheet = worksheetCanvas.current;
const canvas = canvasElement.current;
const worksheetWrapper = worksheetElement.current;
// Silence the linter
if (!canvas || !worksheet || !worksheetWrapper) {
return;
}
const canvasRect = canvas.getBoundingClientRect();
x -= canvasRect.x;
y -= canvasRect.y;
// Makes sure is in the sheet area
if (
x > canvasRect.width ||
x < headerColumnWidth ||
y < headerRowHeight ||
y > canvasRect.height
) {
if (x > 0 && x < headerColumnWidth && y > headerRowHeight && y < canvasRect.height) {
// Click on a row number
const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
if (cell) {
// TODO
// Row selected
}
}
return;
}
const cell = worksheet.getCellByCoordinates(x, y);
if (cell) {
options.onCellSelected(cell, event);
isSelecting.current = true;
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],
);
return {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerHandleDown,
// onContextMenu,
};
};
export default usePointer;

View File

@@ -0,0 +1,377 @@
import Toolbar from "./toolbar";
import FormulaBar from "./formulabar";
import Navigation from "./navigation/navigation";
import Worksheet from "./worksheet";
import { styled } from "@mui/material/styles";
import { useEffect, useRef, useState } from "react";
import useKeyboardNavigation from "./useKeyboardNavigation";
import { NavigationKey, getCellAddress } from "./WorksheetCanvas/util";
import { LAST_COLUMN, LAST_ROW } from "./WorksheetCanvas/constants";
import { WorkbookState } from "./workbookState";
import { BorderOptions, Model, WorksheetProperties } from "@ironcalc/wasm";
const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const { model, workbookState } = props;
const rootRef = useRef<HTMLDivElement>(null);
const [_redrawId, setRedrawId] = useState(0);
const info = model
.getWorksheetsProperties()
.map(({ name, color, sheet_id }: WorksheetProperties) => {
return { name, color: color ? color : "#FFF", sheetId: sheet_id };
});
const onRedo = () => {
model.redo();
setRedrawId((id) => id + 1);
};
const onUndo = () => {
model.undo();
setRedrawId((id) => id + 1);
};
const updateRangeStyle = (stylePath: string, value: string) => {
const {
sheet,
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
const row = Math.min(rowStart, rowEnd);
const column = Math.min(columnStart, columnEnd);
const range = {
sheet,
row,
column,
width: Math.abs(columnEnd - columnStart) + 1,
height: Math.abs(rowEnd - rowStart) + 1,
};
model.updateRangeStyle(range, stylePath, value);
setRedrawId((id) => id + 1);
};
const onToggleUnderline = (value: boolean) => {
updateRangeStyle("font.u", `${value}`);
};
const onToggleItalic = (value: boolean) => {
updateRangeStyle("font.i", `${value}`);
};
const onToggleBold = (value: boolean) => {
updateRangeStyle("font.b", `${value}`);
};
const onToggleStrike = (value: boolean) => {
updateRangeStyle("font.strike", `${value}`);
};
const onToggleHorizontalAlign = (value: string) => {
updateRangeStyle("alignment.horizontal", value);
};
const onToggleVerticalAlign = (value: string) => {
updateRangeStyle("alignment.vertical", value);
};
const onTextColorPicked = (hex: string) => {
updateRangeStyle("font.color", hex);
};
const onFillColorPicked = (hex: string) => {
updateRangeStyle("fill.fg_color", hex);
};
const onNumberFormatPicked = (numberFmt: string) => {
updateRangeStyle("num_fmt", numberFmt);
};
const onCopyStyles = () => {
const {
sheet,
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
const row1 = Math.min(rowStart, rowEnd);
const column1 = Math.min(columnStart, columnEnd);
const row2 = Math.max(rowStart, rowEnd);
const column2 = Math.max(columnStart, columnEnd);
const styles = [];
for (let row = row1; row <= row2; row++) {
const styleRow = [];
for (let column = column1; column <= column2; column++) {
styleRow.push(model.getCellStyle(sheet, row, column));
}
styles.push(styleRow);
}
console.log("set styles", styles);
workbookState.setCopyStyles(styles);
const el = rootRef.current?.getElementsByClassName("sheet-container")[0];
if (el) {
(el as HTMLElement).style.cursor =
`url('data:image/svg+xml;utf8,<svg data-v-56bd7dfc="" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paintbrush-vertical"><path d="M10 2v2"></path><path d="M14 2v4"></path><path d="M17 2a1 1 0 0 1 1 1v9H6V3a1 1 0 0 1 1-1z"></path><path d="M6 12a1 1 0 0 0-1 1v1a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v2.9a2 2 0 1 0 4 0V17a1 1 0 0 1 1-1h2a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1"></path></svg>'), auto`;
}
};
// FIXME: My gut tells me that we should have only one on onKeyPressed function that goes to
// the Rust end
const { onKeyDown } = useKeyboardNavigation({
onCellsDeleted: function (): void {
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.rangeClearContents(
sheet,
row,
column,
row + height,
column + width
);
setRedrawId((id) => id + 1);
},
onExpandAreaSelectedKeyboard: function (
key: "ArrowRight" | "ArrowLeft" | "ArrowUp" | "ArrowDown"
): void {
model.onExpandSelectedRange(key);
setRedrawId((id) => id + 1);
},
onEditKeyPressStart: function (initText: string): void {
console.log(initText);
throw new Error("Function not implemented.");
},
onCellEditStart: function (): void {
throw new Error("Function not implemented.");
},
onBold: () => {
let { sheet, row, column } = model.getSelectedView();
let value = !model.getCellStyle(sheet, row, column).font.b;
onToggleBold(!value);
},
onItalic: () => {
let { sheet, row, column } = model.getSelectedView();
let value = !model.getCellStyle(sheet, row, column).font.i;
onToggleItalic(!value);
},
onUnderline: () => {
let { sheet, row, column } = model.getSelectedView();
let value = !model.getCellStyle(sheet, row, column).font.u;
onToggleUnderline(!value);
},
onNavigationToEdge: function (direction: NavigationKey): void {
console.log(direction);
// const newSelectedCell = model.getNavigationEdge(
// key,
// selectedSheet,
// selectedCell.row,
// selectedCell.column,
// canvas.lastRow,
// canvas.lastColumn,
// );
setRedrawId((id) => id + 1);
},
onPageDown: function (): void {
model.onPageDown();
setRedrawId((id) => id + 1);
},
onPageUp: function (): void {
model.onPageUp();
setRedrawId((id) => id + 1);
},
onArrowDown: function (): void {
model.onArrowDown();
setRedrawId((id) => id + 1);
},
onArrowUp: function (): void {
model.onArrowUp();
setRedrawId((id) => id + 1);
},
onArrowLeft: function (): void {
model.onArrowLeft();
setRedrawId((id) => id + 1);
},
onArrowRight: function (): void {
model.onArrowRight();
setRedrawId((id) => id + 1);
},
onKeyHome: function (): void {
const view = model.getSelectedView();
const cell = model.getSelectedCell();
model.setSelectedCell(cell[1], 1);
model.setTopLeftVisibleCell(view.top_row, 1);
setRedrawId((id) => id + 1);
},
onKeyEnd: function (): void {
const view = model.getSelectedView();
const cell = model.getSelectedCell();
model.setSelectedCell(cell[1], LAST_COLUMN);
model.setTopLeftVisibleCell(view.top_row, LAST_COLUMN - 5);
setRedrawId((id) => id + 1);
},
onUndo: function (): void {
model.undo();
setRedrawId((id) => id + 1);
},
onRedo: function (): void {
model.redo();
setRedrawId((id) => id + 1);
},
onNextSheet: function (): void {
const nextSheet = model.getSelectedSheet() + 1;
if (nextSheet >= model.getWorksheetsProperties().length) {
model.setSelectedSheet(0);
} else {
model.setSelectedSheet(nextSheet);
}
},
onPreviousSheet: function (): void {
const nextSheet = model.getSelectedSheet() - 1;
if (nextSheet < 0) {
model.setSelectedSheet(model.getWorksheetsProperties().length - 1);
} else {
model.setSelectedSheet(nextSheet);
}
},
root: rootRef,
});
useEffect(() => {
if (!rootRef.current) {
return;
}
rootRef.current.focus();
});
const {
sheet,
row,
column,
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
const cellAddress = getCellAddress(
{ rowStart, rowEnd, columnStart, columnEnd },
{ row, column }
);
const formulaValue = model.getCellContent(sheet, row, column);
const style = model.getCellStyle(sheet, row, column);
return (
<Container ref={rootRef} onKeyDown={onKeyDown} tabIndex={0}>
<Toolbar
canUndo={model.canUndo()}
canRedo={model.canRedo()}
onRedo={onRedo}
onUndo={onUndo}
onToggleUnderline={onToggleUnderline}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStrike={onToggleStrike}
onToggleHorizontalAlign={onToggleHorizontalAlign}
onToggleVerticalAlign={onToggleVerticalAlign}
onCopyStyles={onCopyStyles}
onTextColorPicked={onTextColorPicked}
onFillColorPicked={onFillColorPicked}
onNumberFormatPicked={onNumberFormatPicked}
onBorderChanged={function (border: BorderOptions): void {
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 borderArea = {
type: border.border,
item: border,
};
model.setAreaWithBorder(
{ sheet, row, column, width, height },
borderArea
);
setRedrawId((id) => id + 1);
}}
fillColor={style.fill.fg_color || "#FFF"}
fontColor={style.font.color}
bold={style.font.b}
underline={style.font.u}
italic={style.font.i}
strike={style.font.strike}
horizontalAlign={
style.alignment ? style.alignment.horizontal : "general"
}
verticalAlign={style.alignment ? style.alignment.vertical : "center"}
canEdit={true}
numFmt={""}
showGridLines={model.getShowGridLines(sheet)}
onToggleShowGridLines={(show) => {
model.setShowGridLines(sheet, show);
setRedrawId((id) => id + 1);
}}
/>
<FormulaBar
cellAddress={cellAddress}
formulaValue={formulaValue}
onChange={(value) => {
console.log('set', sheet, row, column, value);
model.setUserInput(sheet, row, column, value);
setRedrawId((id) => id + 1);
}}
/>
<Worksheet
model={model}
workbookState={workbookState}
refresh={(): void => {
setRedrawId((id) => id + 1);
}}
/>
<Navigation
sheets={info}
selectedIndex={model.getSelectedSheet()}
onSheetSelected={function (sheet: number): void {
model.setSelectedSheet(sheet);
setRedrawId((value) => value + 1);
}}
onAddBlankSheet={function (): void {
model.newSheet();
}}
onSheetColorChanged={function (hex: string): void {
try {
model.setSheetColor(model.getSelectedSheet(), hex);
} catch (e) {
alert(`${e}`);
}
}}
onSheetRenamed={function (name: string): void {
try {
model.renameSheet(model.getSelectedSheet(), name);
} catch (e) {
alert(`${e}`);
}
}}
onSheetDeleted={function (): void {
model.deleteSheet(model.getSelectedSheet());
}}
/>
</Container>
);
};
const Container = styled("div")`
display: flex;
flex-direction: column;
height: 100%;
font-family: ${({ theme }) => theme.typography.fontFamily};
&:focus {
outline: none;
}
`;
export default Workbook;

View File

@@ -0,0 +1,62 @@
import { createContext } from "react";
export interface Cell {
row: number;
column: number;
}
export interface Area {
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
}
interface Scroll {
left: number;
top: number;
}
type FocusType = "cell" | "formula-bar";
/**
* In Excel there are two "modes" of editing
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
* * `edit`: If you double click on a cell or click in the cell while editing.
* In this mode arrow keys will move within the cell.
*
* In a formula bar mode is always `edit`.
*/
type CellEditMode = "init" | "edit";
const WorkbookContext = createContext<{
selectedSheet: number;
selectedCell: Cell;
selectedArea: Area;
scroll: Scroll;
extendToArea: Area | null;
editor: Editor | null;
}>({
selectedSheet: 0,
selectedCell: {row: 1, column: 1},
selectedArea: {rowStart:1, rowEnd: 1, columnStart:1, columnEnd: 1},
scroll: {top: 0, left: 0},
extendToArea: null,
editor: null
});
interface Editor {
id: number;
sheet: number;
row: number;
column: number;
text: string;
base: string;
mode: CellEditMode;
focus: FocusType;
}
export default WorkbookContext;

View File

@@ -0,0 +1,125 @@
import { CellStyle } from "@ironcalc/wasm";
export interface Cell {
row: number;
column: number;
}
export enum AreaType {
rowsDown,
columnsRight,
rowsUp,
columnsLeft,
}
export interface Area {
type: AreaType;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
}
type FocusType = "cell" | "formula-bar";
/**
* In Excel there are two "modes" of editing
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
* * `edit`: If you double click on a cell or click in the cell while editing.
* In this mode arrow keys will move within the cell.
*
* In a formula bar mode is always `edit`.
*/
type CellEditMode = "init" | "edit";
interface Editor {
id: number;
sheet: number;
row: number;
column: number;
text: string;
base: string;
mode: CellEditMode;
focus: FocusType;
}
interface Cells {
topLeftCell: { row: number; column: number };
bottomRightCell: { row: number; column: number };
}
type AreaStyles = CellStyle[][];
export class WorkbookState {
private extendToArea: Area | null;
private editor: Editor | null;
private visibleCells: Cells | null;
private id;
private copyStyles: AreaStyles | null;
constructor() {
this.extendToArea = null;
this.visibleCells = null;
this.editor = null;
this.id = Math.floor(Math.random() * 1000);
this.copyStyles = null;
}
startEditing(_focus: FocusType, _text: string) {
// const {row, column} = this.selectedCell;
// this.editor = {
// id: 0,
// sheet: this.selectedSheet,
// row,
// column,
// base: '',
// text,
// mode: 'init',
// focus
// }
}
setEditorText(text: string) {
if (!this.editor) {
return;
}
this.editor.text = text;
}
setVisibleCells(cells: Cells) {
this.visibleCells = cells;
}
getVisibleCells(): Cells | null {
return this.visibleCells;
}
endEditing() {
this.editor = null;
}
getEditor(): Editor | null {
console.log("getEditor", this.id);
return this.editor;
}
getExtendToArea(): Area | null {
return this.extendToArea;
}
clearExtendToArea(): void {
this.extendToArea = null;
}
setExtendToArea(area: Area): void {
this.extendToArea = area;
}
setCopyStyles(styles: AreaStyles | null): void {
this.copyStyles = styles;
}
getCopyStyles(): AreaStyles | null {
return this.copyStyles;
}
}

View File

@@ -0,0 +1,523 @@
import { styled } from "@mui/material/styles";
import { useEffect, useRef, useState } from "react";
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
import {
outlineBackgroundColor,
outlineColor,
} from "./WorksheetCanvas/constants";
import usePointer from "./usePointer";
import { AreaType, WorkbookState } from "./workbookState";
import { Cell } from "./WorksheetCanvas/types";
import Editor from "./editor";
import EditorContext, { EditorState } from "./editor/editorContext";
import { getFormulaHTML } from "./editor/util";
import { Model } from "@ironcalc/wasm";
function Worksheet(props: {
model: Model;
workbookState: WorkbookState;
refresh: () => void;
}) {
const canvasElement = useRef<HTMLCanvasElement>(null);
const worksheetElement = useRef<HTMLDivElement>(null);
const scrollElement = useRef<HTMLDivElement>(null);
// const rootElement = useRef<HTMLDivElement>(null);
const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null);
const cellOutlineHandle = useRef<HTMLDivElement>(null);
const extendToOutline = useRef<HTMLDivElement>(null);
const columnResizeGuide = useRef<HTMLDivElement>(null);
const rowResizeGuide = useRef<HTMLDivElement>(null);
// const contextMenuAnchorElement = useRef<HTMLDivElement>(null);
const columnHeaders = useRef<HTMLDivElement>(null);
const worksheetCanvas = useRef<WorksheetCanvas | null>(null);
const [isEditing, setEditing] = useState(false);
const ignoreScrollEventRef = useRef(false);
const [editorContext, setEditorContext] = useState<EditorState>({
mode: "accept",
insertRange: null,
baseText: "",
id: Math.floor(Math.random() * 1000),
});
const { model, workbookState, refresh } = props;
useEffect(() => {
const canvasRef = canvasElement.current;
const columnGuideRef = columnResizeGuide.current;
const rowGuideRef = rowResizeGuide.current;
const columnHeadersRef = columnHeaders.current;
const worksheetRef = worksheetElement.current;
const outline = cellOutline.current;
const handle = cellOutlineHandle.current;
const area = areaOutline.current;
const extendTo = extendToOutline.current;
if (
!canvasRef ||
!columnGuideRef ||
!rowGuideRef ||
!columnHeadersRef ||
!worksheetRef ||
!outline ||
!handle ||
!area ||
!extendTo ||
!scrollElement.current
)
return;
model.setWindowWidth(worksheetRef.clientWidth);
model.setWindowHeight(worksheetRef.clientHeight);
const canvas = new WorksheetCanvas({
width: worksheetRef.clientWidth,
height: worksheetRef.clientHeight,
model,
workbookState,
elements: {
canvas: canvasRef,
columnGuide: columnGuideRef,
rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef,
cellOutline: outline,
cellOutlineHandle: handle,
areaOutline: area,
extendToOutline: extendTo,
},
onColumnWidthChanges(sheet, column, width) {
model.setColumnWidth(sheet, column, width);
worksheetCanvas.current?.renderSheet();
},
onRowHeightChanges(sheet, row, height) {
model.setRowHeight(sheet, row, height);
worksheetCanvas.current?.renderSheet();
},
});
const scrollX = model.getScrollX();
const scrollY = model.getScrollY();
const [sheetWidth, sheetHeight] = [scrollX + 100_000, scrollY + 500_000]; //canvas.getSheetDimensions();
if (spacerElement.current) {
spacerElement.current.style.height = `${sheetHeight}px`;
spacerElement.current.style.width = `${sheetWidth}px`;
}
const left = scrollElement.current.scrollLeft;
const top = scrollElement.current.scrollTop;
if (scrollX !== left) {
ignoreScrollEventRef.current = true;
scrollElement.current.scrollLeft = scrollX;
setTimeout(() => {
ignoreScrollEventRef.current = false;
}, 0);
}
if (scrollY !== top) {
ignoreScrollEventRef.current = true;
scrollElement.current.scrollTop = scrollY;
setTimeout(() => {
ignoreScrollEventRef.current = false;
}, 0);
}
canvas.renderSheet();
worksheetCanvas.current = canvas;
});
const sheetNames = model
.getWorksheetsProperties()
.map((s: { name: string }) => s.name);
const {
onPointerMove,
onPointerDown,
onPointerHandleDown,
onPointerUp,
// onContextMenu,
} = usePointer({
onCellSelected: (cell: Cell, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
model.setSelectedCell(cell.row, cell.column);
refresh();
},
onAreaSelecting: (cell: Cell) => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
model.onAreaSelecting(row, column);
canvas.renderSheet();
},
onAreaSelected: () => {
let styles = workbookState.getCopyStyles();
if (styles && styles.length) {
model.onPasteStyles(styles);
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
canvas.renderSheet();
}
workbookState.setCopyStyles(null);
if (worksheetElement.current) {
worksheetElement.current.style.cursor = "auto";
}
},
onExtendToCell: (cell) => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
const {
sheet,
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;
}
}
workbookState.clearExtendToArea();
canvas.renderSheet();
},
canvasElement,
worksheetElement,
worksheetCanvas,
// rowContextMenuAnchorElement,
// columnContextMenuAnchorElement,
// onRowContextMenu,
// onColumnContextMenu,
});
const onScroll = (_event: any): void => {
if (!scrollElement.current || !worksheetCanvas.current) {
return;
}
if (ignoreScrollEventRef.current) {
// Programmatic scroll ignored
return;
}
const left = scrollElement.current.scrollLeft;
const top = scrollElement.current.scrollTop;
worksheetCanvas.current.setScrollPosition({ left, top });
worksheetCanvas.current.renderSheet();
};
const { row, column, sheet: selectedSheet } = model.getSelectedView();
return (
// <EditorContext.Provider value={{editorContext}}>
<Wrapper ref={scrollElement} onScroll={onScroll} className="scroll">
<Spacer ref={spacerElement} />
<SheetContainer
className="sheet-container"
ref={worksheetElement}
onPointerDown={(event) => {
if (isEditing === true && editorContext.mode !== "insert") {
setEditing(false);
model.setUserInput(
selectedSheet,
row,
column,
editorContext.baseText
);
}
onPointerDown(event);
}}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={(event) => {
const { sheet, row, column } = model.getSelectedView();
const text = model.getCellContent(sheet, row, column) || "";
console.log("dbclick", text);
workbookState.startEditing("cell", `${text}`);
setEditorContext((c: EditorState) => {
console.log("text", text, c.id);
return {
mode: c.mode,
insertRange: c.insertRange,
baseText: text,
dontChange: true,
id: c.id,
};
});
setEditing(true);
event.stopPropagation();
event.preventDefault();
// refresh();
}}
>
<SheetCanvas ref={canvasElement} />
<CellOutline ref={cellOutline}>
{
<Editor
minimalWidth={200}
minimalHeight={90}
textColor="#333"
getStyledText={(text: string, insertRangeText: string) => {
return getFormulaHTML(
text,
0,
sheetNames,
editorContext.insertRange,
insertRangeText
);
}}
onEditEnd={(text: string) => {
console.log(text);
setEditing(false);
model.setUserInput(selectedSheet, row, column, text);
}}
originalText={
model.getCellContent(selectedSheet, row, column) || ""
}
display={isEditing}
cell={{ sheet: selectedSheet, row, column }}
sheetNames={sheetNames}
/>
}
</CellOutline>
<AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle
ref={cellOutlineHandle}
onPointerDown={onPointerHandleDown}
/>
<ColumnResizeGuide ref={columnResizeGuide} />
<RowResizeGuide ref={rowResizeGuide} />
<ColumnHeaders ref={columnHeaders} />
</SheetContainer>
</Wrapper>
// </EditorContext.Provider>
);
}
const Spacer = styled("div")`
position: absolute;
height: 5000px;
width: 5000px;
`;
const SheetContainer = styled("div")`
position: sticky;
top: 0px;
left: 0px;
height: 100%;
.column-resize-handle {
position: absolute;
top: 0px;
width: 3px;
opacity: 0;
background: ${outlineColor};
border-radius: 5px;
cursor: col-resize;
}
.column-resize-handle:hover {
opacity: 1;
}
.row-resize-handle {
position: absolute;
left: 0px;
height: 3px;
opacity: 0;
background: ${outlineColor};
border-radius: 5px;
cursor: row-resize;
}
.row-resize-handle:hover {
opacity: 1;
}
`;
const Wrapper = styled("div")({
position: "absolute",
overflow: "scroll",
top: 71,
left: 0,
right: 0,
bottom: 41,
});
const SheetCanvas = styled("canvas")`
position: relative;
top: 0px;
left: 0px;
right: 0px;
bottom: 40px;
`;
const ColumnResizeGuide = styled("div")`
position: absolute;
top: 0px;
display: none;
height: 100%;
width: 0px;
border-left: 1px dashed ${outlineColor};
`;
const ColumnHeaders = styled("div")`
position: absolute;
left: 0px;
top: 0px;
overflow: hidden;
display: flex;
& .column-header {
display: inline-block;
text-align: center;
overflow: hidden;
height: 100%;
user-select: none;
}
`;
const RowResizeGuide = styled("div")`
position: absolute;
display: none;
left: 0px;
height: 0px;
width: 100%;
border-top: 1px dashed ${outlineColor};
`;
const AreaOutline = styled("div")`
position: absolute;
border: 1px solid ${outlineColor};
border-radius: 3px;
background-color: ${outlineBackgroundColor};
`;
const CellOutline = styled("div")`
position: absolute;
border: 2px solid ${outlineColor};
border-radius: 3px;
word-break: break-word;
font-size: 13px;
display: flex;
`;
const CellOutlineHandle = styled("div")`
position: absolute;
width: 5px;
height: 5px;
background: ${outlineColor};
cursor: crosshair;
// border: 1px solid white;
border-radius: 1px;
`;
const ExtendToOutline = styled("div")`
position: absolute;
border: 1px dashed ${outlineColor};
border-radius: 3px;
`;
export default Worksheet;

16
webapp/src/fonts.css Normal file
View File

@@ -0,0 +1,16 @@
/* inter-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: url('fonts/inter-v13-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* inter-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Inter';
font-style: normal;
font-weight: 600;
src: url('fonts/inter-v13-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

Binary file not shown.

Binary file not shown.

18
webapp/src/i18n.ts Normal file
View File

@@ -0,0 +1,18 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import translationEN from './locale/en_us.json';
const resources = {
'en-US': { translation: translationEN },
};
i18n.use(initReactI18next).init({
resources,
lng: 'en-US',
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="arrow-middle-from-line" clip-path="url(#clip0_107_4135)">
<path id="Vector" d="M8 14.6667V10.6667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M8 5.33333V1.33333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M14.6667 8H1.33334" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M10 12.6667L8 10.6667L6 12.6667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M10 3.33333L8 5.33333L6 3.33333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_107_4135">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 869 B

Some files were not shown because too many files have changed in this diff Show More