mirror of
https://github.com/loro-dev/loro.git
synced 2024-11-28 09:25:36 +00:00
feat: Add event listener and native support of cursor transformation for undo/redo (#369)
This commit is contained in:
parent
6d5083cfc9
commit
6700dad19b
16 changed files with 867 additions and 91 deletions
|
@ -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> {
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>> {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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>,
|
||||
> {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue