feat: Add event listener and native support of cursor transformation for undo/redo (#369)

This commit is contained in:
Zixuan Chen 2024-05-23 10:19:08 +08:00 committed by GitHub
parent 6d5083cfc9
commit 6700dad19b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 867 additions and 91 deletions

View file

@ -291,6 +291,55 @@ impl<V: DeltaValue, Attr: DeltaAttr> DeltaRope<V, Attr> {
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<V: DeltaValue + PartialEq, Attr: DeltaAttr + PartialEq> PartialEq for DeltaRope<V, Attr> {

View file

@ -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<usize>, style: Arc<StyleOp>) {
@ -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::<EventIndexQuery>(&index) else {
return 0;
};
self.cursor_to_unicode_index(cursor.cursor)
}
#[allow(unused)]
pub(crate) fn check(&self) {
if !cfg!(any(debug_assertions, test)) {

View file

@ -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<ID>, container: ContainerID, side: Side) -> Self {
pub fn new(id: Option<ID>, container: ContainerID, side: Side, origin_pos: usize) -> Self {
Self {
id,
container,
side,
origin_pos,
}
}

View file

@ -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<Vec<Index>> {

View file

@ -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<Cursor> {
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<Cursor> {
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,
})
}
}

View file

@ -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};

View file

@ -1112,10 +1112,18 @@ impl LoroDoc {
state.log_estimated_size();
}
/// Get position in a seq container
pub fn query_pos(&self, pos: &Cursor) -> Result<PosQueryResult, CannotFindRelativePosition> {
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<PosQueryResult, CannotFindRelativePosition> {
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(),

View file

@ -1181,13 +1181,13 @@ impl DocState {
State::UnknownState(Box::new(UnknownState::new(idx)))
}
pub fn get_relative_position(&mut self, pos: &Cursor) -> Option<usize> {
pub fn get_relative_position(&mut self, pos: &Cursor, use_event_index: bool) -> Option<usize> {
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")]

View file

@ -143,7 +143,7 @@ impl RichtextState {
None
}
pub fn get_event_index_of_id(&self, id: ID) -> Option<usize> {
pub fn get_text_index_of_id(&self, id: ID, use_event_index: bool) -> Option<usize> {
let iter: &mut dyn Iterator<Item = &RichtextStateChunk>;
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<ID> {
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<ID> {
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)]

View file

@ -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<ContainerID, ContainerID>,
) {
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<Mutex<UndoManagerInner>>,
}
#[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<dyn Fn(UndoOrRedo, CounterSpan) -> UndoItemMeta + Send + Sync>;
pub type OnPop = Box<dyn Fn(UndoOrRedo, CounterSpan, UndoItemMeta) + Send + Sync>;
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<Box<str>>,
on_push: Option<OnPush>,
on_pop: Option<OnPop>,
}
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<CounterSpan>, Arc<Mutex<DiffBatch>>)>,
stack: VecDeque<(VecDeque<StackItem>, Arc<Mutex<DiffBatch>>)>,
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<CursorWithPos>,
}
#[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<Mutex<DiffBatch>>)> {
pub fn pop(&mut self) -> Option<(StackItem, Arc<Mutex<DiffBatch>>)> {
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<bool> {
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<bool> {
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<bool> {
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 <transform_delta> 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();
// <transform_delta>
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() {
// <cursor_transform> We need to transform cursor here.
// Note that right now <transform_delta> 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<OnPush>) {
self.inner.try_lock().unwrap().on_push = on_push;
}
pub fn set_on_pop(&self, on_pop: Option<OnPop>) {
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() {

View file

@ -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<LoroDoc>) -> (JsValue, Option<
}
}
pub(crate) fn js_to_cursor(js: JsValue) -> Result<Cursor, JsValue> {
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,

View file

@ -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<JsValue> {
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<JsValue> {
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<LoroDoc>) {
// [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<Lor
let ans = ob.call1(&event);
drop(copy);
if let Err(e) = ans {
console_error!("Error when calling observer: {:#?}", e);
throw_error_after_micro_task(e)
}
});
@ -3264,6 +3281,11 @@ impl Cursor {
let pos = cursor::Cursor::decode(data).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(Cursor { pos })
}
/// "Cursor"
pub fn kind(&self) -> 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::<Vec<String>>()
})
.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::<js_sys::Function>().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::<js_sys::Object>() {
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::<js_sys::Function>().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 {

View file

@ -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<PosQueryResult, CannotFindRelativePosition> {
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<OnPush>) {
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<OnPop>) {
self.0.set_on_pop(on_pop)
}
}

View file

@ -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<Mutex<Vec<Cursor>>> = 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(())
}

View file

@ -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<string, Container> = Record<string, Container>,
> {

View file

@ -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);
});
});