From 6700dad19b6e87d74d81f7b96200efaae0f7520c Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 23 May 2024 10:19:08 +0800 Subject: [PATCH] feat: Add event listener and native support of cursor transformation for undo/redo (#369) --- crates/delta/src/delta_rope.rs | 49 ++++ .../src/container/richtext/richtext_state.rs | 60 +++-- crates/loro-internal/src/cursor.rs | 7 +- crates/loro-internal/src/event.rs | 11 + crates/loro-internal/src/handler.rs | 38 ++- crates/loro-internal/src/lib.rs | 2 +- crates/loro-internal/src/loro.rs | 15 +- crates/loro-internal/src/state.rs | 10 +- .../loro-internal/src/state/richtext_state.rs | 45 +++- crates/loro-internal/src/undo.rs | 245 +++++++++++++++--- crates/loro-wasm/src/convert.rs | 34 ++- crates/loro-wasm/src/lib.rs | 198 +++++++++++++- crates/loro/src/lib.rs | 21 +- .../loro/tests/integration_test/undo_test.rs | 124 ++++++++- loro-js/src/index.ts | 17 ++ loro-js/tests/undo.test.ts | 82 +++++- 16 files changed, 867 insertions(+), 91 deletions(-) diff --git a/crates/delta/src/delta_rope.rs b/crates/delta/src/delta_rope.rs index 1c25e3ee..03de7932 100644 --- a/crates/delta/src/delta_rope.rs +++ b/crates/delta/src/delta_rope.rs @@ -291,6 +291,55 @@ impl DeltaRope { pub fn transform_(&mut self, other: &Self, left_prior: bool) { *self = self.transform(other, left_prior); } + + /// Transforms the position based on self + pub fn transform_pos(&self, mut pos: usize, left_prior: bool) -> usize { + let mut index = 0; + let mut iter = self.iter_with_len(); + while iter.peek().is_some() { + if iter.peek_is_retain() { + let DeltaItem::Retain { len, attr: _ } = iter.next().unwrap() else { + unreachable!() + }; + index += len; + if index > pos || (index == pos && !left_prior) { + return pos; + } + } else if iter.peek_is_insert() { + if index == pos && !left_prior { + return pos; + } + + let insert_len; + match iter.peek().unwrap() { + DeltaItem::Replace { value, .. } => { + insert_len = value.rle_len(); + } + _ => { + unreachable!() + } + } + + pos += insert_len; + index += insert_len; + iter.next_with(insert_len).unwrap(); + } else { + // Delete + match iter.next().unwrap() { + DeltaItem::Replace { delete, .. } => { + pos = pos.saturating_sub(delete); + if pos < index { + pos = index; + return pos; + } + } + DeltaItem::Retain { .. } => unreachable!(), + } + } + } + + pos + } } impl PartialEq for DeltaRope { diff --git a/crates/loro-internal/src/container/richtext/richtext_state.rs b/crates/loro-internal/src/container/richtext/richtext_state.rs index d970652f..ce853ead 100644 --- a/crates/loro-internal/src/container/richtext/richtext_state.rs +++ b/crates/loro-internal/src/container/richtext/richtext_state.rs @@ -1388,8 +1388,6 @@ impl RichtextState { /// /// - If feature="wasm", index is utf16 index, /// - If feature!="wasm", index is unicode index, - /// - // PERF: this is slow pub(crate) fn cursor_to_event_index(&self, cursor: Cursor) -> usize { if cfg!(feature = "wasm") { let mut ans = 0; @@ -1416,31 +1414,33 @@ impl RichtextState { ans } else { - let mut ans = 0; - self.tree - .visit_previous_caches(cursor, |cache| match cache { - generic_btree::PreviousCache::NodeCache(c) => { - ans += c.unicode_len; - } - generic_btree::PreviousCache::PrevSiblingElem(c) => match c { - RichtextStateChunk::Text(s) => { - ans += s.unicode_len(); - } - RichtextStateChunk::Style { .. } => {} - }, - generic_btree::PreviousCache::ThisElemAndOffset { elem, offset } => { - match elem { - RichtextStateChunk::Text { .. } => { - ans += offset as i32; - } - RichtextStateChunk::Style { .. } => {} - } - } - }); - ans as usize + self.cursor_to_unicode_index(cursor) } } + pub(crate) fn cursor_to_unicode_index(&self, cursor: Cursor) -> usize { + let mut ans = 0; + self.tree + .visit_previous_caches(cursor, |cache| match cache { + generic_btree::PreviousCache::NodeCache(c) => { + ans += c.unicode_len; + } + generic_btree::PreviousCache::PrevSiblingElem(c) => match c { + RichtextStateChunk::Text(s) => { + ans += s.unicode_len(); + } + RichtextStateChunk::Style { .. } => {} + }, + generic_btree::PreviousCache::ThisElemAndOffset { elem, offset } => match elem { + RichtextStateChunk::Text { .. } => { + ans += offset as i32; + } + RichtextStateChunk::Style { .. } => {} + }, + }); + ans as usize + } + /// This method only updates `style_ranges`. /// When this method is called, the style start anchor and the style end anchor should already have been inserted. pub(crate) fn annotate_style_range(&mut self, range: Range, style: Arc) { @@ -1902,6 +1902,18 @@ impl RichtextState { self.cursor_to_event_index(cursor.cursor) } + pub fn event_index_to_unicode_index(&self, index: usize) -> usize { + if !cfg!(feature = "wasm") { + return index; + } + + let Some(cursor) = self.tree.query::(&index) else { + return 0; + }; + + self.cursor_to_unicode_index(cursor.cursor) + } + #[allow(unused)] pub(crate) fn check(&self) { if !cfg!(any(debug_assertions, test)) { diff --git a/crates/loro-internal/src/cursor.rs b/crates/loro-internal/src/cursor.rs index 87d52050..a73ee251 100644 --- a/crates/loro-internal/src/cursor.rs +++ b/crates/loro-internal/src/cursor.rs @@ -10,6 +10,10 @@ pub struct Cursor { /// /// Side info can help to model the selection pub side: Side, + /// The position of the cursor in the container when the cursor is created. + /// For text, this is the unicode codepoint index + /// This value is not encoded + pub(crate) origin_pos: usize, } #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -59,11 +63,12 @@ pub enum CannotFindRelativePosition { } impl Cursor { - pub fn new(id: Option, container: ContainerID, side: Side) -> Self { + pub fn new(id: Option, container: ContainerID, side: Side, origin_pos: usize) -> Self { Self { id, container, side, + origin_pos, } } diff --git a/crates/loro-internal/src/event.rs b/crates/loro-internal/src/event.rs index bc28f654..aff62e83 100644 --- a/crates/loro-internal/src/event.rs +++ b/crates/loro-internal/src/event.rs @@ -4,6 +4,7 @@ use itertools::Itertools; use loro_delta::{array_vec::ArrayVec, delta_trait::DeltaAttr, DeltaItem, DeltaRope}; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; +use tracing::trace; use crate::{ container::richtext::richtext_state::RichtextStateChunk, @@ -453,6 +454,16 @@ impl Diff { _ => unreachable!(), } } + + /// Transform the cursor based on this diff + pub(crate) fn transform_cursor(&self, pos: usize, left_prior: bool) -> usize { + let ans = match self { + Diff::List(list) => list.transform_pos(pos, left_prior), + Diff::Text(text) => text.transform_pos(pos, left_prior), + _ => pos, + }; + ans + } } pub fn str_to_path(s: &str) -> Option> { diff --git a/crates/loro-internal/src/handler.rs b/crates/loro-internal/src/handler.rs index ab8401d4..348a766a 100644 --- a/crates/loro-internal/src/handler.rs +++ b/crates/loro-internal/src/handler.rs @@ -1728,14 +1728,35 @@ impl TextHandler { } } - /// Get the stable position representation for the target pos pub fn get_cursor(&self, event_index: usize, side: Side) -> Option { + self.get_cursor_internal(event_index, side, true) + } + + /// Get the stable position representation for the target pos + pub(crate) fn get_cursor_internal( + &self, + index: usize, + side: Side, + get_by_event_index: bool, + ) -> Option { match &self.inner { MaybeDetached::Detached(_) => None, MaybeDetached::Attached(a) => { - let (id, len) = a.with_state(|s| { + let (id, len, origin_pos) = a.with_state(|s| { let s = s.as_richtext_state_mut().unwrap(); - (s.get_stable_position(event_index), s.len_event()) + ( + s.get_stable_position(index, get_by_event_index), + if get_by_event_index { + s.len_event() + } else { + s.len_unicode() + }, + if get_by_event_index { + s.event_index_to_unicode_index(index) + } else { + index + }, + ) }); if len == 0 { @@ -1747,14 +1768,16 @@ impl TextHandler { } else { side }, + origin_pos: 0, }); } - if len <= event_index { + if len <= index { return Some(Cursor { id: None, container: self.id(), side: Side::Right, + origin_pos: len, }); } @@ -1763,6 +1786,7 @@ impl TextHandler { id: Some(id), container: self.id(), side, + origin_pos, }) } } @@ -2129,6 +2153,7 @@ impl ListHandler { } else { side }, + origin_pos: 0, }); } @@ -2137,6 +2162,7 @@ impl ListHandler { id: None, container: self.id(), side: Side::Right, + origin_pos: len, }); } @@ -2145,6 +2171,7 @@ impl ListHandler { id: Some(id.id()), container: self.id(), side, + origin_pos: pos, }) } } @@ -2778,6 +2805,7 @@ impl MovableListHandler { } else { side }, + origin_pos: 0, }); } @@ -2786,6 +2814,7 @@ impl MovableListHandler { id: None, container: self.id(), side: Side::Right, + origin_pos: len, }); } @@ -2794,6 +2823,7 @@ impl MovableListHandler { id: Some(id.id()), container: self.id(), side, + origin_pos: pos, }) } } diff --git a/crates/loro-internal/src/lib.rs b/crates/loro-internal/src/lib.rs index 75e17e6d..d52827ac 100644 --- a/crates/loro-internal/src/lib.rs +++ b/crates/loro-internal/src/lib.rs @@ -55,7 +55,7 @@ pub use error::{LoroError, LoroResult}; pub(crate) mod group; pub(crate) mod macros; pub(crate) mod state; -pub(crate) mod undo; +pub mod undo; pub(crate) mod value; pub(crate) use id::{PeerID, ID}; diff --git a/crates/loro-internal/src/loro.rs b/crates/loro-internal/src/loro.rs index 0fcee38a..2575ab47 100644 --- a/crates/loro-internal/src/loro.rs +++ b/crates/loro-internal/src/loro.rs @@ -1112,10 +1112,18 @@ impl LoroDoc { state.log_estimated_size(); } - /// Get position in a seq container pub fn query_pos(&self, pos: &Cursor) -> Result { + self.query_pos_internal(pos, true) + } + + /// Get position in a seq container + pub(crate) fn query_pos_internal( + &self, + pos: &Cursor, + ret_event_index: bool, + ) -> Result { let mut state = self.state.lock().unwrap(); - if let Some(ans) = state.get_relative_position(pos) { + if let Some(ans) = state.get_relative_position(pos, ret_event_index) { Ok(PosQueryResult { update: None, current: AbsolutePosition { @@ -1215,6 +1223,7 @@ impl LoroDoc { id: None, container: text.id(), side: pos.side, + origin_pos: text.len_unicode(), }), current: AbsolutePosition { pos: text.len_event(), @@ -1229,6 +1238,7 @@ impl LoroDoc { id: None, container: list.id(), side: pos.side, + origin_pos: list.len(), }), current: AbsolutePosition { pos: list.len(), @@ -1243,6 +1253,7 @@ impl LoroDoc { id: None, container: list.id(), side: pos.side, + origin_pos: list.len(), }), current: AbsolutePosition { pos: list.len(), diff --git a/crates/loro-internal/src/state.rs b/crates/loro-internal/src/state.rs index 1095816e..a499b48c 100644 --- a/crates/loro-internal/src/state.rs +++ b/crates/loro-internal/src/state.rs @@ -1181,13 +1181,13 @@ impl DocState { State::UnknownState(Box::new(UnknownState::new(idx))) } - pub fn get_relative_position(&mut self, pos: &Cursor) -> Option { + pub fn get_relative_position(&mut self, pos: &Cursor, use_event_index: bool) -> Option { let idx = self.arena.register_container(&pos.container); let state = self.states.get_mut(&idx)?; if let Some(id) = pos.id { match state { State::ListState(s) => s.get_index_of_id(id), - State::RichtextState(s) => s.get_event_index_of_id(id), + State::RichtextState(s) => s.get_text_index_of_id(id, use_event_index), State::MovableListState(s) => s.get_index_of_id(id), State::MapState(_) | State::TreeState(_) | State::UnknownState(_) => unreachable!(), #[cfg(feature = "counter")] @@ -1200,7 +1200,11 @@ impl DocState { match state { State::ListState(s) => Some(s.len()), - State::RichtextState(s) => Some(s.len_event()), + State::RichtextState(s) => Some(if use_event_index { + s.len_event() + } else { + s.len_unicode() + }), State::MovableListState(s) => Some(s.len()), State::MapState(_) | State::TreeState(_) | State::UnknownState(_) => unreachable!(), #[cfg(feature = "counter")] diff --git a/crates/loro-internal/src/state/richtext_state.rs b/crates/loro-internal/src/state/richtext_state.rs index da3d0cd3..8ae22d43 100644 --- a/crates/loro-internal/src/state/richtext_state.rs +++ b/crates/loro-internal/src/state/richtext_state.rs @@ -143,7 +143,7 @@ impl RichtextState { None } - pub fn get_event_index_of_id(&self, id: ID) -> Option { + pub fn get_text_index_of_id(&self, id: ID, use_event_index: bool) -> Option { let iter: &mut dyn Iterator; let mut a; let mut b; @@ -164,10 +164,14 @@ impl RichtextState { if span.contains(id) { match elem { RichtextStateChunk::Text(t) => { - let event_offset = t.convert_unicode_offset_to_event_offset( - (id.counter - span.counter.start) as usize, - ); - return Some(index + event_offset); + if use_event_index { + let event_offset = t.convert_unicode_offset_to_event_offset( + (id.counter - span.counter.start) as usize, + ); + return Some(index + event_offset); + } else { + return Some(index + (id.counter - span.counter.start) as usize); + } } RichtextStateChunk::Style { .. } => { return Some(index); @@ -176,7 +180,13 @@ impl RichtextState { } index += match elem { - RichtextStateChunk::Text(t) => t.event_len() as usize, + RichtextStateChunk::Text(t) => { + if use_event_index { + t.event_len() as usize + } else { + t.unicode_len() as usize + } + } RichtextStateChunk::Style { .. } => 0, }; } @@ -749,10 +759,19 @@ impl RichtextState { } #[inline] - pub(crate) fn get_stable_position(&mut self, event_index: usize) -> Option { - self.state - .get_mut() - .get_stable_position_at_event_index(event_index, PosType::Event) + pub(crate) fn get_stable_position( + &mut self, + event_index: usize, + get_by_event_index: bool, + ) -> Option { + self.state.get_mut().get_stable_position_at_event_index( + event_index, + if get_by_event_index { + PosType::Event + } else { + PosType::Unicode + }, + ) } pub(crate) fn entity_index_to_event_index(&mut self, entity_index: usize) -> usize { @@ -760,6 +779,12 @@ impl RichtextState { .get_mut() .entity_index_to_event_index(entity_index) } + + pub(crate) fn event_index_to_unicode_index(&mut self, event_index: usize) -> usize { + self.state + .get_mut() + .event_index_to_unicode_index(event_index) + } } #[derive(Debug, Default, Clone)] diff --git a/crates/loro-internal/src/undo.rs b/crates/loro-internal/src/undo.rs index e1920ab8..60b9a331 100644 --- a/crates/loro-internal/src/undo.rs +++ b/crates/loro-internal/src/undo.rs @@ -7,12 +7,13 @@ use either::Either; use fxhash::FxHashMap; use loro_common::{ ContainerID, Counter, CounterSpan, HasCounterSpan, HasIdSpan, IdSpan, LoroError, LoroResult, - PeerID, + LoroValue, PeerID, }; -use tracing::{debug_span, info_span, instrument}; +use tracing::{debug_span, info_span, instrument, trace}; use crate::{ change::get_sys_timestamp, + cursor::{AbsolutePosition, Cursor}, event::{Diff, EventTriggerKind}, version::Frontiers, ContainerDiff, DocDiff, LoroDoc, @@ -47,7 +48,7 @@ impl DiffBatch { } pub fn transform(&mut self, other: &Self, left_priority: bool) { - if other.0.is_empty() { + if other.0.is_empty() || self.0.is_empty() { return; } @@ -63,6 +64,52 @@ impl DiffBatch { } } +fn transform_cursor( + cursor_with_pos: &mut CursorWithPos, + remote_diff: &DiffBatch, + doc: &LoroDoc, + container_remap: &FxHashMap, +) { + let mut cid = &cursor_with_pos.cursor.container; + while let Some(new_cid) = container_remap.get(&cid) { + cid = new_cid; + } + + if let Some(diff) = remote_diff.0.get(cid) { + let new_pos = diff.transform_cursor(cursor_with_pos.pos.pos, false); + cursor_with_pos.pos.pos = new_pos; + }; + + let new_pos = cursor_with_pos.pos.pos; + match doc.get_handler(cid.clone()) { + crate::handler::Handler::Text(h) => { + let Some(new_cursor) = h.get_cursor_internal(new_pos, cursor_with_pos.pos.side, false) + else { + return; + }; + + cursor_with_pos.cursor = new_cursor; + } + crate::handler::Handler::List(h) => { + let Some(new_cursor) = h.get_cursor(new_pos, cursor_with_pos.pos.side) else { + return; + }; + + cursor_with_pos.cursor = new_cursor; + } + crate::handler::Handler::MovableList(h) => { + let Some(new_cursor) = h.get_cursor(new_pos, cursor_with_pos.pos.side) else { + return; + }; + + cursor_with_pos.cursor = new_cursor; + } + crate::handler::Handler::Map(_) => {} + crate::handler::Handler::Tree(_) => {} + crate::handler::Handler::Unknown(_) => {} + } +} + /// UndoManager is responsible for managing undo/redo from the current peer's perspective. /// /// Undo/local is local: it cannot be used to undone the changes made by other peers. @@ -76,7 +123,24 @@ pub struct UndoManager { inner: Arc>, } -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UndoOrRedo { + Undo, + Redo, +} + +impl UndoOrRedo { + fn opposite(&self) -> UndoOrRedo { + match self { + Self::Undo => Self::Redo, + Self::Redo => Self::Undo, + } + } +} + +pub type OnPush = Box UndoItemMeta + Send + Sync>; +pub type OnPop = Box; + struct UndoManagerInner { latest_counter: Counter, undo_stack: Stack, @@ -86,14 +150,80 @@ struct UndoManagerInner { merge_interval: i64, max_stack_size: usize, exclude_origin_prefixes: Vec>, + on_push: Option, + on_pop: Option, +} + +impl std::fmt::Debug for UndoManagerInner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UndoManagerInner") + .field("latest_counter", &self.latest_counter) + .field("undo_stack", &self.undo_stack) + .field("redo_stack", &self.redo_stack) + .field("processing_undo", &self.processing_undo) + .field("last_undo_time", &self.last_undo_time) + .field("merge_interval", &self.merge_interval) + .field("max_stack_size", &self.max_stack_size) + .field("exclude_origin_prefixes", &self.exclude_origin_prefixes) + .finish() + } } #[derive(Debug)] struct Stack { - stack: VecDeque<(VecDeque, Arc>)>, + stack: VecDeque<(VecDeque, Arc>)>, size: usize, } +#[derive(Debug)] +struct StackItem { + span: CounterSpan, + meta: UndoItemMeta, +} + +/// The metadata of an undo item. +/// +/// The cursors inside the metadata will be transformed by remote operations as well. +/// So that when the item is popped, users can restore the cursors position correctly. +#[derive(Debug, Default)] +pub struct UndoItemMeta { + pub value: LoroValue, + pub cursors: Vec, +} + +#[derive(Debug)] +pub struct CursorWithPos { + pub cursor: Cursor, + pub pos: AbsolutePosition, +} + +impl UndoItemMeta { + pub fn new() -> Self { + Self { + value: LoroValue::Null, + cursors: Default::default(), + } + } + + /// It's assumed that the cursor is just acqured before the ops that + /// need to be undo/redo. + /// + /// We need to rely on the validity of the original pos value + pub fn add_cursor(&mut self, cursor: &Cursor) { + self.cursors.push(CursorWithPos { + cursor: cursor.clone(), + pos: AbsolutePosition { + pos: cursor.origin_pos, + side: cursor.side, + }, + }); + } + + pub fn set_value(&mut self, value: LoroValue) { + self.value = value; + } +} + impl Stack { pub fn new() -> Self { let mut stack = VecDeque::new(); @@ -101,7 +231,7 @@ impl Stack { Stack { stack, size: 0 } } - pub fn pop(&mut self) -> Option<(CounterSpan, Arc>)> { + pub fn pop(&mut self) -> Option<(StackItem, Arc>)> { while self.stack.back().unwrap().0.is_empty() && self.stack.len() > 1 { let (_, diff) = self.stack.pop_back().unwrap(); let diff = diff.try_lock().unwrap(); @@ -117,6 +247,7 @@ impl Stack { } if self.stack.len() == 1 && self.stack.back().unwrap().0.is_empty() { + // If the stack is empty, we need to clear the remote diff self.stack.back_mut().unwrap().1.try_lock().unwrap().clear(); return None; } @@ -124,24 +255,27 @@ impl Stack { self.size -= 1; let last = self.stack.back_mut().unwrap(); last.0.pop_back().map(|x| (x, last.1.clone())) + // If this row in stack is empty, we don't pop it right away + // Because we still need the remote diff to be available. + // Cursor position transformation relies on the remote diff in the same row. } - pub fn push(&mut self, span: CounterSpan) { - self.push_with_merge(span, false) + pub fn push(&mut self, span: CounterSpan, meta: UndoItemMeta) { + self.push_with_merge(span, meta, false) } - pub fn push_with_merge(&mut self, span: CounterSpan, can_merge: bool) { + pub fn push_with_merge(&mut self, span: CounterSpan, meta: UndoItemMeta, can_merge: bool) { let last = self.stack.back_mut().unwrap(); let mut last_remote_diff = last.1.try_lock().unwrap(); if !last_remote_diff.0.is_empty() { // If the remote diff is not empty, we cannot merge if last.0.is_empty() { - last.0.push_back(span); + last.0.push_back(StackItem { span, meta }); last_remote_diff.clear(); } else { drop(last_remote_diff); let mut v = VecDeque::new(); - v.push_back(span); + v.push_back(StackItem { span, meta }); self.stack .push_back((v, Arc::new(Mutex::new(DiffBatch::default())))); } @@ -150,16 +284,16 @@ impl Stack { } else { if can_merge { if let Some(last_span) = last.0.back_mut() { - if last_span.end == span.start { + if last_span.span.end == span.start { // merge the span - last_span.end = span.end; + last_span.span.end = span.end; return; } } } self.size += 1; - last.0.push_back(span); + last.0.push_back(StackItem { span, meta }); } } @@ -234,6 +368,8 @@ impl UndoManagerInner { last_undo_time: 0, max_stack_size: usize::MAX, exclude_origin_prefixes: vec![], + on_pop: None, + on_push: None, } } @@ -245,11 +381,16 @@ impl UndoManagerInner { assert!(self.latest_counter < latest_counter); let now = get_sys_timestamp(); let span = CounterSpan::new(self.latest_counter, latest_counter); + let meta = self + .on_push + .as_ref() + .map(|x| x(UndoOrRedo::Undo, span)) + .unwrap_or_default(); if !self.undo_stack.is_empty() && now - self.last_undo_time < self.merge_interval { - self.undo_stack.push_with_merge(span, true); + self.undo_stack.push_with_merge(span, meta, true); } else { self.last_undo_time = now; - self.undo_stack.push(span); + self.undo_stack.push(span, meta); } self.latest_counter = latest_counter; @@ -318,6 +459,10 @@ impl UndoManager { } } + pub fn peer(&self) -> PeerID { + self.peer + } + pub fn set_merge_interval(&mut self, interval: i64) { self.inner.try_lock().unwrap().merge_interval = interval; } @@ -350,12 +495,22 @@ impl UndoManager { #[instrument(skip_all)] pub fn undo(&mut self, doc: &LoroDoc) -> LoroResult { - self.perform(doc, |x| &mut x.undo_stack, |x| &mut x.redo_stack) + self.perform( + doc, + |x| &mut x.undo_stack, + |x| &mut x.redo_stack, + UndoOrRedo::Undo, + ) } #[instrument(skip_all)] pub fn redo(&mut self, doc: &LoroDoc) -> LoroResult { - self.perform(doc, |x| &mut x.redo_stack, |x| &mut x.undo_stack) + self.perform( + doc, + |x| &mut x.redo_stack, + |x| &mut x.undo_stack, + UndoOrRedo::Redo, + ) } fn perform( @@ -363,6 +518,7 @@ impl UndoManager { doc: &LoroDoc, get_stack: impl Fn(&mut UndoManagerInner) -> &mut Stack, get_opposite: impl Fn(&mut UndoManagerInner) -> &mut Stack, + kind: UndoOrRedo, ) -> LoroResult { self.record_new_checkpoint(doc)?; let end_counter = get_counter_end(doc, self.peer); @@ -373,30 +529,51 @@ impl UndoManager { }; let mut executed = false; - while let Some((span, e)) = top { + while let Some((mut span, remote_diff)) = top { { let inner = self.inner.clone(); - // TODO: Perf we can try to avoid this clone - let e = e.try_lock().unwrap().clone(); - doc.undo_internal( + // We need to clone this because otherwise will be applied to the same remote diff + let remote_change_clone = remote_diff.try_lock().unwrap().clone(); + let commit = doc.undo_internal( IdSpan { peer: self.peer, - counter: span, + counter: span.span, }, &mut self.container_remap, - Some(&e), + Some(&remote_change_clone), &mut |diff| { info_span!("transform remote diff").in_scope(|| { let mut inner = inner.try_lock().unwrap(); + // get_stack(&mut inner).transform_based_on_this_delta(diff); }); }, )?; + drop(commit); + if let Some(x) = self.inner.try_lock().unwrap().on_pop.as_ref() { + for cursor in span.meta.cursors.iter_mut() { + // We need to transform cursor here. + // Note that right now is already done, + // remote_diff is also transformed by it now (that's what we need). + transform_cursor( + cursor, + &remote_diff.try_lock().unwrap(), + doc, + &self.container_remap, + ); + } + x(kind, span.span, span.meta) + } } let new_counter = get_counter_end(doc, self.peer); if end_counter != new_counter { let mut inner = self.inner.try_lock().unwrap(); - get_opposite(&mut inner).push(CounterSpan::new(end_counter, new_counter)); + let meta = inner + .on_push + .as_ref() + .map(|x| x(kind.opposite(), CounterSpan::new(end_counter, new_counter))) + .unwrap_or_default(); + get_opposite(&mut inner).push(CounterSpan::new(end_counter, new_counter), meta); inner.latest_counter = new_counter; executed = true; break; @@ -418,6 +595,14 @@ impl UndoManager { pub fn can_redo(&self) -> bool { !self.inner.try_lock().unwrap().redo_stack.is_empty() } + + pub fn set_on_push(&self, on_push: Option) { + self.inner.try_lock().unwrap().on_push = on_push; + } + + pub fn set_on_pop(&self, on_pop: Option) { + self.inner.try_lock().unwrap().on_pop = on_pop; + } } /// Undo the given spans of operations. @@ -471,19 +656,19 @@ pub(crate) fn undo( // --------------------------------------- // 2. Calc event B_i // --------------------------------------- - let mut stack_diff_batch = None; - let event_b_i = debug_span!("2. Calc event B_i").in_scope(|| { + let stack_diff_batch; + let event_b_i = 'block: { let next = if i + 1 < spans.len() { spans[i + 1].0.id_last().into() } else { - match last_frontiers_or_last_bi { + match last_frontiers_or_last_bi.clone() { Either::Left(last_frontiers) => last_frontiers.clone(), - Either::Right(right) => return right, + Either::Right(right) => break 'block right, } }; stack_diff_batch = Some(calc_diff(&this_id_span.id_last().into(), &next)); stack_diff_batch.as_ref().unwrap() - }); + }; // event_a_prime can undo the ops in the current span and the previous spans let mut event_a_prime = if let Some(mut last_ci) = last_ci.take() { diff --git a/crates/loro-wasm/src/convert.rs b/crates/loro-wasm/src/convert.rs index a899b26e..b044ddbb 100644 --- a/crates/loro-wasm/src/convert.rs +++ b/crates/loro-wasm/src/convert.rs @@ -9,7 +9,7 @@ use loro_internal::{ListDiffItem, LoroDoc, LoroValue}; use wasm_bindgen::JsValue; use crate::{ - frontiers_to_ids, Container, JsContainer, JsImportBlobMetadata, LoroList, LoroMap, + frontiers_to_ids, Container, Cursor, JsContainer, JsImportBlobMetadata, LoroList, LoroMap, LoroMovableList, LoroText, LoroTree, }; use wasm_bindgen::__rt::IntoJsResult; @@ -198,6 +198,38 @@ fn delta_item_to_js(item: ListDiffItem, doc: &Arc) -> (JsValue, Option< } } +pub(crate) fn js_to_cursor(js: JsValue) -> Result { + if !js.is_object() { + return Err(JsValue::from_str(&format!( + "Value supplied is not an object, but {:?}", + js + ))); + } + + let kind_method = Reflect::get(&js, &JsValue::from_str("kind")); + let kind = match kind_method { + Ok(kind_method) if kind_method.is_function() => { + let kind_string = js_sys::Function::from(kind_method).call0(&js); + match kind_string { + Ok(kind_string) if kind_string.is_string() => kind_string.as_string().unwrap(), + _ => return Err(JsValue::from_str("kind() did not return a string")), + } + } + _ => return Err(JsValue::from_str("No kind method found or not a function")), + }; + + if kind.as_str() != "Cursor" { + return Err(JsValue::from_str("Value is not a Cursor")); + } + + let Ok(ptr) = Reflect::get(&js, &JsValue::from_str("__wbg_ptr")) else { + return Err(JsValue::from_str("Cannot find pointer field")); + }; + let ptr_u32: u32 = ptr.as_f64().unwrap() as u32; + let cursor = unsafe { Cursor::ref_from_abi(ptr_u32) }; + Ok(cursor.clone()) +} + pub fn convert(value: LoroValue) -> JsValue { match value { LoroValue::Null => JsValue::NULL, diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index a0d919bb..9b628daa 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -18,6 +18,7 @@ use loro_internal::{ id::{Counter, TreeID, ID}, loro::CommitOptions, obs::SubID, + undo::{UndoItemMeta, UndoOrRedo}, version::Frontiers, ContainerType, DiffEvent, HandlerTrait, LoroDoc, LoroValue, MovableListHandler, UndoManager as InnerUndoManager, VersionVector as InternalVersionVector, @@ -25,13 +26,13 @@ use loro_internal::{ use rle::HasLength; use serde::{Deserialize, Serialize}; use std::{cell::RefCell, cmp::Ordering, rc::Rc, sync::Arc}; -use wasm_bindgen::{__rt::IntoJsResult, prelude::*}; +use wasm_bindgen::{__rt::IntoJsResult, prelude::*, throw_val}; use wasm_bindgen_derive::TryFromJsValue; mod awareness; mod log; -use crate::convert::{handler_to_js_value, js_to_container}; +use crate::convert::{handler_to_js_value, js_to_container, js_to_cursor}; pub use awareness::AwarenessWasm; mod convert; @@ -156,9 +157,7 @@ extern "C" { pub type JsSide; #[wasm_bindgen(typescript_type = "{ update?: Cursor, offset: number, side: Side }")] pub type JsCursorQueryAns; - #[wasm_bindgen( - typescript_type = "{ mergeInterval?: number, maxUndoSteps?: number, excludeOriginPrefixes?: string[] } | undefined" - )] + #[wasm_bindgen(typescript_type = "UndoConfig")] pub type JsUndoConfig; } @@ -189,7 +188,23 @@ mod observer { if std::thread::current().id() == self.thread { self.f.call1(&JsValue::NULL, arg) } else { - panic!("Observer called from different thread") + Err(JsValue::from_str("Observer called from different thread")) + } + } + + pub fn call2(&self, arg1: &JsValue, arg2: &JsValue) -> JsResult { + if std::thread::current().id() == self.thread { + self.f.call2(&JsValue::NULL, arg1, arg2) + } else { + Err(JsValue::from_str("Observer called from different thread")) + } + } + + pub fn call3(&self, arg1: &JsValue, arg2: &JsValue, arg3: &JsValue) -> JsResult { + if std::thread::current().id() == self.thread { + self.f.call3(&JsValue::NULL, arg1, arg2, arg3) + } else { + Err(JsValue::from_str("Observer called from different thread")) } } } @@ -1241,7 +1256,9 @@ fn call_subscriber(ob: observer::Observer, e: DiffEvent, doc: &Arc) { // [1]: https://caniuse.com/?search=FinalizationRegistry // [2]: https://rustwasm.github.io/wasm-bindgen/reference/weak-references.html let event = diff_event_to_js_value(e, doc); - ob.call1(&event).unwrap_throw(); + if let Err(e) = ob.call1(&event) { + throw_error_after_micro_task(e); + } } #[allow(unused)] @@ -1255,7 +1272,7 @@ fn call_after_micro_task(ob: observer::Observer, event: DiffEvent, doc: &Arc JsValue { + JsValue::from_str("Cursor") + } } fn loro_value_to_js_value_or_container( @@ -3303,8 +3325,26 @@ pub struct UndoManager { #[wasm_bindgen] impl UndoManager { - /// Create a new undo manager. It will bind on the current PeerID. + /// `UndoManager` is responsible for handling undo and redo operations. + /// /// PeerID cannot be changed during the lifetime of the UndoManager. + /// + /// Note that undo operations are local and cannot revert changes made by other peers. + /// To undo changes made by other peers, consider using the time travel feature. + /// + /// Each commit made by the current peer is recorded as an undo step in the `UndoManager`. + /// Undo steps can be merged if they occur within a specified merge interval. + /// + /// ## Config + /// + /// - `mergeInterval`: Optional. The interval in milliseconds within which undo steps can be merged. Default is 1000 ms. + /// - `maxUndoSteps`: Optional. The maximum number of undo steps to retain. Default is 100. + /// - `excludeOriginPrefixes`: Optional. An array of string prefixes. Events with origins matching these prefixes will be excluded from undo steps. + /// - `onPush`: Optional. A callback function that is called when an undo/redo step is pushed. + /// The function can return a meta data value that will be attached to the given stack item. + /// - `onPop`: Optional. A callback function that is called when an undo/redo step is popped. + /// The function will have a meta data value that was attached to the given stack item when + /// `onPush` was called. #[wasm_bindgen(constructor)] pub fn new(doc: &Loro, config: JsUndoConfig) -> Self { let max_undo_steps = Reflect::get(&config, &JsValue::from_str("maxUndoSteps")) @@ -3325,16 +3365,28 @@ impl UndoManager { .collect::>() }) .unwrap_or_default(); + let on_push = Reflect::get(&config, &JsValue::from_str("onPush")).ok(); + let on_pop = Reflect::get(&config, &JsValue::from_str("onPop")).ok(); + let mut undo = InnerUndoManager::new(&doc.0); undo.set_max_undo_steps(max_undo_steps); undo.set_merge_interval(merge_interval); for prefix in exclude_origin_prefixes { undo.add_exclude_origin_prefix(&prefix); } - UndoManager { + + let mut ans = UndoManager { undo, doc: doc.0.clone(), + }; + + if let Some(on_push) = on_push { + ans.setOnPush(on_push); } + if let Some(on_pop) = on_pop { + ans.setOnPop(on_pop); + } + ans } /// Undo the last operation. @@ -3382,6 +3434,125 @@ impl UndoManager { pub fn checkBinding(&self, doc: &Loro) -> bool { Arc::ptr_eq(&self.doc, &doc.0) } + + /// Set the on push event listener. + /// + /// Every time an undo step or redo step is pushed, the on push event listener will be called. + #[wasm_bindgen(skip_typescript)] + pub fn setOnPush(&mut self, on_push: JsValue) { + let on_push = on_push.dyn_into::().ok(); + if let Some(on_push) = on_push { + let on_push = observer::Observer::new(on_push); + self.undo.set_on_push(Some(Box::new(move |kind, span| { + let is_undo = JsValue::from_bool(matches!(kind, UndoOrRedo::Undo)); + let counter_range = js_sys::Object::new(); + js_sys::Reflect::set( + &counter_range, + &JsValue::from_str("start"), + &JsValue::from_f64(span.start as f64), + ) + .unwrap(); + js_sys::Reflect::set( + &counter_range, + &JsValue::from_str("end"), + &JsValue::from_f64(span.end as f64), + ) + .unwrap(); + + let mut undo_item_meta = UndoItemMeta::new(); + match on_push.call2(&is_undo, &counter_range) { + Ok(v) => { + if let Ok(obj) = v.dyn_into::() { + if let Ok(value) = + js_sys::Reflect::get(&obj, &JsValue::from_str("value")) + { + let value: LoroValue = value.into(); + undo_item_meta.value = value; + } + if let Ok(cursors) = + js_sys::Reflect::get(&obj, &JsValue::from_str("cursors")) + { + let cursors: js_sys::Array = cursors.into(); + for cursor in cursors.iter() { + let cursor = js_to_cursor(cursor).unwrap_throw(); + undo_item_meta.add_cursor(&cursor.pos); + } + } + } + } + Err(e) => { + throw_error_after_micro_task(e); + } + } + + undo_item_meta + }))); + } else { + self.undo.set_on_push(None); + } + } + + /// Set the on pop event listener. + /// + /// Every time an undo step or redo step is popped, the on pop event listener will be called. + #[wasm_bindgen(skip_typescript)] + pub fn setOnPop(&mut self, on_pop: JsValue) { + let on_pop = on_pop.dyn_into::().ok(); + if let Some(on_pop) = on_pop { + let on_pop = observer::Observer::new(on_pop); + self.undo + .set_on_pop(Some(Box::new(move |kind, span, value| { + let is_undo = JsValue::from_bool(matches!(kind, UndoOrRedo::Undo)); + let meta = js_sys::Object::new(); + js_sys::Reflect::set(&meta, &JsValue::from_str("value"), &value.value.into()) + .unwrap(); + let cursors_array = js_sys::Array::new(); + for cursor in value.cursors { + let c = Cursor { pos: cursor.cursor }; + cursors_array.push(&c.into()); + } + js_sys::Reflect::set(&meta, &JsValue::from_str("cursors"), &cursors_array) + .unwrap(); + let counter_range = js_sys::Object::new(); + js_sys::Reflect::set( + &counter_range, + &JsValue::from_str("start"), + &JsValue::from_f64(span.start as f64), + ) + .unwrap(); + js_sys::Reflect::set( + &counter_range, + &JsValue::from_str("end"), + &JsValue::from_f64(span.end as f64), + ) + .unwrap(); + match on_pop.call3(&is_undo, &meta.into(), &counter_range) { + Ok(_) => {} + Err(e) => { + throw_error_after_micro_task(e); + } + } + }))); + } else { + self.undo.set_on_pop(None); + } + } +} + +/// Use this function to throw an error after the micro task. +/// +/// We should avoid panic or use js_throw directly inside a event listener as it might +/// break the internal invariants. +fn throw_error_after_micro_task(error: JsValue) { + let drop_handler = Rc::new(RefCell::new(None)); + let drop_handler_clone = drop_handler.clone(); + let closure = Closure::once(Box::new(move |_| { + drop(drop_handler_clone); + throw_val(error); + })); + let promise = Promise::resolve(&JsValue::NULL); + let _ = promise.then(&closure); + drop_handler.borrow_mut().replace(closure); } /// [VersionVector](https://en.wikipedia.org/wiki/Version_vector) @@ -3686,6 +3857,13 @@ export type Value = | Uint8Array | Value[]; +export type UndoConfig = { + mergeInterval?: number, + maxUndoSteps?: number, + excludeOriginPrefixes?: string[], + onPush?: (isUndo: boolean, counterRange: { start: number, end: number }) => { value: Value, cursors: Cursor[] }, + onPop?: (isUndo: boolean, value: { value: Value, cursors: Cursor[] }, counterRange: { start: number, end: number }) => void +}; export type Container = LoroList | LoroMap | LoroText | LoroTree | LoroMovableList; export interface ImportBlobMetadata { diff --git a/crates/loro/src/lib.rs b/crates/loro/src/lib.rs index b15501ba..988bbc04 100644 --- a/crates/loro/src/lib.rs +++ b/crates/loro/src/lib.rs @@ -12,6 +12,7 @@ use loro_internal::encoding::ImportBlobMetadata; use loro_internal::handler::HandlerTrait; use loro_internal::handler::ValueOrHandler; use loro_internal::loro::CommitOptions; +use loro_internal::undo::{OnPop, OnPush}; use loro_internal::LoroDoc as InnerLoroDoc; use loro_internal::OpLog; @@ -28,18 +29,19 @@ use std::sync::Arc; use tracing::info; pub mod event; - pub use loro_internal::awareness; pub use loro_internal::configure::Configure; pub use loro_internal::configure::StyleConfigMap; pub use loro_internal::container::richtext::ExpandType; pub use loro_internal::container::{ContainerID, ContainerType}; +pub use loro_internal::cursor; pub use loro_internal::delta::{TreeDeltaItem, TreeDiff, TreeExternalDiff}; pub use loro_internal::event::Index; pub use loro_internal::handler::TextDelta; pub use loro_internal::id::{PeerID, TreeID, ID}; pub use loro_internal::obs::SubID; pub use loro_internal::oplog::FrontiersNotIncluded; +pub use loro_internal::undo; pub use loro_internal::version::{Frontiers, VersionVector}; pub use loro_internal::UndoManager as InnerUndoManager; pub use loro_internal::{loro_value, to_value}; @@ -468,6 +470,11 @@ impl LoroDoc { ) -> Result { self.doc.query_pos(cursor) } + + /// Get the inner LoroDoc ref. + pub fn inner(&self) -> &InnerLoroDoc { + &self.doc + } } /// It's used to prevent the user from implementing the trait directly. @@ -1861,4 +1868,16 @@ impl UndoManager { pub fn set_merge_interval(&mut self, interval: i64) { self.0.set_merge_interval(interval) } + + /// Set the listener for push events. + /// The listener will be called when a new undo/redo item is pushed into the stack. + pub fn set_on_push(&mut self, on_push: Option) { + self.0.set_on_push(on_push) + } + + /// Set the listener for pop events. + /// The listener will be called when an undo/redo item is popped from the stack. + pub fn set_on_pop(&mut self, on_pop: Option) { + self.0.set_on_pop(on_pop) + } } diff --git a/crates/loro/tests/integration_test/undo_test.rs b/crates/loro/tests/integration_test/undo_test.rs index c321d245..8d1e47dd 100644 --- a/crates/loro/tests/integration_test/undo_test.rs +++ b/crates/loro/tests/integration_test/undo_test.rs @@ -1,8 +1,11 @@ -use std::sync::Arc; +use std::sync::{ + atomic::{self, AtomicUsize}, + Arc, Mutex, +}; use loro::{ - LoroDoc, LoroError, LoroList, LoroMap, LoroResult, LoroText, LoroValue, StyleConfigMap, ToJson, - UndoManager, + undo::UndoItemMeta, LoroDoc, LoroError, LoroList, LoroMap, LoroResult, LoroText, LoroValue, + StyleConfigMap, ToJson, UndoManager, }; use loro_internal::{configure::StyleConfig, id::ID, loro::CommitOptions}; use serde_json::json; @@ -1518,3 +1521,118 @@ fn should_not_trigger_update_when_undo_ops_depend_on_deleted_container() -> anyh assert_eq!(f, doc_b.oplog_frontiers()); Ok(()) } + +#[test] +fn undo_manager_events() -> anyhow::Result<()> { + let doc = LoroDoc::new(); + let text = doc.get_text("text"); + let mut undo = UndoManager::new(&doc); + let push_count = Arc::new(AtomicUsize::new(0)); + let push_count_clone = push_count.clone(); + let pop_count = Arc::new(AtomicUsize::new(0)); + let pop_count_clone = pop_count.clone(); + let popped_value = Arc::new(Mutex::new(LoroValue::Null)); + let popped_value_clone = popped_value.clone(); + undo.set_on_push(Some(Box::new(move |_source, span| { + push_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + UndoItemMeta { + value: LoroValue::I64(span.start as i64), + cursors: Default::default(), + } + }))); + undo.set_on_pop(Some(Box::new(move |_source, _span, v| { + pop_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + *popped_value_clone.lock().unwrap() = v.value; + }))); + text.insert(0, "Hello")?; + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 0); + doc.commit(); + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 1); + text.insert(0, "A")?; + text.insert(1, "B")?; + assert_eq!(pop_count.load(atomic::Ordering::SeqCst), 0); + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 1); + doc.commit(); + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 2); + + undo.undo(&doc)?; + assert_eq!(&*popped_value.lock().unwrap(), &LoroValue::I64(5)); + assert_eq!(pop_count.load(atomic::Ordering::SeqCst), 1); + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 3); + undo.undo(&doc)?; + assert_eq!(&*popped_value.lock().unwrap(), &LoroValue::I64(0)); + assert_eq!(pop_count.load(atomic::Ordering::SeqCst), 2); + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 4); + undo.redo(&doc)?; + assert_eq!(pop_count.load(atomic::Ordering::SeqCst), 3); + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 5); + undo.redo(&doc)?; + assert_eq!(pop_count.load(atomic::Ordering::SeqCst), 4); + assert_eq!(push_count.load(atomic::Ordering::SeqCst), 6); + Ok(()) +} + +#[test] +fn undo_transform_cursor_position() -> anyhow::Result<()> { + use loro::cursor::Cursor; + let doc = Arc::new(LoroDoc::new()); + let text = doc.get_text("text"); + let mut undo = UndoManager::new(&doc); + let cursors: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cursors_clone = cursors.clone(); + undo.set_on_push(Some(Box::new(move |_, _| { + let mut ans = UndoItemMeta::new(); + let cursors = cursors_clone.lock().unwrap(); + for c in cursors.iter() { + ans.add_cursor(c) + } + ans + }))); + let popped_cursors = Arc::new(Mutex::new(Vec::new())); + let popped_cursors_clone = popped_cursors.clone(); + undo.set_on_pop(Some(Box::new(move |_, _, meta| { + *popped_cursors_clone.lock().unwrap() = meta.cursors; + }))); + text.insert(0, "Hello world!")?; + doc.commit(); + cursors + .lock() + .unwrap() + .push(text.get_cursor(1, loro::cursor::Side::Left).unwrap()); + cursors + .lock() + .unwrap() + .push(text.get_cursor(4, loro::cursor::Side::Right).unwrap()); + text.delete(1, 4)?; + doc.commit(); + { + let doc_b = LoroDoc::new(); + doc_b.import(&doc.export_snapshot())?; + doc_b.get_text("text").insert(0, "Hi ")?; + doc_b.get_text("text").insert(4, "ii")?; + doc.import(&doc_b.export_snapshot())?; + assert_eq!(text.to_string(), "Hi Hii world!"); + } + assert_eq!(popped_cursors.lock().unwrap().len(), 0); + undo.undo(&doc)?; + + // Undo will create new "Hello". They have different IDs than the original ones. + // But the original cursors are binded on the original deleted text. + // So internally, the cursor positions are transformed. + + // The transformation should also consider the effect of the remote changes. + assert_eq!(text.to_string(), "Hi Helloii world!"); + { + let cursors = popped_cursors.lock().unwrap(); + assert_eq!(cursors.len(), 2); + assert_eq!(cursors[0].pos.pos, 4); + assert_eq!(cursors[1].pos.pos, 7); + let q = doc.get_cursor_pos(&cursors[0].cursor)?; + dbg!(&cursors); + assert_eq!(q.current.pos, 4); + let q = doc.get_cursor_pos(&cursors[1].cursor)?; + assert_eq!(q.current.pos, 7); + } + + Ok(()) +} diff --git a/loro-js/src/index.ts b/loro-js/src/index.ts index e44186a9..cf1d4c6c 100644 --- a/loro-js/src/index.ts +++ b/loro-js/src/index.ts @@ -186,6 +186,23 @@ declare module "loro-wasm" { subscribe(listener: Listener): number; } + interface UndoManager { + /** + * Set the callback function that is called when an undo/redo step is pushed. + * The function can return a meta data value that will be attached to the given stack item. + * + * @param listener - The callback function. + */ + setOnPush(listener?: UndoConfig["onPush"]): void; + /** + * Set the callback function that is called when an undo/redo step is popped. + * The function will have a meta data value that was attached to the given stack item when `onPush` was called. + * + * @param listener - The callback function. + */ + setOnPop(listener?: UndoConfig["onPop"]): void; + } + interface Loro< T extends Record = Record, > { diff --git a/loro-js/tests/undo.test.ts b/loro-js/tests/undo.test.ts index fdabb609..c2cad12e 100644 --- a/loro-js/tests/undo.test.ts +++ b/loro-js/tests/undo.test.ts @@ -1,4 +1,4 @@ -import { Loro, UndoManager } from "../src"; +import { Cursor, Loro, UndoManager } from "../src"; import { describe, expect, test } from "vitest"; describe("undo", () => { @@ -149,4 +149,84 @@ describe("undo", () => { await new Promise((r) => setTimeout(r, 10)); expect(ran).toBeTruthy(); }); + + test("undo event listener", async () => { + const doc = new Loro(); + let pushReturn: null | number = null; + let expectedValue: null | number = null; + + let pushTimes = 0; + let popTimes = 0; + const undo = new UndoManager(doc, { + mergeInterval: 0, + onPop: (isUndo, value, counterRange) => { + expect(value.value).toBe(expectedValue); + expect(value.cursors).toStrictEqual([]); + popTimes += 1; + }, + onPush: (isUndo, counterRange) => { + pushTimes += 1; + return { value: pushReturn, cursors: [] }; + }, + }); + + doc.getText("text").insert(0, "hello"); + pushReturn = 1; + doc.commit(); + doc.getText("text").insert(5, " world"); + pushReturn = 2; + doc.commit(); + doc.getText("text").insert(0, "alice "); + pushReturn = 3; + doc.commit(); + expect(pushTimes).toBe(3); + expect(popTimes).toBe(0); + + expectedValue = 3; + undo.undo(); + expect(pushTimes).toBe(4); + expect(popTimes).toBe(1); + + expectedValue = 2; + undo.undo(); + expect(pushTimes).toBe(5); + expect(popTimes).toBe(2); + + expectedValue = 1; + undo.undo(); + expect(pushTimes).toBe(6); + expect(popTimes).toBe(3); + }); + + test("undo cursor transform", async () => { + const doc = new Loro(); + let cursors: Cursor[] = []; + let poppedCursors: Cursor[] = []; + const undo = new UndoManager(doc , { + mergeInterval: 0, + onPop: (isUndo, value, counterRange) => { + poppedCursors = value.cursors + }, + onPush: () => { + return { value: null, cursors: cursors }; + } + }); + + doc.getText("text").insert(0, "hello world"); + doc.commit(); + cursors = [ + doc.getText("text").getCursor(0)!, + doc.getText("text").getCursor(5)!, + ]; + doc.getText("text").delete(0, 6); + doc.commit(); + expect(poppedCursors.length).toBe(0); + undo.undo(); + expect(poppedCursors.length).toBe(2); + expect(doc.toJSON()).toStrictEqual({ + text: "hello world", + }); + expect(doc.getCursorPos(poppedCursors[0]).offset).toBe(0); + expect(doc.getCursorPos(poppedCursors[1]).offset).toBe(5); + }); });