diff --git a/Cargo.lock b/Cargo.lock index cbcdd993..05b01121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,6 +1073,7 @@ name = "wasm" version = "0.5.0" dependencies = [ "ironcalc_base", + "js-sys", "serde", "serde-wasm-bindgen", "wasm-bindgen", diff --git a/base/Cargo.toml b/base/Cargo.toml index e43def8b..323b78fe 100644 --- a/base/Cargo.toml +++ b/base/Cargo.toml @@ -25,6 +25,7 @@ csv = "1.3.0" default = ["use_regex_full"] use_regex_full = ["regex"] use_regex_lite = ["regex-lite"] +single_threaded = [] [dev-dependencies] serde_json = "1.0" diff --git a/base/src/lib.rs b/base/src/lib.rs index 08fc1072..1ccc3abe 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -59,6 +59,10 @@ pub mod mock_time; pub use model::get_milliseconds_since_epoch; pub use model::Model; -pub use user_model::BorderArea; -pub use user_model::ClipboardData; -pub use user_model::UserModel; +pub use user_model::{ + common::{BorderArea, ClipboardData, ModelEvent}, + event::{EventEmitter, Subscription}, + history::Diff, + ui::SelectedView, + UserModel, +}; diff --git a/base/src/model.rs b/base/src/model.rs index f2879cb3..88aad465 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -1797,6 +1797,20 @@ impl Model { } } + /// Returns a list of all cells that have been changed or are being evaluated + pub fn get_changed_cells(&self) -> Vec { + self.cells + .keys() + .map( + |&(sheet, row, column)| crate::user_model::history::CellReference { + sheet, + row, + column, + }, + ) + .collect() + } + /// Removes the content of the cell but leaves the style. /// /// See also: diff --git a/base/src/types.rs b/base/src/types.rs index 07b41bc8..90f9600f 100644 --- a/base/src/types.rs +++ b/base/src/types.rs @@ -64,7 +64,7 @@ pub struct DefinedName { /// * state: /// 18.18.68 ST_SheetState (Sheet Visibility Types) /// hidden, veryHidden, visible -#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Serialize)] pub enum SheetState { Visible, Hidden, @@ -84,7 +84,7 @@ 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)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct WorksheetView { /// The row index of the currently selected cell. pub row: i32, @@ -99,7 +99,7 @@ pub struct WorksheetView { } /// Internal representation of a worksheet Excel object -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct Worksheet { pub dimension: String, pub cols: Vec, @@ -124,7 +124,7 @@ pub struct Worksheet { pub type SheetData = HashMap>; // ECMA-376-1:2016 section 18.3.1.73 -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct Row { /// Row index pub r: i32, @@ -136,7 +136,7 @@ pub struct Row { } // ECMA-376-1:2016 section 18.3.1.13 -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct Col { // 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. @@ -159,7 +159,7 @@ pub enum CellType { CompoundData = 128, } -#[derive(Encode, Decode, Debug, Clone, PartialEq)] +#[derive(Encode, Decode, Debug, Clone, PartialEq, Serialize)] pub enum Cell { EmptyCell { s: i32, @@ -226,7 +226,7 @@ impl Default for Cell { } } -#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Serialize)] pub struct Comment { pub text: String, pub author_name: String, diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 0b4b172d..5ceecc0b 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -6,7 +6,7 @@ use csv::{ReaderBuilder, WriterBuilder}; use serde::{Deserialize, Serialize}; use crate::{ - constants::{self, LAST_COLUMN, LAST_ROW}, + constants::{COLUMN_WIDTH_FACTOR, LAST_COLUMN, LAST_ROW}, expressions::{ types::{Area, CellReferenceIndex}, utils::{is_valid_column_number, is_valid_row}, @@ -16,14 +16,25 @@ use crate::{ Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState, Style, VerticalAlignment, }, + user_model::{ + event::{EventEmitter, Subscription}, + history::{ + CellReference, ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, + }, + }, utils::is_valid_hex_color, }; -use crate::user_model::history::{ - ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, -}; - use super::border_utils::is_max_border; + +/// Events that the `UserModel` can emit. +pub enum ModelEvent { + /// A list of diffs (a single user action) that can be undone/redone. + Diffs(DiffList), + /// An informational event that cells have been evaluated. + CellsEvaluated(Vec), +} + /// Data for the clipboard pub type ClipboardData = HashMap>; @@ -223,6 +234,7 @@ pub struct UserModel { history: History, send_queue: Vec, pause_evaluation: bool, + event_emitter: EventEmitter, } impl Debug for UserModel { @@ -239,6 +251,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), } } @@ -253,6 +266,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), }) } @@ -267,6 +281,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), }) } @@ -351,13 +366,48 @@ impl UserModel { self.pause_evaluation = false; } + /// Subscribes to diff events. + /// Returns a Subscription handle that automatically unsubscribes when dropped. + #[cfg(any(target_arch = "wasm32", feature = "single_threaded"))] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&ModelEvent) + 'static, + { + self.event_emitter.subscribe(listener) + } + + /// Subscribes to diff events. + /// Returns a Subscription handle that automatically unsubscribes when dropped. + #[cfg(not(any(target_arch = "wasm32", feature = "single_threaded")))] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&ModelEvent) + Send + Sync + 'static, + { + self.event_emitter.subscribe(listener) + } + /// Forces an evaluation of the model /// /// See also: /// * [Model::evaluate] /// * [UserModel::pause_evaluation] pub fn evaluate(&mut self) { - self.model.evaluate() + // Perform evaluation + self.model.evaluate(); + + // Get the list of cells that were just evaluated + let evaluated_cells = self.model.get_changed_cells(); + + // Emit cells evaluated event if there are any cells that were evaluated + if !evaluated_cells.is_empty() { + self.event_emitter + .emit(&ModelEvent::CellsEvaluated(evaluated_cells)); + } + } + + /// Returns a list of all cells that have been changed or are being evaluated + pub fn get_changed_cells(&self) -> Vec { + self.model.get_changed_cells() } /// Returns the list of pending diffs and removes them from the queue @@ -704,7 +754,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -734,7 +784,7 @@ impl UserModel { sheet, row: row.r, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -786,7 +836,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -841,7 +891,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -1680,13 +1730,13 @@ impl UserModel { // remain in the copied area let source = &CellReferenceIndex { sheet, - column: *source_column, row: *source_row, + column: *source_column, }; let target = &CellReferenceIndex { sheet, - column: target_column, row: target_row, + column: target_column, }; let new_value = if is_cut { self.model @@ -1876,6 +1926,8 @@ impl UserModel { r#type: DiffType::Redo, list: diff_list.clone(), }); + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); self.history.push(diff_list); } @@ -2003,7 +2055,7 @@ impl UserModel { } // makes sure that the width and style is correct if let Some(col) = &old_data.column { - let width = col.width * constants::COLUMN_WIDTH_FACTOR; + let width = col.width * COLUMN_WIDTH_FACTOR; let style = col.style; worksheet.set_column_width_and_style(*column, width, style)?; } @@ -2155,16 +2207,14 @@ impl UserModel { Diff::DeleteRowStyle { sheet, row, - old_value, + old_value: _, } => { - if let Some(s) = old_value.as_ref() { - self.model.set_row_style(*sheet, *row, s)?; - } else { - self.model.delete_row_style(*sheet, *row)?; - } + self.model.delete_row_style(*sheet, *row)?; } } } + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); if needs_evaluation { self.evaluate_if_not_paused(); } @@ -2366,7 +2416,8 @@ impl UserModel { } } } - + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); if needs_evaluation { self.evaluate_if_not_paused(); } diff --git a/base/src/user_model/event.rs b/base/src/user_model/event.rs new file mode 100644 index 00000000..0e34d4b5 --- /dev/null +++ b/base/src/user_model/event.rs @@ -0,0 +1,125 @@ +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, RwLock, Weak, + }, +}; + +// Type alias to reduce complexity +#[cfg(any(target_arch = "wasm32", feature = "single_threaded"))] +type ListenerMap = HashMap>; +#[cfg(not(any(target_arch = "wasm32", feature = "single_threaded")))] +type ListenerMap = HashMap>; + +/// RAII handle: dropping it (or calling `.unsubscribe()`) removes the listener. +pub struct Subscription { + id: u32, + inner: Weak>, +} + +impl Subscription { + /// explicitly unsubscribe early + pub fn unsubscribe(self) { + if let Some(inner) = self.inner.upgrade() { + if let Ok(mut listeners) = inner.listeners.write() { + listeners.remove(&self.id); + } + } + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + if let Ok(mut listeners) = inner.listeners.write() { + listeners.remove(&self.id); + } + } + } +} + +struct Inner { + listeners: RwLock>, + next_id: AtomicU32, +} + +/// A tiny, generic event emitter. +#[derive(Clone)] +pub struct EventEmitter { + inner: Arc>, +} + +impl EventEmitter { + /// Create a new emitter. + pub fn new() -> Self { + Self { + inner: Arc::new(Inner { + listeners: RwLock::new(HashMap::new()), + next_id: AtomicU32::new(0), + }), + } + } + + /// Subscribe to events carrying `&T`. + /// Returns a `Subscription` which unsubscribes on drop. + #[cfg(any(target_arch = "wasm32", feature = "single_threaded"))] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&T) + 'static, + { + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.insert(id, Box::new(listener)); + } + + Subscription { + id, + inner: Arc::downgrade(&self.inner), + } + } + + /// Subscribe to events carrying `&T`. + /// Returns a `Subscription` which unsubscribes on drop. + #[cfg(not(any(target_arch = "wasm32", feature = "single_threaded")))] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&T) + Send + Sync + 'static, + { + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.insert(id, Box::new(listener)); + } + + Subscription { + id, + inner: Arc::downgrade(&self.inner), + } + } + + /// Manually unsubscribe by ID. Returns `true` if removed. + pub fn unsubscribe(&self, id: u32) -> bool { + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.remove(&id).is_some() + } else { + false + } + } + + /// Emit a payload to all current listeners. + pub fn emit(&self, payload: &T) { + // Call listeners directly under read lock + // This is safe because we're not modifying the map during iteration + if let Ok(read) = self.inner.listeners.read() { + for callback in read.values() { + callback(payload); + } + } + } +} + +impl Default for EventEmitter { + fn default() -> Self { + Self::new() + } +} diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index 53268e12..a5b6cab9 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -1,23 +1,33 @@ use std::collections::HashMap; use bitcode::{Decode, Encode}; +use serde::Serialize; use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet}; -#[derive(Clone, Encode, Decode)] -pub(crate) struct RowData { - pub(crate) row: Option, - pub(crate) data: HashMap, +#[derive(Clone, Encode, Decode, Serialize)] +pub struct RowData { + pub row: Option, + pub data: HashMap, } -#[derive(Clone, Encode, Decode)] -pub(crate) struct ColumnData { - pub(crate) column: Option, - pub(crate) data: HashMap, +#[derive(Clone, Encode, Decode, Serialize)] +pub struct ColumnData { + pub column: Option, + pub data: HashMap, } -#[derive(Clone, Encode, Decode)] -pub(crate) enum Diff { +#[allow(missing_docs)] +#[derive(Clone, Encode, Decode, Serialize)] +pub struct CellReference { + pub sheet: u32, + pub row: i32, + pub column: i32, +} + +#[allow(missing_docs)] +#[derive(Clone, Encode, Decode, Serialize)] +pub enum Diff { // Cell diffs SetCellValue { sheet: u32, diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index 06cd85c4..7280db88 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -2,9 +2,10 @@ mod border; mod border_utils; -mod common; -mod history; -mod ui; +pub mod common; +pub mod event; +pub mod history; +pub mod ui; pub use common::UserModel; @@ -12,4 +13,3 @@ pub use common::UserModel; pub use ui::SelectedView; pub use common::BorderArea; -pub use common::ClipboardData; diff --git a/base/src/user_model/ui.rs b/base/src/user_model/ui.rs index b217b1b4..fea42442 100644 --- a/base/src/user_model/ui.rs +++ b/base/src/user_model/ui.rs @@ -8,12 +8,19 @@ use super::common::UserModel; #[derive(Serialize, Deserialize)] #[cfg_attr(test, derive(PartialEq, Debug))] +/// Represents the user's current view of the worksheet, including selection and scroll position. pub struct SelectedView { + /// The index of the selected sheet. pub sheet: u32, + /// The selected row. pub row: i32, + /// The selected column. pub column: i32, + /// The selected range, as `[start_row, start_column, end_row, end_column]`. pub range: [i32; 4], + /// The first row visible in the viewport. pub top_row: i32, + /// The first column visible in the viewport. pub left_column: i32, } diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 23152109..67855c22 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -14,10 +14,11 @@ crate-type = ["cdylib"] # Uses `../ironcalc/base` when used locally, and uses # the inicated version from crates.io when published. # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations -ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] } +ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite", "single_threaded"] } serde = { version = "1.0", features = ["derive"] } wasm-bindgen = "0.2.92" serde-wasm-bindgen = "0.4" +js-sys = "0.3" [dev-dependencies] wasm-bindgen-test = "0.3.38" diff --git a/bindings/wasm/README.pkg.md b/bindings/wasm/README.pkg.md index 8e62d0ec..7e238805 100644 --- a/bindings/wasm/README.pkg.md +++ b/bindings/wasm/README.pkg.md @@ -31,3 +31,42 @@ function compute() { compute(); ``` + +To listen to model changes you can subscribe to diff events: + +```TypeScript +import init, { Model } from "@ironcalc/wasm"; + +await init(); + +const model = new Model("Sheet1", "en", "UTC"); + +model.onDiffs(() => { + // React to diff list updates + redraw(); +}); + +model.setUserInput(0, 1, 1, "=1+1"); +``` + +To listen to cells that change during evaluation (formulas that recalculate): + +```TypeScript +import init, { Model } from "@ironcalc/wasm"; + +await init(); + +const model = new Model("Sheet1", "en", "UTC"); + +model.onCellsEvaluated((cellReferences) => { + // cellReferences is an array of {sheet, row, column} objects + // that represent cells that were recalculated during evaluation + cellReferences.forEach(cell => { + console.log(`Cell ${cell.sheet}:${cell.row}:${cell.column} was evaluated`); + }); +}); + +// Setting a formula will trigger evaluation +model.setUserInput(0, 1, 1, "=SUM(A2:A5)"); +model.setUserInput(0, 2, 1, "10"); // This will trigger re-evaluation of A1 +``` diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2ed..c766fc0b 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,7 +1,10 @@ +use js_sys::Function; use serde::Serialize; +use std::cell::RefCell; +use std::rc::Rc; use wasm_bindgen::{ prelude::{wasm_bindgen, JsError}, - JsValue, + JsCast, JsValue, }; use ironcalc_base::{ @@ -83,6 +86,74 @@ impl Model { self.model.resume_evaluation() } + #[wasm_bindgen(js_name = "onDiffs")] + pub fn on_diffs(&mut self, callback: Function) -> Function { + let subscription = self.model.subscribe(move |event| { + if let ironcalc_base::ModelEvent::Diffs(diffs) = event { + match serde_wasm_bindgen::to_value(diffs) { + Ok(js_diffs) => { + let _ = callback.call1(&JsValue::NULL, &js_diffs); + } + Err(_e) => { + // Silent skip: if serialization fails, we skip this diff event + } + } + } + }); + + // Store subscription in an Rc> so it can be moved into the closure + let subscription_rc = Rc::new(RefCell::new(Some(subscription))); + let subscription_clone = subscription_rc.clone(); + + // Create the unsubscribe function + let unsubscribe_fn = wasm_bindgen::closure::Closure::wrap(Box::new(move || { + if let Ok(mut sub) = subscription_clone.try_borrow_mut() { + if let Some(subscription) = sub.take() { + subscription.unsubscribe(); + } + } + }) as Box); + + let js_function = unsubscribe_fn.as_ref().unchecked_ref::().clone(); + unsubscribe_fn.forget(); // Prevent the closure from being dropped + + js_function + } + + #[wasm_bindgen(js_name = "onCellsEvaluated")] + pub fn on_cells_evaluated(&mut self, callback: Function) -> Function { + let subscription = self.model.subscribe(move |event| { + if let ironcalc_base::ModelEvent::CellsEvaluated(cells) = event { + match serde_wasm_bindgen::to_value(cells) { + Ok(js_cells) => { + let _ = callback.call1(&JsValue::NULL, &js_cells); + } + Err(_e) => { + // Silent skip: if serialization fails, we skip this event + } + } + } + }); + + // Store subscription in an Rc> so it can be moved into the closure + let subscription_rc = Rc::new(RefCell::new(Some(subscription))); + let subscription_clone = subscription_rc.clone(); + + // Create the unsubscribe function + let unsubscribe_fn = wasm_bindgen::closure::Closure::wrap(Box::new(move || { + if let Ok(mut sub) = subscription_clone.try_borrow_mut() { + if let Some(subscription) = sub.take() { + subscription.unsubscribe(); + } + } + }) as Box); + + let js_function = unsubscribe_fn.as_ref().unchecked_ref::().clone(); + unsubscribe_fn.forget(); // Prevent the closure from being dropped + + js_function + } + pub fn evaluate(&mut self) { self.model.evaluate(); } diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 37466996..a387d4bb 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -1,9 +1,17 @@ import test from 'node:test'; import assert from 'node:assert' import { Model } from "../pkg/wasm.js"; +import { setTimeout } from 'node:timers/promises'; const DEFAULT_ROW_HEIGHT = 28; +// Helper to sort cells for consistent comparison +const sortCells = (cells) => cells.sort((a, b) => { + if (a.sheet !== b.sheet) return a.sheet - b.sheet; + if (a.row !== b.row) return a.row - b.row; + return a.column - b.column; +}); + test('Frozen rows and columns', () => { let model = new Model('Workbook1', 'en', 'UTC'); assert.strictEqual(model.getFrozenRowsCount(0), 0); @@ -131,4 +139,514 @@ test("autofill", () => { }); +test('onDiffs', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diffs => { + events.push(...diffs); + }); + + model.setUserInput(0, 1, 1, 'test'); + await setTimeout(0); + + const expectedEvents = [ + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: 'test', + old_value: undefined + } + }, + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + + +test('onDiffs emits correct diff types for various operations', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diffs => { + events.push(...diffs); + }); + + // Test various operations that should emit different diff types + model.setUserInput(0, 1, 1, '42'); + model.insertRow(0, 2); + model.setRowsHeight(0, 1, 1, 35); + model.insertColumn(0, 2); + model.setColumnsWidth(0, 1, 1, 120); + model.newSheet(); + model.renameSheet(1, "TestSheet"); + model.setFrozenRowsCount(0, 2); + model.setFrozenColumnsCount(0, 3); + + // Allow any async operations to complete + await setTimeout(0); + + const expectedEvents = [ + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: '42', + old_value: undefined + } + }, + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + { + SetRowHeight: { + sheet: 0, + row: 1, + new_value: 35, + old_value: 28 + } + }, + { + InsertColumn: { + sheet: 0, + column: 2 + } + }, + { + SetColumnWidth: { + sheet: 0, + column: 1, + new_value: 120, + old_value: 125 + } + }, + { + NewSheet: { + index: 1, + name: 'Sheet2' + } + }, + { + RenameSheet: { + index: 1, + old_value: 'Sheet2', + new_value: 'TestSheet' + } + }, + { + SetFrozenRowsCount: { + sheet: 0, + new_value: 2, + old_value: 0 + } + }, + { + SetFrozenColumnsCount: { + sheet: 0, + new_value: 3, + old_value: 0 + } + } + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + +test('onDiffs emits full diff objects for undo/redo operations', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diffs => { + events.push(...diffs); + }); + + // Perform initial operations + model.setUserInput(0, 1, 1, 'Hello'); + model.setUserInput(0, 1, 2, 'World'); + model.insertRow(0, 2); + + // Test undo - should emit diffs for undoing operations + model.undo(); + model.undo(); + + // Test redo - should emit diffs for redoing operations + model.redo(); + model.redo(); + + await setTimeout(0); + + const expectedEvents = [ + // Initial operations (3 events) + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: 'Hello', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 1, + column: 2, + new_value: 'World', + old_value: undefined + } + }, + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + // Undo operations (2 events) - Note: these emit the same diff structures as the forward operations + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + { + SetCellValue: { + sheet: 0, + row: 1, + column: 2, + new_value: 'World', + old_value: undefined + } + }, + // Redo operations (2 events) - These also emit the same diff structures + { + SetCellValue: { + sheet: 0, + row: 1, + column: 2, + new_value: 'World', + old_value: undefined + } + }, + { + InsertRow: { + sheet: 0, + row: 2 + } + } + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + +test('onDiffs handles multiple subscribers and provides full diff objects', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diffs => { + events.push(...diffs); + }); + + // Perform complex operations that generate multiple diffs + model.setUserInput(0, 1, 1, '=SUM(A2:A5)'); + model.setUserInput(0, 2, 1, '10'); + model.setUserInput(0, 3, 1, '20'); + model.setUserInput(0, 4, 1, '30'); + + // Test row operations + model.insertRow(0, 2); + model.deleteRow(0, 2); + + // Test range operations + model.rangeClearContents(0, 2, 1, 3, 1); + + await setTimeout(0); + + const expectedEvents = [ + // SetUserInput operations (4 events) + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: '=SUM(A2:A5)', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 2, + column: 1, + new_value: '10', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 3, + column: 1, + new_value: '20', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 4, + column: 1, + new_value: '30', + old_value: undefined + } + }, + // Row operations (2 events) + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + { + DeleteRow: { + sheet: 0, + row: 2, + old_data: { + data: new Map(), + row: undefined + } + } + }, + // Range clear operations (2 events) + { + CellClearContents: { + sheet: 0, + row: 2, + column: 1, + old_value: { + NumberCell: { + v: 10, + s: 0 + } + } + } + }, + { + CellClearContents: { + sheet: 0, + row: 3, + column: 1, + old_value: { + NumberCell: { + v: 20, + s: 0 + } + } + } + } + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + +test('onDiffs returns unregister function that works correctly', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events1 = []; + const events2 = []; + + // Register two listeners + const unregister1 = model.onDiffs(diffs => { + events1.push(...diffs); + }); + + const unregister2 = model.onDiffs(diffs => { + events2.push(...diffs); + }); + + // Both should be functions + assert.strictEqual(typeof unregister1, 'function', 'onDiffs should return a function'); + assert.strictEqual(typeof unregister2, 'function', 'onDiffs should return a function'); + + // Trigger some events + model.setUserInput(0, 1, 1, 'Test'); + model.setUserInput(0, 1, 2, 'Test2'); + + await setTimeout(0); + + // Both listeners should have received events + assert.strictEqual(events1.length, 2, 'First listener should receive 2 events'); + assert.strictEqual(events2.length, 2, 'Second listener should receive 2 events'); + + // Unregister first listener + unregister1(); + + // Trigger more events + model.setUserInput(0, 1, 3, 'Test3'); + + await setTimeout(0); + + assert.strictEqual(events1.length, 2, 'First listener should receive 2 events'); + assert.strictEqual(events2.length, 3, 'Second listener should receive 2 events'); + + // Call the second unregister too + unregister2(); +}); + +test('onCellsEvaluated tracks formula evaluation', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + // Set up a formula and its dependencies + model.setUserInput(0, 1, 1, '=A2+A3'); // A1 = A2 + A3 + model.setUserInput(0, 2, 1, '10'); // A2 = 10 + model.setUserInput(0, 3, 1, '20'); // A3 = 20 + model.evaluate(); + + // Update a dependency to trigger re-evaluation + model.setUserInput(0, 2, 1, '15'); // Change A2 from 10 to 15 + model.evaluate(); + + await setTimeout(0); + + const expectedCells = [ + // First evaluation of A1 + { sheet: 0, row: 1, column: 1 }, + // Re-evaluation of A1 after dependency changed + { sheet: 0, row: 1, column: 1 }, + ]; + assert.strictEqual(evaluatedCells.length, expectedCells.length, `Should have exactly ${expectedCells.length} cell evaluation events`); + assert.deepStrictEqual(sortCells(evaluatedCells), sortCells(expectedCells), 'The evaluated cells should match the expected cells'); + + // Verify the formula calculated correctly + const result = model.getFormattedCellValue(0, 1, 1); + assert.strictEqual(result, '35', 'Formula should calculate 15 + 20 = 35'); +}); + +test('onCellsEvaluated tracks complex formula dependencies', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + // Set up a chain of formulas: A1 -> B1 -> C1 + model.setUserInput(0, 1, 1, '10'); // A1 = 10 (base value) + model.setUserInput(0, 1, 2, '=A1*2'); // B1 = A1 * 2 + model.setUserInput(0, 1, 3, '=B1+5'); // C1 = B1 + 5 + model.evaluate(); + + // Update the root value to trigger cascade re-evaluation + model.setUserInput(0, 1, 1, '5'); // Change A1 from 10 to 5 + model.evaluate(); + + await setTimeout(0); + + const expectedCells = [ + // Initial evaluation + { sheet: 0, row: 1, column: 2 }, // B1 + { sheet: 0, row: 1, column: 3 }, // C1 + // Re-evaluation after dependency change + { sheet: 0, row: 1, column: 2 }, // B1 + { sheet: 0, row: 1, column: 3 }, // C1 + ]; + + assert.strictEqual(evaluatedCells.length, expectedCells.length, `Should have exactly ${expectedCells.length} cell evaluation events`); + assert.deepStrictEqual(sortCells(evaluatedCells), sortCells(expectedCells), 'The evaluated cells should match the expected cells'); + + // Verify the formulas calculated correctly + assert.strictEqual(model.getFormattedCellValue(0, 1, 2), '10', 'B1 should be 5 * 2 = 10'); + assert.strictEqual(model.getFormattedCellValue(0, 1, 3), '15', 'C1 should be 10 + 5 = 15'); +}); + +test('onCellsEvaluated only fires after evaluate', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + // Set a formula + model.setUserInput(0, 1, 1, '=1+1'); + + // No cells should be evaluated yet because model.evaluate() has not been called + assert.strictEqual(evaluatedCells.length, 0, 'evaluatedCells should be empty before evaluate()'); + + // Now, trigger the evaluation + model.evaluate(); + await setTimeout(0); + + // Now, the cell should be evaluated + const expectedCells = [ + { sheet: 0, row: 1, column: 1 }, + ]; + + assert.deepStrictEqual(sortCells(evaluatedCells), sortCells(expectedCells), 'evaluatedCells should contain the evaluated cell after evaluate()'); +}); + +test('onCellsEvaluated returns unsubscribe function', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + const unsubscribe = model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + assert.strictEqual(typeof unsubscribe, 'function', 'onCellsEvaluated should return a function'); + + // Set up a formula + model.setUserInput(0, 1, 1, '=A2*2'); + model.setUserInput(0, 2, 1, '5'); + + model.evaluate(); + await setTimeout(0); + + assert.ok(evaluatedCells.length > 0, 'Should have tracked evaluated cells'); + + // Unsubscribe + unsubscribe(); + evaluatedCells.length = 0; + + // Update to trigger re-evaluation + model.setUserInput(0, 2, 1, '10'); + + model.evaluate(); + await setTimeout(0); + + assert.strictEqual(evaluatedCells.length, 0, 'Should not track cells after unsubscribing'); +}); \ No newline at end of file diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 7af55b8c..170050e9 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -233,4 +233,281 @@ export interface DefinedName { name: string; scope?: number; formula: string; -} \ No newline at end of file +} + +export interface CellReference { + sheet: number; + row: number; + column: number; +} + +export interface Cell { + type: string; // e.g., "NumberCell", "SharedString", "BooleanCell" + v?: number | boolean | string; // value, if applicable + s: number; // style index + f?: number; // formula index, if applicable + ei?: string; // error type, if applicable + o?: string; // origin, if applicable + m?: string; // error message, if applicable +} + +export interface Style { + read_only?: boolean; + quote_prefix: boolean; + fill: { + pattern_type: string; + fg_color?: string; + bg_color?: string; + }; + font: { + u: boolean; + b: boolean; + i: boolean; + strike: boolean; + sz: number; + color?: string; + name: string; + family: number; + scheme: string; + }; + border: { + diagonal_up?: boolean; + diagonal_down?: boolean; + left?: { style: string; color?: string }; + right?: { style: string; color?: string }; + top?: { style: string; color?: string }; + bottom?: { style: string; color?: string }; + diagonal?: { style: string; color?: string }; + }; + num_fmt: string; + alignment?: { + horizontal: HorizontalAlignment; + vertical: VerticalAlignment; + wrap_text: boolean; + }; +} + +export interface RowData { + row?: { r: number; height: number; custom_format: boolean; custom_height: boolean; s: number; hidden: boolean }; + data: Record; +} + +export interface ColumnData { + column?: { min: number; max: number; width: number; custom_width: boolean; style?: number }; + data: Record; +} + +export interface Worksheet { + dimension: string; + cols: Array<{ min: number; max: number; width: number; custom_width: boolean; style?: number }>; + rows: Array<{ r: number; height: number; custom_format: boolean; custom_height: boolean; s: number; hidden: boolean }>; + name: string; + sheet_data: Record>; + shared_formulas: string[]; + sheet_id: number; + state: string; // e.g., "Visible", "Hidden", "VeryHidden" + color?: string; + merge_cells: string[]; + comments: Array<{ text: string; author_name: string; author_id?: string; cell_ref: string }>; + frozen_rows: number; + frozen_columns: number; + views: Record; + show_grid_lines: boolean; +} + +// Individual Diff type interfaces for better developer experience +export interface SetCellValueDiff { + sheet: number; + row: number; + column: number; + new_value: string; + old_value?: Cell | null; +} + +export interface CellClearContentsDiff { + sheet: number; + row: number; + column: number; + old_value?: Cell | null; +} + +export interface CellClearAllDiff { + sheet: number; + row: number; + column: number; + old_value?: Cell | null; + old_style: Style; +} + +export interface CellClearFormattingDiff { + sheet: number; + row: number; + column: number; + old_style?: Style | null; +} + +export interface SetCellStyleDiff { + sheet: number; + row: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetColumnWidthDiff { + sheet: number; + column: number; + new_value: number; + old_value: number; +} + +export interface SetRowHeightDiff { + sheet: number; + row: number; + new_value: number; + old_value: number; +} + +export interface SetColumnStyleDiff { + sheet: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetRowStyleDiff { + sheet: number; + row: number; + old_value?: Style | null; + new_value: Style; +} + +export interface DeleteColumnStyleDiff { + sheet: number; + column: number; + old_value?: Style | null; +} + +export interface DeleteRowStyleDiff { + sheet: number; + row: number; + old_value?: Style | null; +} + +export interface InsertRowDiff { + sheet: number; + row: number; +} + +export interface DeleteRowDiff { + sheet: number; + row: number; + old_data: RowData; +} + +export interface InsertColumnDiff { + sheet: number; + column: number; +} + +export interface DeleteColumnDiff { + sheet: number; + column: number; + old_data: ColumnData; +} + +export interface DeleteSheetDiff { + sheet: number; + old_data: Worksheet; +} + +export interface SetFrozenRowsCountDiff { + sheet: number; + new_value: number; + old_value: number; +} + +export interface SetFrozenColumnsCountDiff { + sheet: number; + new_value: number; + old_value: number; +} + +export interface NewSheetDiff { + index: number; + name: string; +} + +export interface RenameSheetDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetSheetColorDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetSheetStateDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetShowGridLinesDiff { + sheet: number; + old_value: boolean; + new_value: boolean; +} + +export interface CreateDefinedNameDiff { + name: string; + scope?: number; + value: string; +} + +export interface DeleteDefinedNameDiff { + name: string; + scope?: number; + old_value: string; +} + +export interface UpdateDefinedNameDiff { + name: string; + scope?: number; + old_formula: string; + new_name: string; + new_scope?: number; + new_formula: string; +} + +// Union type for all Diff variants - these are serialized as tagged enums with variant names as keys +export type Diff = + | { SetCellValue: SetCellValueDiff } + | { CellClearContents: CellClearContentsDiff } + | { CellClearAll: CellClearAllDiff } + | { CellClearFormatting: CellClearFormattingDiff } + | { SetCellStyle: SetCellStyleDiff } + | { SetColumnWidth: SetColumnWidthDiff } + | { SetRowHeight: SetRowHeightDiff } + | { SetColumnStyle: SetColumnStyleDiff } + | { SetRowStyle: SetRowStyleDiff } + | { DeleteColumnStyle: DeleteColumnStyleDiff } + | { DeleteRowStyle: DeleteRowStyleDiff } + | { InsertRow: InsertRowDiff } + | { DeleteRow: DeleteRowDiff } + | { InsertColumn: InsertColumnDiff } + | { DeleteColumn: DeleteColumnDiff } + | { DeleteSheet: DeleteSheetDiff } + | { SetFrozenRowsCount: SetFrozenRowsCountDiff } + | { SetFrozenColumnsCount: SetFrozenColumnsCountDiff } + | { NewSheet: NewSheetDiff } + | { RenameSheet: RenameSheetDiff } + | { SetSheetColor: SetSheetColorDiff } + | { SetSheetState: SetSheetStateDiff } + | { SetShowGridLines: SetShowGridLinesDiff } + | { CreateDefinedName: CreateDefinedNameDiff } + | { DeleteDefinedName: DeleteDefinedNameDiff } + | { UpdateDefinedName: UpdateDefinedNameDiff };