refactor: use rich text style config (#246)

* refactor: use rich text style config

* chore: rm log

* feat: support config text style in wasm

* feat: overlapped styles

* chore: add warning style key cannot contain ':'

* test: refine test case for richtext

* test: refine test
This commit is contained in:
Zixuan Chen 2024-01-17 22:55:46 +08:00 committed by GitHub
parent 692c5e3436
commit b4701a4de6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 650 additions and 572 deletions

View file

@ -1,6 +1,6 @@
use std::time::Instant;
use bench_utils::{json, SyncKind};
use bench_utils::SyncKind;
use examples::{draw::DrawActor, run_async_workflow, run_realtime_collab_workflow};
use loro::{LoroDoc, ToJson};
use tabled::{settings::Style, Table, Tabled};
@ -25,24 +25,24 @@ struct BenchResult {
pub fn main() {
let seed = 123123;
let ans = vec![
// run_async(1, 100, seed),
// run_async(1, 1000, seed),
// run_async(1, 5000, seed),
// run_async(1, 10000, seed),
// run_async(5, 100, seed),
// run_async(5, 1000, seed),
// run_async(5, 10000, seed),
// run_async(10, 1000, seed),
// run_async(10, 10000, seed),
// run_async(10, 100000, seed),
// run_async(10, 100000, 1000),
// run_realtime_collab(5, 100, seed),
// run_realtime_collab(5, 1000, seed),
// run_realtime_collab(5, 10000, seed),
// run_realtime_collab(10, 1000, seed),
run_async(1, 100, seed),
run_async(1, 1000, seed),
run_async(1, 5000, seed),
run_async(1, 10000, seed),
run_async(5, 100, seed),
run_async(5, 1000, seed),
run_async(5, 10000, seed),
run_async(10, 1000, seed),
run_async(10, 10000, seed),
run_async(10, 100000, seed),
run_async(10, 100000, 1000),
run_realtime_collab(5, 100, seed),
run_realtime_collab(5, 1000, seed),
run_realtime_collab(5, 10000, seed),
run_realtime_collab(10, 1000, seed),
run_realtime_collab(10, 10000, seed),
// run_realtime_collab(10, 100000, seed),
// run_realtime_collab(10, 100000, 1000),
run_realtime_collab(10, 100000, seed),
run_realtime_collab(10, 100000, 1000),
];
let mut table = Table::new(ans);
let style = Style::markdown();

View file

@ -1,7 +1,7 @@
use serde_columnar::ColumnarError;
use thiserror::Error;
use crate::{PeerID, TreeID, ID};
use crate::{InternalString, PeerID, TreeID, ID};
pub type LoroResult<T> = Result<T, LoroError>;
@ -23,7 +23,7 @@ pub enum LoroError {
LockError,
#[error("Each AppState can only have one transaction at a time")]
DuplicatedTransactionError,
#[error("Cannot find ({0}) ")]
#[error("Cannot find ({0})")]
NotFoundError(Box<str>),
// TODO: more details transaction error
#[error("Transaction error ({0})")]
@ -38,6 +38,8 @@ pub enum LoroError {
ArgErr(Box<str>),
#[error("Auto commit has not started. The doc is readonly when detached. You should ensure autocommit is on and the doc and the state is attached.")]
AutoCommitNotStarted,
#[error("You need to specify the style flag for \"({0:?})\" before mark with this key")]
StyleConfigMissing(InternalString),
#[error("Unknown Error ({0})")]
Unknown(Box<str>),
}

View file

@ -1,18 +1,15 @@
use std::{fmt::Debug, sync::Arc};
use crate::Timestamp;
pub use crate::container::richtext::config::{StyleConfig, StyleConfigMap};
#[derive(Clone)]
pub struct Configure {
pub get_time: fn() -> Timestamp,
pub rand: Arc<dyn SecureRandomGenerator>,
pub text_style_config: Arc<RwLock<StyleConfigMap>>,
}
impl Debug for Configure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Configure")
.field("get_time", &self.get_time)
.finish()
impl Default for Configure {
fn default() -> Self {
Self {
text_style_config: Arc::new(RwLock::new(StyleConfigMap::default_rich_text_config())),
}
}
}
@ -20,6 +17,7 @@ pub struct DefaultRandom;
#[cfg(test)]
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};
#[cfg(test)]
static mut TEST_RANDOM: AtomicU64 = AtomicU64::new(0);
@ -63,12 +61,3 @@ pub trait SecureRandomGenerator: Send + Sync {
i32::from_le_bytes(buf)
}
}
impl Default for Configure {
fn default() -> Self {
Self {
get_time: || 0,
rand: Arc::new(DefaultRandom),
}
}
}

View file

@ -10,6 +10,7 @@
//!
//! The users of this type can only operate on unicode index or utf16 index, but calculated entity index will be provided.
pub(crate) mod config;
mod fugue_span;
mod query_by_len;
pub(crate) mod richtext_state;
@ -39,10 +40,6 @@ pub struct RichtextSpan {
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Style {
pub key: InternalString,
/// The value of the style.
///
/// - If the style is a container, this is the Container
/// - Otherwise, this is true
pub data: LoroValue,
}
@ -60,52 +57,27 @@ pub struct StyleOp {
#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)]
pub(crate) enum StyleKey {
Key(InternalString),
KeyWithId { key: InternalString, id: ID },
}
impl StyleKey {
pub fn to_attr_key(&self) -> String {
match self {
Self::Key(key) => key.to_string(),
Self::KeyWithId { key: _, id } => format!("id:{}", id),
}
}
pub fn key(&self) -> &InternalString {
match self {
Self::Key(key) => key,
Self::KeyWithId { key, .. } => key,
}
}
pub fn contains_id(&self) -> bool {
matches!(self, Self::KeyWithId { .. })
}
}
impl StyleOp {
pub fn to_style(&self) -> Style {
if self.info.is_delete() {
return Style {
key: self.key.clone(),
data: LoroValue::Bool(false),
};
}
if self.info.is_container() {
Style {
key: self.key.clone(),
data: LoroValue::Container(loro_common::ContainerID::Normal {
peer: self.peer,
counter: self.cnt,
container_type: loro_common::ContainerType::Map,
}),
}
} else {
Style {
key: self.key.clone(),
data: LoroValue::Bool(true),
}
Style {
key: self.key.clone(),
data: self.value.clone(),
}
}
@ -114,14 +86,7 @@ impl StyleOp {
}
pub(crate) fn get_style_key(&self) -> StyleKey {
if !self.info.mergeable() {
StyleKey::KeyWithId {
key: self.key.clone(),
id: self.id(),
}
} else {
StyleKey::Key(self.key.clone())
}
StyleKey::Key(self.key.clone())
}
#[cfg(test)]
@ -160,11 +125,11 @@ impl Ord for StyleOp {
///
/// Note: we assume style with the same key has the same `Mergeable` and `isContainer` value.
///
/// - Mergeable (1st bit): whether two styles with the same key can be merged into one.
/// - 0 (1st bit)
/// - Expand Before (2nd bit): when inserting new text before this style, whether the new text should inherit this style.
/// - Expand After (3rd bit): when inserting new text after this style, whether the new text should inherit this style.
/// - Delete (4th bit): whether this is used to remove a style from a range.
/// - isContainer (5th bit): whether the style also store other data in a associated map container with the same OpID.
/// - 0 (5th bit): whether the style also store other data in a associated map container with the same OpID.
/// - 0 (6th bit)
/// - 0 (7th bit)
/// - isAlive (8th bit): always 1 unless the style is garbage collected. If this is 0, all other bits should be 0 as well.
@ -178,20 +143,15 @@ impl Debug for TextStyleInfoFlag {
f.debug_struct("TextStyleInfo")
// write data in binary format
.field("data", &format!("{:#010b}", self.data))
.field("mergeable", &self.mergeable())
.field("expand_before", &self.expand_before())
.field("expand_after", &self.expand_after())
.field("is_delete", &self.is_delete())
.field("is_container", &self.is_container())
.finish()
}
}
const MERGEABLE_MASK: u8 = 0b0000_0001;
const EXPAND_BEFORE_MASK: u8 = 0b0000_0010;
const EXPAND_AFTER_MASK: u8 = 0b0000_0100;
const DELETE_MASK: u8 = 0b0000_1000;
const CONTAINER_MASK: u8 = 0b0001_0000;
const ALIVE_MASK: u8 = 0b1000_0000;
/// Whether to expand the style when inserting new text around it.
@ -240,7 +200,7 @@ impl ExpandType {
/// Create reversed expand type.
///
/// Beofre -> After
/// Before -> After
/// After -> Before
/// Both -> None
/// None -> Both
@ -258,13 +218,6 @@ impl ExpandType {
}
impl TextStyleInfoFlag {
/// Whether two styles with the same key can be merged into one.
/// If false, the styles will coexist in the same range.
#[inline(always)]
pub fn mergeable(self) -> bool {
self.data & MERGEABLE_MASK != 0
}
/// When inserting new text around this style, prefer inserting after it.
#[inline(always)]
pub fn expand_before(self) -> bool {
@ -292,38 +245,14 @@ impl TextStyleInfoFlag {
}
}
#[inline(always)]
pub fn is_delete(&self) -> bool {
self.data & DELETE_MASK != 0
}
#[inline(always)]
pub fn is_container(&self) -> bool {
self.data & CONTAINER_MASK != 0
}
pub const fn new(
mergeable: bool,
expand_type: ExpandType,
is_delete: bool,
is_container: bool,
) -> Self {
pub const fn new(expand_type: ExpandType) -> Self {
let mut data = ALIVE_MASK;
if mergeable {
data |= MERGEABLE_MASK;
}
if expand_type.expand_before() {
data |= EXPAND_BEFORE_MASK;
}
if expand_type.expand_after() {
data |= EXPAND_AFTER_MASK;
}
if is_delete {
data |= DELETE_MASK;
}
if is_container {
data |= CONTAINER_MASK;
}
TextStyleInfoFlag { data }
}
@ -347,12 +276,9 @@ impl TextStyleInfoFlag {
Self { data }
}
pub const BOLD: TextStyleInfoFlag =
TextStyleInfoFlag::new(true, ExpandType::After, false, false);
pub const LINK: TextStyleInfoFlag =
TextStyleInfoFlag::new(true, ExpandType::None, false, false);
pub const COMMENT: TextStyleInfoFlag =
TextStyleInfoFlag::new(false, ExpandType::None, false, true);
pub const BOLD: TextStyleInfoFlag = TextStyleInfoFlag::new(ExpandType::After);
pub const LINK: TextStyleInfoFlag = TextStyleInfoFlag::new(ExpandType::None);
pub const COMMENT: TextStyleInfoFlag = TextStyleInfoFlag::new(ExpandType::None);
pub const fn to_byte(&self) -> u8 {
self.data

View file

@ -0,0 +1,123 @@
use fxhash::FxHashMap;
use loro_common::InternalString;
use super::{ExpandType, TextStyleInfoFlag};
#[derive(Debug, Default, Clone)]
pub struct StyleConfigMap {
map: FxHashMap<InternalString, StyleConfig>,
}
impl StyleConfigMap {
pub fn new() -> Self {
Self {
map: FxHashMap::default(),
}
}
pub fn insert(&mut self, key: InternalString, value: StyleConfig) {
if key.contains(':') {
panic!("style key should not contain ':'");
}
self.map.insert(key, value);
}
pub fn get(&self, key: &InternalString) -> Option<&StyleConfig> {
self.map.get(key)
}
pub fn get_style_flag(&self, key: &InternalString) -> Option<TextStyleInfoFlag> {
self._get_style_flag(key, false)
}
pub fn get_style_flag_for_unmark(&self, key: &InternalString) -> Option<TextStyleInfoFlag> {
self._get_style_flag(key, true)
}
fn _get_style_flag(&self, key: &InternalString, is_del: bool) -> Option<TextStyleInfoFlag> {
let f = |x: &StyleConfig| {
TextStyleInfoFlag::new(if is_del { x.expand.reverse() } else { x.expand })
};
if let Some(index) = key.find(':') {
let key = key[..index].into();
self.map.get(&key).map(f)
} else {
self.map.get(key).map(f)
}
}
pub fn default_rich_text_config() -> Self {
let mut map = Self {
map: FxHashMap::default(),
};
map.map.insert(
"bold".into(),
StyleConfig {
expand: ExpandType::After,
},
);
map.map.insert(
"italic".into(),
StyleConfig {
expand: ExpandType::After,
},
);
map.map.insert(
"underline".into(),
StyleConfig {
expand: ExpandType::After,
},
);
map.map.insert(
"link".into(),
StyleConfig {
expand: ExpandType::None,
},
);
map.map.insert(
"highlight".into(),
StyleConfig {
expand: ExpandType::None,
},
);
map.map.insert(
"comment".into(),
StyleConfig {
expand: ExpandType::None,
},
);
map
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StyleConfig {
pub expand: ExpandType,
}
impl StyleConfig {
pub fn new() -> Self {
Self {
expand: ExpandType::None,
}
}
pub fn expand(mut self, expand: ExpandType) -> Self {
self.expand = expand;
self
}
}
impl Default for StyleConfig {
fn default() -> Self {
Self::new()
}
}

View file

@ -1368,7 +1368,44 @@ impl RichtextState {
/// 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>) {
self.ensure_style_ranges_mut().annotate(range, style)
self.ensure_style_ranges_mut().annotate(range, style, None)
}
/// 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.
///
/// This method will return the event of this annotation in event length
pub(crate) fn annotate_style_range_with_event(
&mut self,
range: Range<usize>,
style: Arc<StyleOp>,
) -> impl Iterator<Item = (StyleMeta, usize)> + '_ {
let mut ranges_in_entity_index: Vec<(StyleMeta, Range<usize>)> = Vec::new();
let mut start = range.start;
let end = range.end;
self.ensure_style_ranges_mut().annotate(
range,
style,
Some(&mut |s, len| {
let range = start..start + len;
start += len;
ranges_in_entity_index.push((s.into(), range));
}),
);
assert_eq!(ranges_in_entity_index.last().unwrap().1.end, end);
let mut converter = ContinuousIndexConverter::new(self);
ranges_in_entity_index
.into_iter()
.filter_map(move |(meta, range)| {
let start = converter.convert_entity_index_to_event_index(range.start);
let end = converter.convert_entity_index_to_event_index(range.end);
if end == start {
return None;
}
Some((meta, end - start))
})
}
/// init style ranges if not initialized
@ -1384,11 +1421,11 @@ impl RichtextState {
/// The result is only different from `query` when there are style anchors around the insert pos.
/// Returns the right neighbor of the insert pos and the entity index.
///
/// 1. Insertions occur before tombstones that contain the beginning of new marks.
/// 2. Insertions occur before tombstones that contain the end of bold-like marks
/// 3. Insertions occur after tombstones that contain the end of link-like marks
/// 1. Insertions occur before style anchors that contain the beginning of new marks.
/// 2. Insertions occur before style anchors that contain the end of bold-like marks
/// 3. Insertions occur after style anchors that contain the end of link-like marks
///
/// Rule 1 should be satisfied before rules 2 and 3 to avoid this problem.
/// Rule 1 should be satisfied before rules 2 and 3 to avoid creating a new style out of nowhere
///
/// The current method will scan forward to find the last position that satisfies 1 and 2.
/// Then it scans backward to find the first position that satisfies 3.
@ -1739,7 +1776,7 @@ impl RichtextState {
// 1. We inserted a start anchor before end_entity_index, so we need to +1
// 2. We need to include the end anchor in the range, so we need to +1
self.ensure_style_ranges_mut()
.annotate(range.start..range.end + 2, style);
.annotate(range.start..range.end + 2, style, None);
}
pub fn iter(&self) -> impl Iterator<Item = RichtextSpan> + '_ {
@ -1966,6 +2003,69 @@ impl RichtextState {
}
}
use converter::ContinuousIndexConverter;
mod converter {
use generic_btree::{rle::HasLength, Cursor};
use super::{query::EntityQuery, RichtextState};
/// Convert entity index into event index.
/// It assumes the entity_index are in ascending order
pub(super) struct ContinuousIndexConverter<'a> {
state: &'a RichtextState,
last_entity_index_cache: Option<ConverterCache>,
}
struct ConverterCache {
entity_index: usize,
cursor: Cursor,
event_index: usize,
cursor_elem_len: usize,
}
impl<'a> ContinuousIndexConverter<'a> {
pub fn new(state: &'a RichtextState) -> Self {
Self {
state,
last_entity_index_cache: None,
}
}
pub fn convert_entity_index_to_event_index(&mut self, entity_index: usize) -> usize {
if let Some(last) = self.last_entity_index_cache.as_ref() {
if last.entity_index == entity_index {
return last.event_index;
}
assert!(entity_index > last.entity_index);
if last.cursor.offset + entity_index - last.entity_index < last.cursor_elem_len {
// in the same cursor
return self.state.cursor_to_event_index(Cursor {
leaf: last.cursor.leaf,
offset: last.cursor.offset + entity_index - last.entity_index,
});
}
}
let cursor = self
.state
.tree
.query::<EntityQuery>(&entity_index)
.unwrap()
.cursor;
let ans = self.state.cursor_to_event_index(cursor);
let len = self.state.tree.get_elem(cursor.leaf).unwrap().rle_len();
self.last_entity_index_cache = Some(ConverterCache {
entity_index,
cursor,
event_index: ans,
cursor_elem_len: len,
});
ans
}
}
}
#[cfg(test)]
mod test {
use append_only_bytes::AppendOnlyBytes;
@ -2029,7 +2129,7 @@ mod test {
fn comment(n: isize) -> Arc<StyleOp> {
Arc::new(StyleOp::new_for_test(
n,
"comment",
&format!("comment:{}", n),
"comment".into(),
TextStyleInfoFlag::COMMENT,
))
@ -2355,33 +2455,21 @@ mod test {
{
"insert": "H",
"attributes": {
"id:0@0": {
"key": "comment",
"data": "comment"
},
"comment:0": "comment",
},
},
{
"insert": "ello",
"attributes": {
"id:0@0": {
"key": "comment",
"data": "comment"
},
"id:1@1": {
"key": "comment",
"data": "comment"
}
"comment:0": "comment",
"comment:1": "comment",
},
},
{
"insert": " ",
"attributes": {
"id:1@1": {
"key": "comment",
"data": "comment"
}
"comment:1": "comment",
},
},
{

View file

@ -133,7 +133,12 @@ impl StyleRangeMap {
}
}
pub fn annotate(&mut self, range: Range<usize>, style: Arc<StyleOp>) {
pub fn annotate(
&mut self,
range: Range<usize>,
style: Arc<StyleOp>,
mut yield_style: Option<&mut dyn FnMut(&Styles, usize)>,
) {
let range = self.tree.range::<LengthFinder>(range);
if range.is_none() {
unreachable!();
@ -152,6 +157,9 @@ impl StyleRangeMap {
x.styles.insert(key, value);
}
if let Some(y) = yield_style.as_mut() {
y(&x.styles, x.len);
}
None
});
}
@ -255,7 +263,7 @@ impl StyleRangeMap {
let right = self.tree.shift_path_by_one_offset(left).unwrap();
if left.leaf == right.leaf {
let styles = &self.tree.get_elem(left.leaf).unwrap().styles;
styles.clone().into()
styles.into()
} else {
let mut styles = self.tree.get_elem(left.leaf).unwrap().styles.clone();
let right_styles = &self.tree.get_elem(right.leaf).unwrap().styles;
@ -502,7 +510,7 @@ mod test {
#[test]
fn test_basic_insert() {
let mut map = StyleRangeMap::default();
map.annotate(1..10, new_style(1));
map.annotate(1..10, new_style(1), None);
{
map.insert(0, 1);
assert_eq!(map.iter().count(), 1);
@ -532,7 +540,7 @@ mod test {
#[test]
fn delete_style() {
let mut map = StyleRangeMap::default();
map.annotate(1..10, new_style(1));
map.annotate(1..10, new_style(1), None);
{
map.delete(0..2);
assert_eq!(map.iter().count(), 1);

View file

@ -615,12 +615,15 @@ impl<Value: DeltaValue, M: Meta> Delta<Value, M> {
}
pub fn chop(mut self) -> Self {
let last_op = self.vec.last();
if let Some(last_op) = last_op {
if last_op.is_retain() && last_op.meta().is_empty() {
self.vec.pop();
loop {
match self.vec.last() {
Some(last_op) if last_op.is_retain() && last_op.meta().is_empty() => {
self.vec.pop();
}
_ => break,
}
}
self
}
}

View file

@ -33,8 +33,8 @@ impl StyleMetaItem {
}
}
impl From<Styles> for StyleMeta {
fn from(styles: Styles) -> Self {
impl From<&Styles> for StyleMeta {
fn from(styles: &Styles) -> Self {
let mut map = FxHashMap::with_capacity_and_hasher(styles.len(), Default::default());
for (key, value) in styles.iter() {
if let Some(value) = value.get() {
@ -52,6 +52,13 @@ impl From<Styles> for StyleMeta {
}
}
impl From<Styles> for StyleMeta {
fn from(styles: Styles) -> Self {
let temp = &styles;
temp.into()
}
}
impl Meta for StyleMeta {
fn is_empty(&self) -> bool {
self.map.is_empty()
@ -103,17 +110,7 @@ impl StyleMeta {
return None;
}
Some((
key.to_attr_key(),
if key.contains_id() {
let mut map: FxHashMap<String, LoroValue> = Default::default();
map.insert("key".into(), key.key().to_string().into());
map.insert("data".into(), value.value.clone());
LoroValue::Map(Arc::new(map))
} else {
value.value.clone()
},
))
Some((key.to_attr_key(), value.value.clone()))
})
.collect(),
))
@ -124,15 +121,7 @@ impl ToJson for StyleMeta {
fn to_json_value(&self) -> serde_json::Value {
let mut map = serde_json::Map::new();
for (key, style) in self.iter() {
let value = if !key.contains_id() {
serde_json::to_value(&style.data).unwrap()
} else {
let mut value = serde_json::Map::new();
value.insert("key".to_string(), style.key.to_string().into());
let data = serde_json::to_value(&style.data).unwrap();
value.insert("data".to_string(), data);
value.into()
};
let value = serde_json::to_value(&style.data).unwrap();
map.insert(key.to_attr_key(), value);
}

View file

@ -4,7 +4,6 @@ pub mod tree;
use crate::{
array_mut_ref,
container::richtext::TextStyleInfoFlag,
delta::{Delta, DeltaItem, StyleMeta},
event::Diff,
loro::LoroDoc,
@ -13,7 +12,7 @@ use crate::{
};
use debug_log::debug_log;
use enum_as_inner::EnumAsInner;
use loro_common::{ContainerID, LoroValue};
use loro_common::ContainerID;
use std::{
fmt::Debug,
sync::{Arc, Mutex},
@ -21,17 +20,7 @@ use std::{
};
use tabled::{TableIteratorExt, Tabled};
const STYLES: [TextStyleInfoFlag; 8] = [
TextStyleInfoFlag::BOLD,
TextStyleInfoFlag::COMMENT,
TextStyleInfoFlag::LINK,
TextStyleInfoFlag::LINK.to_delete(),
TextStyleInfoFlag::BOLD.to_delete(),
TextStyleInfoFlag::COMMENT.to_delete(),
TextStyleInfoFlag::from_byte(0),
TextStyleInfoFlag::from_byte(0).to_delete(),
];
const STYLES_NAME: [&str; 4] = ["bold", "comment", "link", "highlight"];
#[derive(arbitrary::Arbitrary, EnumAsInner, Clone, PartialEq, Eq, Debug)]
pub enum Action {
Ins {
@ -256,18 +245,13 @@ impl Actionable for Vec<LoroDoc> {
let site = &mut self[*site as usize];
let mut txn = site.txn().unwrap();
let text = txn.get_text("text");
let style = STYLES[*style_key as usize];
text.mark_with_txn(
&mut txn,
*pos,
*pos + *len,
&style_key.to_string(),
if style.is_delete() {
LoroValue::Null
} else {
true.into()
},
style,
STYLES_NAME[*style_key as usize % STYLES_NAME.len()],
(*pos as i32).into(),
false,
)
.unwrap();
}

View file

@ -12,36 +12,11 @@ use crate::{
array_mut_ref, container::ContainerID, delta::DeltaItem, id::PeerID, ContainerType, LoroValue,
};
use crate::{
container::richtext::{StyleKey, TextStyleInfoFlag},
event::Diff,
handler::TextDelta,
loro::LoroDoc,
value::ToJson,
version::Frontiers,
TextHandler,
container::richtext::StyleKey, event::Diff, handler::TextDelta, loro::LoroDoc, value::ToJson,
version::Frontiers, TextHandler,
};
const STYLES: [TextStyleInfoFlag; 8] = [
TextStyleInfoFlag::BOLD,
TextStyleInfoFlag::COMMENT,
TextStyleInfoFlag::LINK,
TextStyleInfoFlag::from_byte(0),
TextStyleInfoFlag::LINK.to_delete(),
TextStyleInfoFlag::BOLD.to_delete(),
TextStyleInfoFlag::COMMENT.to_delete(),
TextStyleInfoFlag::from_byte(0).to_delete(),
];
const STYLES_NAME: [&str; 8] = [
"BOLD",
"COMMENT",
"LINK",
"0",
"DEL_LINK",
"DEL_BOLD",
"DEL_COMMENT",
"DEL_0",
];
const STYLES_NAME: [&str; 4] = ["bold", "comment", "link", "highlight"];
#[derive(Arbitrary, EnumAsInner, Clone, PartialEq, Eq, Debug)]
pub enum Action {
@ -123,15 +98,6 @@ impl Actor {
.filter(|(_, v)| !v.data.is_null())
.map(|(k, v)| match k {
StyleKey::Key(k) => (k.to_string(), v.data),
StyleKey::KeyWithId { key, id } => {
let mut data = FxHashMap::default();
data.insert(
"key".to_string(),
LoroValue::String(Arc::new(key.to_string())),
);
data.insert("data".to_string(), v.data);
(format!("id:{}", id), LoroValue::Map(Arc::new(data)))
}
})
.collect();
let attributes = if attributes.is_empty() {
@ -154,15 +120,6 @@ impl Actor {
.filter(|(_, v)| !v.data.is_null())
.map(|(k, v)| match k {
StyleKey::Key(k) => (k.to_string(), v.data),
StyleKey::KeyWithId { key, id } => {
let mut data = FxHashMap::default();
data.insert(
"key".to_string(),
LoroValue::String(Arc::new(key.to_string())),
);
data.insert("data".to_string(), v.data);
(format!("id:{}", id), LoroValue::Map(Arc::new(data)))
}
})
.collect();
let attributes = if attributes.is_empty() {
@ -182,7 +139,7 @@ impl Actor {
// text_doc.peer_id(),
// text_h.get_richtext_value()
// );
// println!("delta {:?}", text_deltas);
debug_log::debug_log!("delta {:?}", text_deltas);
text_h.apply_delta_with_txn(&mut txn, &text_deltas).unwrap();
// println!("after {:?}\n", text_h.get_richtext_value());
@ -329,7 +286,7 @@ impl Actionable for Vec<Actor> {
*pos %= length;
*len %= length - *pos;
*len = 1.max(*len);
*i %= STYLES.len();
*i %= STYLES_NAME.len();
}
}
}
@ -395,18 +352,13 @@ impl Actionable for Vec<Actor> {
text.delete_with_txn(&mut txn, *pos, *len).unwrap();
}
RichTextAction::Mark(i) => {
let style = STYLES[*i];
text.mark_with_txn(
&mut txn,
*pos,
*pos + *len,
STYLES_NAME[*i],
if style.is_delete() {
LoroValue::Null
} else {
true.into()
},
style,
(*pos as i32).into(),
false,
)
.unwrap();
}
@ -469,6 +421,7 @@ fn check_eq(a_actor: &mut Actor, b_actor: &mut Actor) {
debug_log::debug_log!("{}", a_result.to_json_pretty());
assert_eq!(&a_result, &b_result);
debug_log::debug_log!("{}", a_value.to_json_pretty());
assert_value_eq(&a_result, &a_value);
}
@ -1034,4 +987,25 @@ mod failed_tests {
],
)
}
#[test]
fn allow_overlap() {
test_multi_sites(
5,
&mut [
RichText {
site: 255,
pos: 562949940576255,
value: 10,
action: RichTextAction::Insert,
},
RichText {
site: 0,
pos: 52793738066393,
value: 15637060856183783423,
action: RichTextAction::Mark(15697817505862638041),
},
],
);
}
}

View file

@ -4,7 +4,7 @@ use crate::{
container::{
idx::ContainerIdx,
list::list_op::{DeleteSpan, ListOp},
richtext::{richtext_state::PosType, TextStyleInfoFlag},
richtext::richtext_state::PosType,
tree::tree_op::TreeOp,
},
delta::{TreeDiffItem, TreeExternalDiff},
@ -16,12 +16,14 @@ use crate::{
use enum_as_inner::EnumAsInner;
use fxhash::FxHashMap;
use loro_common::{
ContainerID, ContainerType, LoroError, LoroResult, LoroTreeError, LoroValue, TreeID,
ContainerID, ContainerType, InternalString, LoroError, LoroResult, LoroTreeError, LoroValue,
TreeID,
};
use serde::{Deserialize, Serialize};
use smallvec::smallvec;
use std::{
borrow::Cow,
ops::Deref,
sync::{Mutex, Weak},
};
@ -41,6 +43,7 @@ pub enum TextDelta {
},
}
/// Flatten attributes that allow overlap
#[derive(Clone)]
pub struct TextHandler {
txn: Weak<Mutex<Option<Transaction>>>,
@ -315,7 +318,7 @@ impl TextHandler {
pos: usize,
s: &str,
attr: Option<&FxHashMap<String, LoroValue>>,
) -> Result<Vec<(String, LoroValue)>, LoroError> {
) -> Result<Vec<(InternalString, LoroValue)>, LoroError> {
if s.is_empty() {
return Ok(Vec::new());
}
@ -345,10 +348,11 @@ impl TextHandler {
// current styles
let map: FxHashMap<_, _> = styles
.iter()
.map(|x| (x.0.to_attr_key(), x.1.data))
.map(|x| (x.0.key().clone(), x.1.data))
.collect();
debug_log::debug_dbg!(&map);
for (key, style) in map.iter() {
match attr.get(key) {
match attr.get(key.deref()) {
Some(v) if v == style => {}
new_style_value => {
// need to override
@ -359,8 +363,9 @@ impl TextHandler {
}
for (key, style) in attr.iter() {
if !map.contains_key(key) {
override_styles.push((key.clone(), style.clone()));
let key = key.as_str().into();
if !map.contains_key(&key) {
override_styles.push((key, style.clone()));
}
}
}
@ -466,12 +471,28 @@ impl TextHandler {
&self,
start: usize,
end: usize,
key: &str,
key: impl Into<InternalString>,
value: LoroValue,
flag: TextStyleInfoFlag,
) -> LoroResult<()> {
with_txn(&self.txn, |txn| {
self.mark_with_txn(txn, start, end, key, value, flag)
self.mark_with_txn(txn, start, end, key, value, false)
})
}
/// `start` and `end` are [Event Index]s:
///
/// - if feature="wasm", pos is a UTF-16 index
/// - if feature!="wasm", pos is a Unicode index
///
/// This method requires auto_commit to be enabled.
pub fn unmark(
&self,
start: usize,
end: usize,
key: impl Into<InternalString>,
) -> LoroResult<()> {
with_txn(&self.txn, |txn| {
self.mark_with_txn(txn, start, end, key, LoroValue::Null, true)
})
}
@ -484,9 +505,9 @@ impl TextHandler {
txn: &mut Transaction,
start: usize,
end: usize,
key: &str,
key: impl Into<InternalString>,
value: LoroValue,
flag: TextStyleInfoFlag,
is_delete: bool,
) -> LoroResult<()> {
if start >= end {
return Err(loro_common::LoroError::ArgErr(
@ -499,27 +520,24 @@ impl TextHandler {
return Err(LoroError::OutOfBound { pos: end, len });
}
let (entity_range, skip) = self
.state
.upgrade()
.unwrap()
.lock()
.unwrap()
.with_state_mut(self.container_idx, |state| {
let (entity_range, styles) = state
.as_richtext_state_mut()
.unwrap()
.get_entity_range_and_styles_at_range(start..end, PosType::Event);
let key: InternalString = key.into();
let mutex = &self.state.upgrade().unwrap();
let mut doc_state = mutex.lock().unwrap();
let (entity_range, skip) = doc_state.with_state_mut(self.container_idx, |state| {
let (entity_range, styles) = state
.as_richtext_state_mut()
.unwrap()
.get_entity_range_and_styles_at_range(start..end, PosType::Event);
let skip = match styles {
Some(styles) if styles.has_key_value(key, &value) => {
// already has the same style, skip
true
}
_ => false,
};
(entity_range, skip)
});
let skip = match styles {
Some(styles) if styles.has_key_value(&key, &value) => {
// already has the same style, skip
true
}
_ => false,
};
(entity_range, skip)
});
if skip {
return Ok(());
@ -527,24 +545,32 @@ impl TextHandler {
let entity_start = entity_range.start;
let entity_end = entity_range.end;
let style_config = doc_state.config.text_style_config.try_read().unwrap();
let flag = if is_delete {
style_config
.get_style_flag_for_unmark(&key)
.ok_or_else(|| LoroError::StyleConfigMissing(key.clone()))?
} else {
style_config
.get_style_flag(&key)
.ok_or_else(|| LoroError::StyleConfigMissing(key.clone()))?
};
drop(style_config);
drop(doc_state);
txn.apply_local_op(
self.container_idx,
crate::op::RawOpContent::List(ListOp::StyleStart {
start: entity_start as u32,
end: entity_end as u32,
key: key.into(),
key: key.clone(),
value: value.clone(),
info: flag,
}),
EventHint::Mark {
start: start as u32,
end: end as u32,
info: flag,
style: crate::container::richtext::Style {
key: key.into(),
data: value,
},
style: crate::container::richtext::Style { key, data: value },
},
&self.state,
)?;
@ -559,6 +585,20 @@ impl TextHandler {
Ok(())
}
pub fn check(&self) {
self.state
.upgrade()
.unwrap()
.try_lock()
.unwrap()
.with_state_mut(self.container_idx, |state| {
state
.as_richtext_state_mut()
.unwrap()
.check_consistency_between_content_and_style_ranges()
})
}
pub fn apply_delta(&self, delta: &[TextDelta]) -> LoroResult<()> {
with_txn(&self.txn, |txn| self.apply_delta_with_txn(txn, delta))
}
@ -581,8 +621,9 @@ impl TextHandler {
Some(attributes.as_ref().unwrap_or(&Default::default())),
)?;
debug_log::debug_dbg!(&override_styles);
for (key, value) in override_styles {
marks.push((index, end, Cow::Owned(key), value));
marks.push((index, end, key, value));
}
index = end;
@ -595,12 +636,7 @@ impl TextHandler {
match attributes {
Some(attr) if !attr.is_empty() => {
for (key, value) in attr {
marks.push((
index,
end,
Cow::Borrowed(key.as_str()),
value.clone(),
));
marks.push((index, end, key.deref().into(), value.clone()));
}
}
_ => {}
@ -611,8 +647,7 @@ impl TextHandler {
}
for (start, end, key, value) in marks {
// FIXME: allow users to set a config table to store the flag, so that we can use it directly
self.mark_with_txn(txn, start, end, &key, value, TextStyleInfoFlag::BOLD)?;
self.mark_with_txn(txn, start, end, key.deref(), value, false)?;
}
Ok(())
@ -1348,7 +1383,6 @@ fn with_txn<R>(
mod test {
use std::ops::Deref;
use crate::container::richtext::TextStyleInfoFlag;
use crate::loro::LoroDoc;
use crate::version::Frontiers;
use crate::{fx_map, ToJson};
@ -1446,7 +1480,7 @@ mod test {
let handler = loro.get_text("richtext");
handler.insert_with_txn(&mut txn, 0, "hello world").unwrap();
handler
.mark_with_txn(&mut txn, 0, 5, "bold", true.into(), TextStyleInfoFlag::BOLD)
.mark_with_txn(&mut txn, 0, 5, "bold", true.into(), false)
.unwrap();
txn.commit().unwrap();
@ -1498,7 +1532,7 @@ mod test {
let handler = loro.get_text("richtext");
handler.insert_with_txn(&mut txn, 0, "hello world").unwrap();
handler
.mark_with_txn(&mut txn, 0, 5, "bold", true.into(), TextStyleInfoFlag::BOLD)
.mark_with_txn(&mut txn, 0, 5, "bold", true.into(), false)
.unwrap();
txn.commit().unwrap();

View file

@ -45,7 +45,6 @@ pub(crate) mod group;
pub(crate) mod macros;
pub(crate) mod state;
pub(crate) mod value;
pub(crate) use change::Timestamp;
pub(crate) use id::{PeerID, ID};
// TODO: rename as Key?

View file

@ -15,7 +15,8 @@ use loro_common::{ContainerID, ContainerType, LoroResult, LoroValue};
use crate::{
arena::SharedArena,
change::Timestamp,
container::{idx::ContainerIdx, IntoContainerId},
configure::Configure,
container::{idx::ContainerIdx, richtext::config::StyleConfigMap, IntoContainerId},
encoding::{
decode_snapshot, export_snapshot, parse_header_and_body, EncodeMode, ParsedHeaderAndBody,
},
@ -57,6 +58,7 @@ pub struct LoroDoc {
oplog: Arc<Mutex<OpLog>>,
state: Arc<Mutex<DocState>>,
arena: SharedArena,
config: Configure,
observer: Arc<Observer>,
diff_calculator: Arc<Mutex<DiffCalculator>>,
// when dropping the doc, the txn will be committed
@ -76,11 +78,13 @@ impl LoroDoc {
let oplog = OpLog::new();
let arena = oplog.arena.clone();
let global_txn = Arc::new(Mutex::new(None));
let config: Configure = Default::default();
// share arena
let state = DocState::new_arc(arena.clone(), Arc::downgrade(&global_txn));
let state = DocState::new_arc(arena.clone(), Arc::downgrade(&global_txn), config.clone());
Self {
oplog: Arc::new(Mutex::new(oplog)),
state,
config,
detached: AtomicBool::new(false),
auto_commit: AtomicBool::new(false),
observer: Arc::new(Observer::new(arena.clone())),
@ -90,6 +94,11 @@ impl LoroDoc {
}
}
#[inline]
pub fn config_text_style(&self, text_style: StyleConfigMap) {
*self.config.text_style_config.try_write().unwrap() = text_style;
}
/// Create a doc with auto commit enabled.
#[inline]
pub fn new_auto_commit() -> Self {
@ -129,6 +138,7 @@ impl LoroDoc {
Self {
arena: oplog.arena.clone(),
observer: Arc::new(obs),
config: Default::default(),
auto_commit: AtomicBool::new(false),
oplog: Arc::new(Mutex::new(oplog)),
state: Arc::new(Mutex::new(state)),

View file

@ -1,6 +1,6 @@
use std::{
borrow::Cow,
sync::{Arc, Mutex, Weak},
sync::{Arc, Mutex, RwLock, Weak},
};
use enum_as_inner::EnumAsInner;
@ -9,12 +9,12 @@ use fxhash::{FxHashMap, FxHashSet};
use loro_common::{ContainerID, LoroError, LoroResult};
use crate::{
configure::{DefaultRandom, SecureRandomGenerator},
configure::{Configure, DefaultRandom, SecureRandomGenerator},
container::{
idx::ContainerIdx, list::list_op::ListOp, map::MapSet, tree::tree_op::TreeOp,
ContainerIdRaw,
idx::ContainerIdx, list::list_op::ListOp, map::MapSet, richtext::config::StyleConfigMap,
tree::tree_op::TreeOp, ContainerIdRaw,
},
delta::{DeltaItem, MapValue},
delta::DeltaItem,
encoding::{StateSnapshotDecodeContext, StateSnapshotEncoder},
event::{Diff, Index, InternalContainerDiff, InternalDiff},
fx_map,
@ -38,6 +38,17 @@ pub(crate) use tree_state::{get_meta_value, TreeState};
use super::{arena::SharedArena, event::InternalDocDiff};
macro_rules! get_or_create {
($doc_state: ident, $idx: expr) => {{
if !$doc_state.states.contains_key(&$idx) {
let state = $doc_state.create_state($idx);
$doc_state.states.insert($idx, state);
}
$doc_state.states.get_mut(&$idx).unwrap()
}};
}
#[derive(Clone)]
pub struct DocState {
pub(super) peer: PeerID,
@ -45,7 +56,7 @@ pub struct DocState {
pub(super) frontiers: Frontiers,
pub(super) states: FxHashMap<ContainerIdx, State>,
pub(super) arena: SharedArena,
pub(crate) config: Configure,
// resolve event stuff
weak_state: Weak<Mutex<DocState>>,
global_txn: Weak<Mutex<Option<Transaction>>>,
@ -206,8 +217,8 @@ impl State {
Self::MapState(Box::new(MapState::new(idx)))
}
pub fn new_richtext(idx: ContainerIdx) -> Self {
Self::RichtextState(Box::new(RichtextState::new(idx)))
pub fn new_richtext(idx: ContainerIdx, config: Arc<RwLock<StyleConfigMap>>) -> Self {
Self::RichtextState(Box::new(RichtextState::new(idx, config)))
}
pub fn new_tree(idx: ContainerIdx) -> Self {
@ -220,6 +231,7 @@ impl DocState {
pub fn new_arc(
arena: SharedArena,
global_txn: Weak<Mutex<Option<Transaction>>>,
config: Configure,
) -> Arc<Mutex<Self>> {
let peer = DefaultRandom.next_u64();
// TODO: maybe we should switch to certain version in oplog?
@ -230,6 +242,7 @@ impl DocState {
frontiers: Frontiers::default(),
states: FxHashMap::default(),
weak_state: weak.clone(),
config,
global_txn,
in_txn: false,
changed_idx_in_txn: FxHashSet::default(),
@ -376,10 +389,7 @@ impl DocState {
diff.bring_back = true;
}
if diff.bring_back {
let state = self
.states
.entry(diff.idx)
.or_insert_with(|| create_state(idx));
let state = get_or_create!(self, diff.idx);
let state_diff = state.to_diff(&self.arena, &self.global_txn, &self.weak_state);
if diff.diff.is_none() && state_diff.is_empty() {
// empty diff, skip it
@ -419,7 +429,7 @@ impl DocState {
self.changed_idx_in_txn.insert(idx);
}
self.set_parent_by_diff(internal_diff.as_internal().unwrap(), idx);
let state = self.states.entry(idx).or_insert_with(|| create_state(idx));
let state = get_or_create!(self, idx);
if is_recording {
// process bring_back before apply
let external_diff = if diff.bring_back {
@ -465,11 +475,7 @@ impl DocState {
pub fn apply_local_op(&mut self, raw_op: &RawOp, op: &Op) -> LoroResult<()> {
// set parent first, `MapContainer` will only be created for TreeID that does not contain
self.set_container_parent_by_op(raw_op);
let state = self
.states
.entry(op.container)
.or_insert_with(|| create_state(op.container));
let state = get_or_create!(self, op.container);
if self.in_txn {
self.changed_idx_in_txn.insert(op.container);
}
@ -526,12 +532,7 @@ impl DocState {
}
}
RawOpContent::Tree(TreeOp { target, .. }) => {
let state = self
.states
.entry(container)
.or_insert_with(|| create_state(container))
.as_tree_state()
.unwrap();
let state = get_or_create!(self, container).as_tree_state().unwrap();
// create associated metadata container
// TODO: maybe we could create map container only when setting metadata
if !&state.trees.contains_key(target) {
@ -570,12 +571,7 @@ impl DocState {
}
}
InternalDiff::Tree(tree) => {
let state = self
.states
.entry(container)
.or_insert_with(|| create_state(container))
.as_tree_state()
.unwrap();
let state = get_or_create!(self, container).as_tree_state().unwrap();
for diff in tree.diff.iter() {
let target = &diff.target;
if !state.trees.contains_key(target) {
@ -595,7 +591,7 @@ impl DocState {
decode_ctx: StateSnapshotDecodeContext,
) {
let idx = self.arena.register_container(&cid);
let state = self.states.entry(idx).or_insert_with(|| create_state(idx));
let state = get_or_create!(self, idx);
state.import_from_snapshot_ops(decode_ctx);
}
@ -699,7 +695,7 @@ impl DocState {
let idx = idx.unwrap();
self.states
.entry(idx)
.or_insert_with(|| State::new_richtext(idx))
.or_insert_with(|| State::new_richtext(idx, self.config.text_style_config.clone()))
.as_richtext_state_mut()
.map(|x| &mut **x)
}
@ -713,7 +709,7 @@ impl DocState {
if let Some(state) = state {
f(state)
} else {
let state = create_state(idx);
let state = self.create_state(idx);
let ans = f(&state);
self.states.insert(idx, state);
ans
@ -729,7 +725,7 @@ impl DocState {
if let Some(state) = state {
f(state)
} else {
let mut state = create_state(idx);
let mut state = self.create_state(idx);
let ans = f(&mut state);
self.states.insert(idx, state);
ans
@ -1083,6 +1079,18 @@ impl DocState {
state_size_sum
);
}
pub fn create_state(&self, idx: ContainerIdx) -> State {
match idx.get_type() {
ContainerType::Map => State::MapState(Box::new(MapState::new(idx))),
ContainerType::List => State::ListState(Box::new(ListState::new(idx))),
ContainerType::Text => State::RichtextState(Box::new(RichtextState::new(
idx,
self.config.text_style_config.clone(),
))),
ContainerType::Tree => State::TreeState(Box::new(TreeState::new(idx))),
}
}
}
struct SubContainerDiffPatch {
@ -1176,15 +1184,6 @@ impl SubContainerDiffPatch {
}
}
pub fn create_state(idx: ContainerIdx) -> State {
match idx.get_type() {
ContainerType::Map => State::MapState(Box::new(MapState::new(idx))),
ContainerType::List => State::ListState(Box::new(ListState::new(idx))),
ContainerType::Text => State::RichtextState(Box::new(RichtextState::new(idx))),
ContainerType::Tree => State::TreeState(Box::new(TreeState::new(idx))),
}
}
#[derive(Default, Clone)]
struct EventRecorder {
recording_diff: bool,

View file

@ -1,6 +1,6 @@
use std::{
ops::Range,
sync::{Arc, Mutex, Weak},
sync::{Arc, Mutex, RwLock, Weak},
};
use fxhash::FxHashMap;
@ -12,6 +12,7 @@ use crate::{
container::{
idx::ContainerIdx,
richtext::{
config::StyleConfigMap,
richtext_state::{EntityRangeInfo, PosType},
AnchorType, RichtextState as InnerState, StyleOp, Styles,
},
@ -33,14 +34,16 @@ use super::ContainerState;
#[derive(Debug)]
pub struct RichtextState {
idx: ContainerIdx,
config: Arc<RwLock<StyleConfigMap>>,
pub(crate) state: LazyLoad<RichtextStateLoader, InnerState>,
}
impl RichtextState {
#[inline]
pub fn new(idx: ContainerIdx) -> Self {
pub fn new(idx: ContainerIdx, config: Arc<RwLock<StyleConfigMap>>) -> Self {
Self {
idx,
config,
state: LazyLoad::Src(Default::default()),
}
}
@ -74,6 +77,7 @@ impl Clone for RichtextState {
fn clone(&self) -> Self {
Self {
idx: self.idx,
config: self.config.clone(),
state: self.state.clone(),
}
}
@ -176,25 +180,18 @@ impl ContainerState for RichtextState {
event_index: start_event_index,
} = style_starts.remove(style).unwrap();
let mut delta: Delta<StringSlice, _> =
Delta::new().retain(start_event_index);
// we need to + 1 because we also need to annotate the end anchor
self.state.get_mut().annotate_style_range(
let event = self.state.get_mut().annotate_style_range_with_event(
start_entity_index..entity_index + 1,
style.clone(),
);
for (s, l) in event {
delta = delta.retain_with_meta(l, s);
}
let mut meta = StyleMeta::default();
meta.insert(
style.get_style_key(),
crate::delta::StyleMetaItem {
lamport: style.lamport,
peer: style.peer,
value: style.to_value(),
},
);
let delta: Delta<StringSlice, _> = Delta::new()
.retain(start_event_index)
.retain_with_meta(new_event_index - start_event_index, meta);
delta = delta.chop();
style_delta = style_delta.compose(delta);
}
}

View file

@ -16,7 +16,7 @@ use crate::{
container::{
idx::ContainerIdx,
list::list_op::{DeleteSpan, InnerListOp},
richtext::{Style, StyleKey, TextStyleInfoFlag},
richtext::{Style, StyleKey},
IntoContainerId,
},
delta::{
@ -73,7 +73,6 @@ pub(super) enum EventHint {
start: u32,
end: u32,
style: Style,
info: TextStyleInfoFlag,
},
InsertText {
/// pos is a Unicode index. If wasm, it's a UTF-16 index.
@ -501,38 +500,16 @@ fn change_to_diff(
}
'outer: {
match hint {
EventHint::Mark {
start,
end,
style,
info,
} => {
EventHint::Mark { start, end, style } => {
let mut meta = StyleMeta::default();
if info.mergeable() {
meta.insert(
StyleKey::Key(style.key.clone()),
StyleMetaItem {
lamport,
peer: change.id.peer,
value: style.data,
},
)
} else {
meta.insert(
StyleKey::KeyWithId {
key: style.key.clone(),
id: ID {
peer: change.id.peer,
counter: op.counter,
},
},
StyleMetaItem {
lamport,
peer: change.id.peer,
value: style.data,
},
)
}
meta.insert(
StyleKey::Key(style.key.clone()),
StyleMetaItem {
lamport,
peer: change.id.peer,
value: style.data,
},
);
let diff = Delta::new()
.retain(start as usize)
.retain_with_meta((end - start) as usize, meta);

View file

@ -5,10 +5,6 @@ pub enum LazyLoad<Src, Dst: From<Src>> {
}
impl<Src: Default, Dst: From<Src>> LazyLoad<Src, Dst> {
pub fn new_dst(dst: Dst) -> Self {
LazyLoad::Dst(dst)
}
pub fn get_mut(&mut self) -> &mut Dst {
match self {
LazyLoad::Src(src) => {

View file

@ -456,16 +456,7 @@ pub mod wasm {
// TODO: refactor: should we extract the common code of ToJson and ToJsValue
let obj = Object::new();
for (key, style) in value.iter() {
let value = if !key.contains_id() {
JsValue::from(style.data)
} else {
let value = Object::new();
js_sys::Reflect::set(&value, &"key".into(), &JsValue::from_str(&style.key))
.unwrap();
let data = JsValue::from(style.data);
js_sys::Reflect::set(&value, &"data".into(), &data).unwrap();
value.into()
};
let value = JsValue::from(style.data);
js_sys::Reflect::set(&obj, &JsValue::from_str(&key.to_attr_key()), &value).unwrap();
}

View file

@ -36,18 +36,7 @@
| Concurrent B | `Hello a World` |
| Expected Result | `Hello a <b>World</b>` |
#### 4. Concurrently insert text and style that expands
This doesn't work well in our implementation
| Name | Text |
|:----------------|:----------------------|
| Origin | `Hello World` |
| Concurrent A | `<b>Hello</b> World` |
| Concurrent B | `Hello* World` |
| Expected Result | `<b>Hello*</b> World` |
#### 5. Concurrent text edit & style that shrink
#### 4. Concurrent text edit & style that shrink
| Name | Text |
|:----------------|:---------------------------|
@ -56,7 +45,7 @@ This doesn't work well in our implementation
| Concurrent B | `Hey World` |
| Expected Result | `<link>Hey</link> World` |
#### 6. Local insertion expand rules
#### 5. Local insertion expand rules
> [**Hello**](https://www.google.com) World
@ -71,7 +60,7 @@ When insert a new character after "Hello", the new char should be bold but not l
| Expected Result | `<b><link>Hello</link>t<b> World` |
#### 7. Concurrent unbold
#### 6. Concurrent unbold
In Peritext paper 2.3.2
@ -82,7 +71,7 @@ In Peritext paper 2.3.2
| Concurrent B | `<b>The </b>fox<b> jumped</b> over the dog.` |
| Expected Result | `The fox jumped over the dog.` |
#### 8. Bold & Unbold
#### 7. Bold & Unbold
In Peritext paper 2.3.3
@ -93,7 +82,7 @@ In Peritext paper 2.3.3
| Concurrent B | `<b>The</b> fox jumped over the <b>dog</b>.` |
| Expected Result | `<b>The</b> fox jumped over the <b>dog</b>.` |
#### 9. Overlapped formatting
#### 8. Overlapped formatting
In Peritext paper 3.2, example 3
@ -104,6 +93,6 @@ In Peritext paper 3.2, example 3
| Concurrent B | The *fox jumped*. |
| Expected Result | **The _fox_**<i> jumped</i>. |
#### 10. Multiple instances of the same mark
#### 9. Multiple instances of the same mark
![](https://i.postimg.cc/MTNGq8cH/Clean-Shot-2023-10-09-at-12-16-29-2x.png)

View file

@ -3,7 +3,7 @@
use std::ops::Range;
use loro_common::LoroValue;
use loro_internal::{container::richtext::TextStyleInfoFlag, LoroDoc, ToJson};
use loro_internal::{LoroDoc, ToJson};
use serde_json::json;
fn init(s: &str) -> LoroDoc {
@ -37,14 +37,6 @@ impl Kind {
Kind::Italic => "italic",
}
}
fn flag(&self) -> TextStyleInfoFlag {
match self {
Kind::Bold => TextStyleInfoFlag::BOLD,
Kind::Link => TextStyleInfoFlag::LINK,
Kind::Italic => TextStyleInfoFlag::BOLD,
}
}
}
fn insert(doc: &LoroDoc, pos: usize, s: &str) {
@ -62,18 +54,7 @@ fn delete(doc: &LoroDoc, pos: usize, len: usize) {
fn mark(doc: &LoroDoc, range: Range<usize>, kind: Kind) {
let richtext = doc.get_text("r");
doc.with_txn(|txn| {
richtext.mark_with_txn(
txn,
range.start,
range.end,
kind.key(),
if kind.flag().is_delete() {
LoroValue::Null
} else {
true.into()
},
kind.flag(),
)
richtext.mark_with_txn(txn, range.start, range.end, kind.key(), true.into(), false)
})
.unwrap();
}
@ -81,14 +62,15 @@ fn mark(doc: &LoroDoc, range: Range<usize>, kind: Kind) {
fn unmark(doc: &LoroDoc, range: Range<usize>, kind: Kind) {
let richtext = doc.get_text("r");
doc.with_txn(|txn| {
richtext.mark_with_txn(
txn,
range.start,
range.end,
kind.key(),
false.into(),
kind.flag().to_delete(),
)
richtext.mark_with_txn(txn, range.start, range.end, kind.key(), false.into(), false)
})
.unwrap();
}
fn mark_kv(doc: &LoroDoc, range: Range<usize>, key: &str, value: impl Into<LoroValue>) {
let richtext = doc.get_text("r");
doc.with_txn(|txn| {
richtext.mark_with_txn(txn, range.start, range.end, key, value.into(), false)
})
.unwrap();
}
@ -185,7 +167,7 @@ fn case3() {
/// | Concurrent B | `Hey World` |
/// | Expected Result | `<link>Hey</link> World` |
#[test]
fn case5() {
fn case4() {
let doc_a = init("Hello World");
let doc_b = clone(&doc_a, 2);
mark(&doc_a, 0..5, Kind::Link);
@ -213,7 +195,7 @@ fn case5() {
/// | Origin | `<b><link>Hello</link><b> World` |
/// | Expected Result | `<b><link>Hello</link>t<b> World` |
#[test]
fn case6() {
fn case5() {
let doc = init("Hello World");
mark(&doc, 0..5, Kind::Bold);
expect_result(
@ -251,7 +233,7 @@ fn case6() {
/// | Concurrent B | `<b>The </b>fox<b> jumped</b> over the dog.` |
/// | Expected Result | `The fox jumped over the dog.` |
#[test]
fn case7() {
fn case6() {
let doc_a = init("The fox jumped over the dog.");
mark(&doc_a, 0..3, Kind::Bold);
let doc_b = clone(&doc_a, 2);
@ -278,7 +260,7 @@ fn case7() {
/// | Concurrent B | `<b>The</b> fox jumped over the <b>dog</b>.` |
/// | Expected Result | `<b>The</b> fox jumped over the <b>dog</b>.` |
#[test]
fn case8() {
fn case7() {
let doc_a = init("The fox jumped over the dog.");
mark(&doc_a, 0..14, Kind::Bold);
let doc_b = clone(&doc_a, 2);
@ -307,7 +289,7 @@ fn case8() {
/// | Concurrent B | The *fox jumped*. |
/// | Expected Result | **The _fox_**<i> jumped</i>. |
#[test]
fn case9() {
fn case8() {
let doc_a = init("The fox jumped.");
let doc_b = clone(&doc_a, 2);
mark(&doc_a, 0..7, Kind::Bold);
@ -326,6 +308,25 @@ fn case9() {
doc_b.check_state_diff_calc_consistency_slow();
}
/// ![](https://i.postimg.cc/MTNGq8cH/Clean-Shot-2023-10-09-at-12-16-29-2x.png)
#[test]
fn case9() {
let doc_a = init("The fox jumped.");
let doc_b = clone(&doc_a, 2);
mark_kv(&doc_a, 0..7, "comment:alice", "alice comment");
mark_kv(&doc_a, 4..14, "comment:bob", "bob comment");
merge(&doc_a, &doc_b);
expect_result(
&doc_a,
serde_json::json!([
{"insert": "The ", "attributes": {"comment:alice": "alice comment"}},
{"insert": "fox", "attributes": {"comment:alice": "alice comment", "comment:bob": "bob comment"}},
{"insert": " jumped", "attributes": {"comment:bob": "bob comment"}},
{"insert": "."}
]),
);
}
#[test]
fn insert_after_link() {
let doc_a = init("The fox jumped.");

View file

@ -2,7 +2,6 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex};
use loro_common::{ContainerID, ContainerType, LoroResult, LoroValue, ID};
use loro_internal::{
container::richtext::TextStyleInfoFlag,
handler::{Handler, TextDelta, ValueOrContainer},
version::Frontiers,
ApplyDiff, LoroDoc, ToJson,
@ -14,7 +13,7 @@ fn issue_225() -> LoroResult<()> {
let doc = LoroDoc::new_auto_commit();
let text = doc.get_text("text");
text.insert(0, "123")?;
text.mark(0, 3, "bold", true.into(), TextStyleInfoFlag::BOLD)?;
text.mark(0, 3, "bold", true.into())?;
// when apply_delta, the attributes of insert should override the current styles
text.apply_delta(&[
TextDelta::Retain {
@ -66,12 +65,10 @@ fn mark_with_the_same_key_value_should_be_skipped() {
let a = LoroDoc::new_auto_commit();
let text = a.get_text("text");
text.insert(0, "Hello world!").unwrap();
text.mark(0, 11, "key", "value".into(), TextStyleInfoFlag::BOLD)
.unwrap();
text.mark(0, 11, "bold", "value".into()).unwrap();
a.commit_then_renew();
let v = a.oplog_vv();
text.mark(0, 5, "key", "value".into(), TextStyleInfoFlag::BOLD)
.unwrap();
text.mark(0, 5, "bold", "value".into()).unwrap();
a.commit_then_renew();
let new_v = a.oplog_vv();
// new mark should be ignored, so vv should be the same
@ -139,15 +136,9 @@ fn out_of_bound_test() {
assert!(matches!(err, loro_common::LoroError::OutOfBound { .. }));
let err = a.get_text("text").delete(3, 5).unwrap_err();
assert!(matches!(err, loro_common::LoroError::OutOfBound { .. }));
let err = a
.get_text("text")
.mark(0, 8, "h", 5.into(), TextStyleInfoFlag::BOLD)
.unwrap_err();
let err = a.get_text("text").mark(0, 8, "h", 5.into()).unwrap_err();
assert!(matches!(err, loro_common::LoroError::OutOfBound { .. }));
let _err = a
.get_text("text")
.mark(3, 0, "h", 5.into(), TextStyleInfoFlag::BOLD)
.unwrap_err();
let _err = a.get_text("text").mark(3, 0, "h", 5.into()).unwrap_err();
let err = a.get_list("list").insert(6, "Hello").unwrap_err();
assert!(matches!(err, loro_common::LoroError::OutOfBound { .. }));
let err = a.get_list("list").delete(3, 2).unwrap_err();
@ -211,17 +202,9 @@ fn richtext_mark_event() {
}),
);
a.get_text("text").insert(0, "Hello").unwrap();
a.get_text("text").mark(0, 5, "bold", true.into()).unwrap();
a.get_text("text")
.mark(0, 5, "bold", true.into(), TextStyleInfoFlag::BOLD)
.unwrap();
a.get_text("text")
.mark(
2,
4,
"bold",
LoroValue::Null,
TextStyleInfoFlag::BOLD.to_delete(),
)
.mark(2, 4, "bold", LoroValue::Null)
.unwrap();
a.commit_then_stop();
let b = LoroDoc::new_auto_commit();
@ -250,12 +233,8 @@ fn concurrent_richtext_mark_event() {
a.get_text("text").insert(0, "Hello").unwrap();
b.merge(&a).unwrap();
c.merge(&a).unwrap();
b.get_text("text")
.mark(0, 3, "bold", true.into(), TextStyleInfoFlag::BOLD)
.unwrap();
c.get_text("text")
.mark(1, 4, "link", true.into(), TextStyleInfoFlag::LINK)
.unwrap();
b.get_text("text").mark(0, 3, "bold", true.into()).unwrap();
c.get_text("text").mark(1, 4, "link", true.into()).unwrap();
b.merge(&c).unwrap();
let sub_id = a.subscribe(
&a.get_text("text").id(),
@ -287,7 +266,7 @@ fn concurrent_richtext_mark_event() {
},
{
"retain": 1,
"attributes": {"bold": null}
"attributes": {"bold": null, "link": true}
}
])
)
@ -295,13 +274,7 @@ fn concurrent_richtext_mark_event() {
);
b.get_text("text")
.mark(
2,
3,
"bold",
LoroValue::Null,
TextStyleInfoFlag::BOLD.to_delete(),
)
.mark(2, 3, "bold", LoroValue::Null)
.unwrap();
a.merge(&b).unwrap();
a.unsubscribe(sub_id);
@ -331,9 +304,7 @@ fn concurrent_richtext_mark_event() {
fn insert_richtext_event() {
let a = LoroDoc::new_auto_commit();
a.get_text("text").insert(0, "Hello").unwrap();
a.get_text("text")
.mark(0, 5, "bold", true.into(), TextStyleInfoFlag::BOLD)
.unwrap();
a.get_text("text").mark(0, 5, "bold", true.into()).unwrap();
a.commit_then_renew();
let text = a.get_text("text");
a.subscribe(

View file

@ -3,10 +3,8 @@ use convert::resolved_diff_to_js;
use js_sys::{Array, Object, Promise, Reflect, Uint8Array};
use loro_internal::{
change::Lamport,
container::{
richtext::{ExpandType, TextStyleInfoFlag},
ContainerID,
},
configure::{StyleConfig, StyleConfigMap},
container::{richtext::ExpandType, ContainerID},
event::{Diff, Index},
handler::{ListHandler, MapHandler, TextDelta, TextHandler, TreeHandler, ValueOrContainer},
id::{Counter, TreeID, ID},
@ -68,9 +66,7 @@ extern "C" {
pub type JsOrigin;
#[wasm_bindgen(typescript_type = "{ peer: PeerID, counter: number }")]
pub type JsID;
#[wasm_bindgen(
typescript_type = "{ start: number, end: number, expand?: 'before'|'after'|'both'|'none' }"
)]
#[wasm_bindgen(typescript_type = "{ start: number, end: number }")]
pub type JsRange;
#[wasm_bindgen(typescript_type = "number|bool|string|null")]
pub type JsMarkValue;
@ -92,6 +88,8 @@ extern "C" {
pub type JsValueOrContainerOrUndefined;
#[wasm_bindgen(typescript_type = "[string, Value | Container]")]
pub type MapEntry;
#[wasm_bindgen(typescript_type = "{[key: string]: { expand: 'before'|'after'|'none'|'both' }}")]
pub type JsTextStyles;
}
mod observer {
@ -232,6 +230,34 @@ impl Loro {
Self(Arc::new(doc))
}
#[wasm_bindgen(js_name = "configTextStyle")]
pub fn config_text_style(&self, styles: JsTextStyles) -> JsResult<()> {
let mut style_config = StyleConfigMap::new();
// read key value pair in styles
for key in Reflect::own_keys(&styles)?.iter() {
let value = Reflect::get(&styles, &key).unwrap();
let key = key.as_string().unwrap();
// Assert value is an object, otherwise throw an error with desc
if !value.is_object() {
return Err("Text style config format error".into());
}
// read expand value from value
let expand = Reflect::get(&value, &"expand".into()).expect("`expand` not specified");
let expand_str = expand.as_string().unwrap();
// read allowOverlap value from value
style_config.insert(
key.into(),
StyleConfig {
expand: ExpandType::try_from_str(&expand_str)
.expect("`expand` must be one of `none`, `start`, `end`, `both`"),
},
);
}
self.0.config_text_style(style_config);
Ok(())
}
/// Get a loro document from the snapshot.
///
/// @see You can check out what is the snapshot [here](#).
@ -1033,7 +1059,6 @@ pub struct LoroText {
struct MarkRange {
start: usize,
end: usize,
expand: Option<String>,
}
#[wasm_bindgen]
@ -1104,20 +1129,7 @@ impl LoroText {
pub fn mark(&self, range: JsRange, key: &str, value: JsValue) -> Result<(), JsError> {
let range: MarkRange = serde_wasm_bindgen::from_value(range.into())?;
let value: LoroValue = LoroValue::from(value);
let expand = range
.expand
.map(|x| {
ExpandType::try_from_str(&x)
.expect_throw("`expand` must be one of `none`, `start`, `end`, `both`")
})
.unwrap_or(ExpandType::After);
self.handler.mark(
range.start,
range.end,
key,
value,
TextStyleInfoFlag::new(true, expand, false, false),
)?;
self.handler.mark(range.start, range.end, key, value)?;
Ok(())
}
@ -1151,21 +1163,7 @@ impl LoroText {
pub fn unmark(&self, range: JsRange, key: &str) -> Result<(), JsValue> {
// Internally, this may be marking with null or deleting all the marks with key in the range entirely.
let range: MarkRange = serde_wasm_bindgen::from_value(range.into())?;
let expand = range
.expand
.map(|x| {
ExpandType::try_from_str(&x)
.expect_throw("`expand` must be one of `none`, `start`, `end`, `both`")
})
.unwrap_or(ExpandType::After);
let expand = expand.reverse();
self.handler.mark(
range.start,
range.end,
key,
LoroValue::Null,
TextStyleInfoFlag::new(true, expand, true, false),
)?;
self.handler.unmark(range.start, range.end, key)?;
Ok(())
}

View file

@ -55,7 +55,7 @@ use serde_json::json;
let doc = LoroDoc::new();
let text = doc.get_text("text");
text.insert(0, "Hello world!").unwrap();
text.mark(0..5, ExpandType::After, "bold", true).unwrap();
text.mark(0..5, "bold", true).unwrap();
assert_eq!(
text.to_delta().to_json_value(),
json!([
@ -63,7 +63,7 @@ assert_eq!(
{ "insert": " world!" },
])
);
text.unmark(3..5, ExpandType::After, "bold").unwrap();
text.unmark(3..5, "bold").unwrap();
assert_eq!(
text.to_delta().to_json_value(),
json!([
@ -88,7 +88,7 @@ doc_b.import(&bytes).unwrap();
assert_eq!(doc.get_deep_value(), doc_b.get_deep_value());
let text_b = doc_b.get_text("text");
text_b
.mark(0..5, loro::ExpandType::After, "bold", true)
.mark(0..5, "bold", true)
.unwrap();
doc.import(&doc_b.export_from(&doc.oplog_vv())).unwrap();
assert_eq!(

View file

@ -1,7 +1,6 @@
#![doc = include_str!("../README.md")]
use either::Either;
use loro_internal::change::Timestamp;
use loro_internal::container::richtext::TextStyleInfoFlag;
use loro_internal::container::IntoContainerId;
use loro_internal::handler::TextDelta;
use loro_internal::handler::ValueOrContainer;
@ -534,17 +533,10 @@ impl LoroText {
pub fn mark(
&self,
range: Range<usize>,
expand: ExpandType,
key: &str,
value: impl Into<LoroValue>,
) -> LoroResult<()> {
self.handler.mark(
range.start,
range.end,
key,
value.into(),
TextStyleInfoFlag::new(true, expand, false, false),
)
self.handler.mark(range.start, range.end, key, value.into())
}
/// Unmark a range of text with a key and a value.
@ -563,15 +555,8 @@ impl LoroText {
/// *You should make sure that a key is always associated with the same expand type.*
///
/// Note: you cannot delete unmergeable annotations like comments by this method.
pub fn unmark(&self, range: Range<usize>, expand: ExpandType, key: &str) -> LoroResult<()> {
let expand = expand.reverse();
self.handler.mark(
range.start,
range.end,
key,
LoroValue::Null,
TextStyleInfoFlag::new(true, expand, true, false),
)
pub fn unmark(&self, range: Range<usize>, key: &str) -> LoroResult<()> {
self.handler.unmark(range.start, range.end, key)
}
/// Get the text in [Delta](https://quilljs.com/docs/delta/) format.
@ -584,7 +569,7 @@ impl LoroText {
/// let doc = LoroDoc::new();
/// let text = doc.get_text("text");
/// text.insert(0, "Hello world!").unwrap();
/// text.mark(0..5, ExpandType::After, "bold", true).unwrap();
/// text.mark(0..5, "bold", true).unwrap();
/// assert_eq!(
/// text.to_delta().to_json_value(),
/// json!([
@ -592,7 +577,7 @@ impl LoroText {
/// { "insert": " world!" },
/// ])
/// );
/// text.unmark(3..5, ExpandType::After, "bold").unwrap();
/// text.unmark(3..5, "bold").unwrap();
/// assert_eq!(
/// text.to_delta().to_json_value(),
/// json!([

View file

@ -90,13 +90,13 @@ fn check_sync_send(_doc: impl Sync + Send) {}
#[test]
fn richtext_test() {
use loro::{ExpandType, LoroDoc, ToJson};
use loro::{LoroDoc, ToJson};
use serde_json::json;
let doc = LoroDoc::new();
let text = doc.get_text("text");
text.insert(0, "Hello world!").unwrap();
text.mark(0..5, ExpandType::After, "bold", true).unwrap();
text.mark(0..5, "bold", true).unwrap();
assert_eq!(
text.to_delta().to_json_value(),
json!([
@ -104,7 +104,7 @@ fn richtext_test() {
{ "insert": " world!" },
])
);
text.unmark(3..5, ExpandType::After, "bold").unwrap();
text.unmark(3..5, "bold").unwrap();
assert_eq!(
text.to_delta().to_json_value(),
json!([
@ -127,9 +127,7 @@ fn sync() {
doc_b.import(&bytes).unwrap();
assert_eq!(doc.get_deep_value(), doc_b.get_deep_value());
let text_b = doc_b.get_text("text");
text_b
.mark(0..5, loro::ExpandType::After, "bold", true)
.unwrap();
text_b.mark(0..5, "bold", true).unwrap();
doc.import(&doc_b.export_from(&doc.oplog_vv())).unwrap();
assert_eq!(
text.to_delta().to_json_value(),

View file

@ -114,11 +114,58 @@ describe("richtext", () => {
text2.applyDelta(e.diff);
});
text.insert(0, "foo");
text.mark({ start: 0, end: 3, expand: "none" }, "link", true);
text.mark({ start: 0, end: 3 }, "link", true);
doc.commit();
text.insert(3, "baz");
doc.commit();
await new Promise((r) => setTimeout(r, 1));
expect(text2.toDelta()).toStrictEqual([{ insert: 'foo', attributes: { link: true } }, { insert: 'baz' }]);
})
it("custom richtext type", async () => {
const doc = new Loro();
doc.configTextStyle({
myStyle: {
expand: "none",
}
})
const text = doc.getText("text");
text.insert(0, "foo");
text.mark({ start: 0, end: 3 }, "myStyle", 123);
expect(text.toDelta()).toStrictEqual([{ insert: 'foo', attributes: { myStyle: 123 } }]);
expect(() => {
text.mark({ start: 0, end: 3 }, "unknownStyle", 2);
}).toThrowError()
expect(() => {
// default style config should be overwritten
text.mark({ start: 0, end: 3 }, "bold", 2);
}).toThrowError()
})
it("allow overlapped styles", () => {
const doc = new Loro();
doc.configTextStyle({
comment: { expand: "none", }
})
const text = doc.getText("text");
text.insert(0, "The fox jumped.");
text.mark({ start: 0, end: 7 }, "comment:alice", "Hi");
text.mark({ start: 4, end: 14 }, "comment:bob", "Jump");
expect(text.toDelta()).toStrictEqual([
{
insert: "The ", attributes: { "comment:alice": "Hi" },
},
{
insert: "fox", attributes: { "comment:alice": "Hi", "comment:bob": "Jump" },
},
{
insert: " jumped", attributes: { "comment:bob": "Jump" },
},
{
insert: ".",
}
])
})
});