Skip to content

Commit

Permalink
Support multi-level and partial element selection (yorkie-team#624)
Browse files Browse the repository at this point in the history
Support the below features by introducing tree traversal to Tree.Edit.
- Select multiple nodes in a multi-level range.
- Select a part of nodes(e.g. only the closing tag of the node).
  • Loading branch information
sejongk authored and Wu22e committed Sep 3, 2023
1 parent dfffb2e commit 2b8a7a6
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 149 deletions.
150 changes: 67 additions & 83 deletions pkg/document/crdt/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,9 @@ func (n *TreeNode) SplitElement(offset int) (*TreeNode, error) {
}

// remove marks the node as removed.
func (n *TreeNode) remove(removedAt *time.Ticket, latestCreatedAt *time.Ticket) bool {
func (n *TreeNode) remove(removedAt *time.Ticket) bool {
justRemoved := n.RemovedAt == nil
if !n.ID.CreatedAt.After(latestCreatedAt) &&
(n.RemovedAt == nil || n.RemovedAt.Compare(removedAt) > 0) {
if n.RemovedAt == nil || n.RemovedAt.Compare(removedAt) > 0 {
n.RemovedAt = removedAt
if justRemoved {
n.IndexTreeNode.UpdateAncestorsSize()
Expand All @@ -299,6 +298,14 @@ func (n *TreeNode) remove(removedAt *time.Ticket, latestCreatedAt *time.Ticket)
return false
}

func (n *TreeNode) canDelete(removedAt *time.Ticket, latestCreatedAt *time.Ticket) bool {
if !n.ID.CreatedAt.After(latestCreatedAt) &&
(n.RemovedAt == nil || n.RemovedAt.Compare(removedAt) > 0) {
return true
}
return false
}

// InsertAt inserts the given node at the given offset.
func (n *TreeNode) InsertAt(newNode *TreeNode, offset int) error {
return n.IndexTreeNode.InsertAt(newNode.IndexTreeNode, offset)
Expand Down Expand Up @@ -570,35 +577,27 @@ func (t *Tree) Edit(from, to *TreePos,
editedAt *time.Ticket,
) (map[string]*time.Ticket, error) {
// 01. split text nodes at the given range if needed.
fromParent, fromLeft, err := t.findTreeNodesWithSplitText(from, editedAt)
fromParent, fromLeft, err := t.FindTreeNodesWithSplitText(from, editedAt)
if err != nil {
return nil, err
}
_, toLeft, err := t.findTreeNodesWithSplitText(to, editedAt)
toParent, toLeft, err := t.FindTreeNodesWithSplitText(to, editedAt)
if err != nil {
return nil, err
}

// 02. remove the nodes and update linked list and index tree.
// 02. remove the nodes and update index tree.
createdAtMapByActor := make(map[string]*time.Ticket)
var toBeRemoved []*TreeNode

err = t.traverseInPosRange(fromParent.Value, fromLeft.Value, toParent.Value, toLeft.Value,
func(node *TreeNode, contain index.TagContained) {
// If node is a element node and half-contained in the range,
// it should not be removed.
if !node.IsText() && contain != index.AllContained {
return
}

if fromLeft != toLeft {
var fromChildIndex int
var parent *index.Node[*TreeNode]

if fromLeft.Parent == toLeft.Parent {
parent = fromParent
fromChildIndex = parent.OffsetOfChild(fromLeft) + 1
} else {
parent = fromLeft
fromChildIndex = 0
}

toChildIndex := parent.OffsetOfChild(toLeft)

parentChildren := parent.Children(true)
for i := fromChildIndex; i <= toChildIndex; i++ {
node := parentChildren[i].Value
actorIDHex := node.ID.CreatedAt.ActorIDHex()

var latestCreatedAt *time.Ticket
Expand All @@ -613,29 +612,23 @@ func (t *Tree) Edit(from, to *TreePos,
}
}

if node.remove(editedAt, latestCreatedAt) {
if node.canDelete(editedAt, latestCreatedAt) {
latestCreatedAt = createdAtMapByActor[actorIDHex]
createdAt := node.ID.CreatedAt
if latestCreatedAt == nil || createdAt.After(latestCreatedAt) {
createdAtMapByActor[actorIDHex] = createdAt
}
toBeRemoved = append(toBeRemoved, node)
}

t.removedNodeMap[node.ID.toIDString()] = node

// traverse the nodes including tombstones
index.TraverseNode(node.IndexTreeNode, func(node *index.Node[*TreeNode], depth int) {
if node.Value.remove(editedAt, time.MaxTicket) {
// TODO(sejongk): Refactor the repeated code.
latestCreatedAt = createdAtMapByActor[actorIDHex]
createdAt := node.Value.ID.CreatedAt
if latestCreatedAt == nil || createdAt.After(latestCreatedAt) {
createdAtMapByActor[actorIDHex] = createdAt
}
})
if err != nil {
return nil, err
}

t.removedNodeMap[node.Value.ID.toIDString()] = node.Value
}
})
}
for _, node := range toBeRemoved {
if node.remove(editedAt) {
t.removedNodeMap[node.ID.toIDString()] = node
}
}

Expand Down Expand Up @@ -665,7 +658,7 @@ func (t *Tree) Edit(from, to *TreePos,
// make new nodes as tombstone immediately
if fromParent.Value.IsRemoved() {
actorIDHex := node.Value.ID.CreatedAt.ActorIDHex()
if node.Value.remove(editedAt, time.MaxTicket) {
if node.Value.remove(editedAt) {
latestCreatedAt := createdAtMapByActor[actorIDHex]
createdAt := node.Value.ID.CreatedAt
if latestCreatedAt == nil || createdAt.After(latestCreatedAt) {
Expand All @@ -682,6 +675,21 @@ func (t *Tree) Edit(from, to *TreePos,
return createdAtMapByActor, nil
}

func (t *Tree) traverseInPosRange(fromParent, fromLeft, toParent, toLeft *TreeNode,
callback func(node *TreeNode, contain index.TagContained),
) error {
fromIdx, err := t.ToIndex(fromParent, fromLeft)
if err != nil {
return err
}
toIdx, err := t.ToIndex(toParent, toLeft)
if err != nil {
return err
}

return t.IndexTree.NodesBetween(fromIdx, toIdx, callback)
}

// StyleByIndex applies the given attributes of the given range.
// This method uses indexes instead of a pair of TreePos for testing.
func (t *Tree) StyleByIndex(start, end int, attributes map[string]string, editedAt *time.Ticket) error {
Expand All @@ -701,58 +709,39 @@ func (t *Tree) StyleByIndex(start, end int, attributes map[string]string, edited
// Style applies the given attributes of the given range.
func (t *Tree) Style(from, to *TreePos, attributes map[string]string, editedAt *time.Ticket) error {
// 01. split text nodes at the given range if needed.
_, fromLeft, err := t.findTreeNodesWithSplitText(from, editedAt)
fromParent, fromLeft, err := t.FindTreeNodesWithSplitText(from, editedAt)
if err != nil {
return err
}
_, toLeft, err := t.findTreeNodesWithSplitText(to, editedAt)
toParent, toLeft, err := t.FindTreeNodesWithSplitText(to, editedAt)
if err != nil {
return err
}

if fromLeft != toLeft {
var fromChildIndex int
var parent *index.Node[*TreeNode]

if fromLeft.Parent == toLeft.Parent {
parent = fromLeft.Parent
fromChildIndex = parent.OffsetOfChild(fromLeft) + 1
} else {
parent = fromLeft
fromChildIndex = 0
}

toChildIndex := parent.OffsetOfChild(toLeft)

// 02. style the nodes.
parentChildren := parent.Children(true)
for i := fromChildIndex; i <= toChildIndex; i++ {
node := parentChildren[i]

if !node.Value.IsRemoved() {
if node.Value.Attrs == nil {
node.Value.Attrs = NewRHT()
err = t.traverseInPosRange(fromParent.Value, fromLeft.Value, toParent.Value, toLeft.Value,
func(node *TreeNode, contain index.TagContained) {
if !node.IsRemoved() && !node.IsText() && len(attributes) > 0 {
if node.Attrs == nil {
node.Attrs = NewRHT()
}

for key, value := range attributes {
node.Value.Attrs.Set(key, value, editedAt)
node.Attrs.Set(key, value, editedAt)
}
}
}
})
if err != nil {
return err
}

return nil
}

/**
* findTreeNodesWithSplitText finds TreeNode of the given crdt.TreePos and
* splits the text node if necessary.
*
* crdt.TreePos is a position in the CRDT perspective. This is different
* from indexTree.TreePos which is a position of the tree in the local perspective.
* TODO(sejongk): clarify the comments
**/
func (t *Tree) findTreeNodesWithSplitText(pos *TreePos, editedAt *time.Ticket) (
// FindTreeNodesWithSplitText finds TreeNode of the given crdt.TreePos and
// splits the text node if necessary.
// crdt.TreePos is a position in the CRDT perspective. This is different
// from indexTree.TreePos which is a position of the tree in the local perspective.
func (t *Tree) FindTreeNodesWithSplitText(pos *TreePos, editedAt *time.Ticket) (
*index.Node[*TreeNode], *index.Node[*TreeNode], error,
) {
parentNode, leftSiblingNode := t.toTreeNodes(pos)
Expand Down Expand Up @@ -800,12 +789,7 @@ func (t *Tree) findTreeNodesWithSplitText(pos *TreePos, editedAt *time.Ticket) (
}

// toTreePos converts the given crdt.TreePos to local index.TreePos<CRDTTreeNode>.
func (t *Tree) toTreePos(pos *TreePos) (*index.TreePos[*TreeNode], error) {
if pos.ParentID == nil || pos.LeftSiblingID == nil {
return nil, nil
}

parentNode, leftSiblingNode := t.toTreeNodes(pos)
func (t *Tree) toTreePos(parentNode, leftSiblingNode *TreeNode) (*index.TreePos[*TreeNode], error) {
if parentNode == nil || leftSiblingNode == nil {
return nil, nil
}
Expand Down Expand Up @@ -865,8 +849,8 @@ func (t *Tree) toTreePos(pos *TreePos) (*index.TreePos[*TreeNode], error) {
}

// ToIndex converts the given CRDTTreePos to the index of the tree.
func (t *Tree) ToIndex(pos *TreePos) (int, error) {
treePos, err := t.toTreePos(pos)
func (t *Tree) ToIndex(parentNode, leftSiblingNode *TreeNode) (int, error) {
treePos, err := t.toTreePos(parentNode, leftSiblingNode)
if treePos == nil {
return -1, nil
}
Expand Down
101 changes: 82 additions & 19 deletions pkg/document/crdt/tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,6 @@ func TestTree(t *testing.T) {
})

t.Run("delete nodes between element nodes test", func(t *testing.T) {
t.Skip("TODO(hackerwins): We need to fix this test.")

// 01. Create a tree with 2 paragraphs.
// 0 1 2 3 4 5 6 7 8
// <root> <p> a b </p> <p> c d </p> </root>
Expand All @@ -251,19 +249,22 @@ func TestTree(t *testing.T) {
// <root> <p> a d </p> </root>
_, err = tree.EditByIndex(2, 6, nil, nil, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, "<root><p>ad</p></root>", tree.ToXML())
assert.Equal(t, "<root><p>a</p><p>d</p></root>", tree.ToXML())

structure := tree.Structure()
assert.Equal(t, 4, structure.Size)
assert.Equal(t, 2, structure.Children[0].Size)
assert.Equal(t, 1, structure.Children[0].Children[0].Size)
assert.Equal(t, 1, structure.Children[0].Children[1].Size)
// TODO(sejongk): Use the below assertions after implementing Tree.Move.
// assert.Equal(t, "<root><p>ad</p></root>", tree.ToXML())

// 03. insert a new text node at the start of the first paragraph.
_, err = tree.EditByIndex(1, 1, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"text", nil, "@")}, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, "<root><p>@ad</p></root>", tree.ToXML())
// structure := tree.Structure()
// assert.Equal(t, 4, structure.Size)
// assert.Equal(t, 2, structure.Children[0].Size)
// assert.Equal(t, 1, structure.Children[0].Children[0].Size)
// assert.Equal(t, 1, structure.Children[0].Children[1].Size)

// // 03. insert a new text node at the start of the first paragraph.
// _, err = tree.EditByIndex(1, 1, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
// "text", nil, "@")}, helper.IssueTime(ctx))
// assert.NoError(t, err)
// assert.Equal(t, "<root><p>@ad</p></root>", tree.ToXML())
})

t.Run("style node with element attributes test", func(t *testing.T) {
Expand All @@ -284,21 +285,32 @@ func TestTree(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "<root><p>ab</p><p>cd</p></root>", tree.ToXML())

// Currently styling attributes to opening tag is only possible.
// TODO(sejongk): We have to let it possible to style attributes to closing tag.
// style attributes with opening tag
err = tree.StyleByIndex(0, 1, map[string]string{"weight": "bold"}, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, `<root><p weight="bold">ab</p><p>cd</p></root>`, tree.ToXML())

// style attributes with closing tag
err = tree.StyleByIndex(3, 4, map[string]string{"color": "red"}, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, `<root><p color="red" weight="bold">ab</p><p>cd</p></root>`, tree.ToXML())

// style attributes with the whole
err = tree.StyleByIndex(0, 4, map[string]string{"size": "small"}, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, `<root><p color="red" size="small" weight="bold">ab</p><p>cd</p></root>`, tree.ToXML())

// 02. style attributes to elements.
err = tree.StyleByIndex(0, 5, map[string]string{"style": "italic"}, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, `<root><p style="italic" weight="bold">ab</p><p style="italic">cd</p></root>`, tree.ToXML())
assert.Equal(t, `<root><p color="red" size="small" style="italic" weight="bold">ab</p>`+
`<p style="italic">cd</p></root>`, tree.ToXML())

// 03. Ignore styling attributes to text nodes.
err = tree.StyleByIndex(1, 3, map[string]string{"bold": "true"}, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, `<root><p style="italic" weight="bold">ab</p><p style="italic">cd</p></root>`, tree.ToXML())
assert.Equal(t, `<root><p color="red" size="small" style="italic" weight="bold">ab</p>`+
`<p style="italic">cd</p></root>`, tree.ToXML())
})

t.Run("can find the closest TreePos when parentNode or leftSiblingNode does not exist", func(t *testing.T) {
Expand Down Expand Up @@ -329,7 +341,10 @@ func TestTree(t *testing.T) {
assert.Equal(t, "<r><p></p></r>", tree.ToXML())

treePos := crdt.NewTreePos(pNode.ID, textNode.ID)
idx, err := tree.ToIndex(treePos)

parent, leftSibling, err := tree.FindTreeNodesWithSplitText(treePos, helper.IssueTime(ctx))
assert.NoError(t, err)
idx, err := tree.ToIndex(parent.Value, leftSibling.Value)
assert.NoError(t, err)
assert.Equal(t, 1, idx)

Expand All @@ -341,8 +356,56 @@ func TestTree(t *testing.T) {
assert.Equal(t, "<r></r>", tree.ToXML())

treePos = crdt.NewTreePos(pNode.ID, textNode.ID)
idx, err = tree.ToIndex(treePos)
parent, leftSibling, err = tree.FindTreeNodesWithSplitText(treePos, helper.IssueTime(ctx))
assert.NoError(t, err)
idx, err = tree.ToIndex(parent.Value, leftSibling.Value)
assert.NoError(t, err)
assert.Equal(t, 0, idx)
})

t.Run("delete nodes in a multi-level range test", func(t *testing.T) {
ctx := helper.TextChangeContext(helper.TestRoot())
tree := crdt.NewTree(crdt.NewTreeNode(helper.IssuePos(ctx), "root", nil), helper.IssueTime(ctx))
_, err := tree.EditByIndex(0, 0, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"p", nil)}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(1, 1, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"text", nil, "ab")}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(3, 3, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"p", nil)}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(4, 4, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"text", nil, "x")}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(7, 7, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"p", nil)}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(8, 8, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"p", nil)}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(9, 9, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"text", nil, "cd")}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(13, 13, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"p", nil)}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(14, 14, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"p", nil)}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(15, 15, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"text", nil, "y")}, helper.IssueTime(ctx))
assert.NoError(t, err)
_, err = tree.EditByIndex(17, 17, nil, []*crdt.TreeNode{crdt.NewTreeNode(helper.IssuePos(ctx),
"text", nil, "ef")}, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, "<root><p>ab<p>x</p></p><p><p>cd</p></p><p><p>y</p>ef</p></root>", tree.ToXML())

_, err = tree.EditByIndex(2, 18, nil, nil, helper.IssueTime(ctx))
assert.NoError(t, err)
assert.Equal(t, "<root><p>a</p><p>f</p></root>", tree.ToXML())

// TODO(sejongk): Use the below assertion after implementing Tree.Move.
// assert.Equal(t, "<root><p>af</p></root>", tree.ToXML())
})
}
Loading

0 comments on commit 2b8a7a6

Please sign in to comment.