Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/prosereflect/hard_break.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ def self.create(marks = nil)
new(marks: marks)
end

def leaf?
true
end

def inline?
true
end

def text_content
"\n"
end
Expand Down
4 changes: 4 additions & 0 deletions lib/prosereflect/horizontal_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def self.create(attrs = nil)
new(attrs: attrs)
end

def leaf?
true
end

def style=(value)
@style = value
self.attrs ||= {}
Expand Down
8 changes: 8 additions & 0 deletions lib/prosereflect/image.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ def self.create(attrs = nil)
new(attrs: attrs)
end

def leaf?
true
end

def inline?
true
end

# Update the image source URL
def src=(src_url)
@src = src_url
Expand Down
137 changes: 135 additions & 2 deletions lib/prosereflect/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,21 @@ def text?
false
end

# Whether this node can never hold content. A leaf occupies a position but
# has no interior, so a range may never resolve inside one.
# Overridden to true in HardBreak, Image, HorizontalRule and User.
def leaf?
false
end

# Whether this node lives inside a block rather than beside one. Note this
# is not the inverse of leaf?: HardBreak and Image are inline leaves, while
# HorizontalRule is a block leaf.
# Overridden to true in Text, HardBreak, Image and User.
def inline?
false
end

# Return a copy of this node with content restricted to the given range.
# Positions are relative to the start of this node's content.
def cut(from = 0, to = nil)
Expand All @@ -235,6 +250,26 @@ def cut(from = 0, to = nil)
end
end

# Replace the range [from, to) with the given nodes, returning a new node.
#
# Positions are local to this node: its own token sits at 0 and its children
# begin at offset 1. An element child's token sits at its start.
#
# A text child needs care, because its characters and its span differ. Its
# characters occupy [start, start + text.length), but its node_size is
# text.length + 1, so it spans [start, start + text.length + 1). The extra
# position is the caret after the final character, at start + text.length;
# the next sibling begins one past that. This gap between "last character"
# and "end of span" is the source of most off-by-one errors in here.
# For "Hello" at start 2: characters 2..6, carets 2..7, next sibling at 8.
def replace(from, to, nodes = [])
index, child_start = child_to_descend_into(from, to, nodes)
return splice(from, to, nodes) unless index

child = content[index].replace(from - child_start, to - child_start, nodes)
copy(content.each_with_index.map { |node, i| i == index ? child : node })
Comment thread
HassanAkbar marked this conversation as resolved.
end

# Iterate over all nodes between two positions in this node.
# Accepts a block or a callable as the third positional argument.
def nodes_between(from, to, callback = nil, node_start = 0, &block)
Expand Down Expand Up @@ -275,8 +310,8 @@ def eq?(other)
end

# Create a copy of this node with different content.
def copy(new_content = nil)
new_node = self.class.new(type: type, attrs: attrs, marks: raw_marks)
def copy(new_content = nil, new_attrs = attrs)
new_node = self.class.new(type: type, attrs: new_attrs, marks: raw_marks)
case new_content
when nil
# no content
Expand Down Expand Up @@ -310,6 +345,104 @@ def node(depth)

private

# Yields each child with its local start and end offsets.
def each_child_span
pos = 1
(content || []).each_with_index do |child, index|
child_end = pos + child.node_size
yield child, index, pos, child_end
pos = child_end
end
end

# [index, start] of the child the range resolves inside, or nil to splice at
# this level. Text and leaf children have no interior to descend into: a
# position just past a leaf's token belongs after it, not inside it.
def child_to_descend_into(from, to, nodes)
each_child_span do |child, index, child_start, child_end|
next if child.text? || child.leaf?
next unless from >= child_start + 1 && to <= child_end
next if from == child_end && !inline_insertion?(from, to, nodes)

return [index, child_start]
end
nil
end

# This model gives a node no closing token, so a position at child_end is
# both the end of that child's content and the start of its next sibling.
# Inline content has nowhere valid to live out here among block siblings,
# so it resolves inward; block content resolves outward as a sibling.
def inline_insertion?(from, to, nodes)
from == to && nodes.any? && nodes.all?(&:inline?)
end

# Rebuild this node's children around the replaced range. Children wholly
# outside the range are carried across untouched; only the trimmed edges and
# the inserted nodes meet, so only they are merged. Coalescing untouched
# siblings would silently change this node's size, and every position after
# it, for an edit that never reached them.
def splice(from, to, nodes)
kept_before = []
kept_after = []
heads = []
tails = []

each_child_span do |child, _index, child_start, child_end|
if child_end <= from
kept_before << child
elsif child_start >= to
kept_after << child
else
heads << head_of(child, from, child_start) if from > child_start
tails << tail_of(child, to, child_start) if to < child_end
end
end

spliced = merge_adjacent_text(heads + nodes.to_a + tails)
copy(kept_before + spliced + kept_after)
Comment thread
HassanAkbar marked this conversation as resolved.
end

# The part of a straddling child that survives before `from`.
def head_of(child, from, child_start)
offset = from - child_start
return child.cut(0, offset) if child.text?

child.replace(offset, child.node_size, [])
end

# The part of a straddling child that survives from `to` onward.
def tail_of(child, to, child_start)
offset = to - child_start
return child.cut(offset) if child.text?

child.replace(1, offset, [])
end

# Drops empty text (a trimmed edge cut to "") and joins same-mark text into
# one node. Applied only to the spliced run -- see splice for why untouched
# children are deliberately left unmerged.
def merge_adjacent_text(nodes)
nodes.reject { |node| node.text? && node.text_content.empty? }
.each_with_object([]) do |node, result|
previous = result.last
if mergeable_text?(previous, node)
result[-1] = previous.class.new(text: previous.text + node.text,
marks: previous.raw_marks)
else
result << node
end
end
end

# Compares serialized marks, since unmarked text is represented as both nil
# and [] and the two must not be treated as different marks.
def mergeable_text?(previous, node)
return false unless previous&.text? && node.text?

(previous.marks || []) == (node.marks || [])
end

def cut_content(from, to)
return [] unless content

Expand Down
4 changes: 4 additions & 0 deletions lib/prosereflect/text.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def text?
true
end

def inline?
true
end

# Return a copy of this text node with content restricted to range
def cut(from = 0, to = nil)
txt = text || ""
Expand Down
2 changes: 1 addition & 1 deletion lib/prosereflect/transform/attr_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def compute_new_attrs(target_node)

def replace_node_with_new_attrs(doc, target_node, new_attrs)
new_content = doc.content.to_a.map { |node| replace_node(node, target_node, new_attrs) }
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
doc.copy(new_content, doc.attrs.dup)
end

def replace_node(node, target_node, new_attrs)
Expand Down
8 changes: 4 additions & 4 deletions lib/prosereflect/transform/mark_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def self.from_json(_schema, json)

def add_mark_to_range(doc)
new_content = doc.content.map { |node| apply_mark_to_node(node) }
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
doc.copy(new_content, doc.attrs.dup)
end

def apply_mark_to_node(node)
Expand All @@ -83,7 +83,7 @@ def apply_mark_to_node(node)

def remove_mark_from_range(doc)
new_content = doc.content.map { |node| remove_mark_from_node_single(node) }
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
doc.copy(new_content, doc.attrs.dup)
end

def remove_mark_from_node_single(node)
Expand Down Expand Up @@ -191,7 +191,7 @@ def self.from_json(_schema, json)

def add_mark_to_node(doc)
new_content = doc.content.map { |node| add_mark_to_single_node(node) }
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
doc.copy(new_content, doc.attrs.dup)
end

def add_mark_to_single_node(node)
Expand Down Expand Up @@ -253,7 +253,7 @@ def self.from_json(_schema, json)

def remove_mark_from_node(doc)
new_content = doc.content.map { |node| remove_mark_from_single_node(node) }
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
doc.copy(new_content, doc.attrs.dup)
end

def remove_mark_from_single_node(node)
Expand Down
3 changes: 1 addition & 2 deletions lib/prosereflect/transform/replace_around_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,7 @@ def content_after(doc, pos)
end

def rebuild_doc(doc, new_content)
attrs = doc.attrs.dup
doc.class.new(content: Fragment.new(new_content), attrs: attrs)
doc.copy(new_content, doc.attrs.dup)
end
end
end
Expand Down
47 changes: 10 additions & 37 deletions lib/prosereflect/transform/replace_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@ def apply(doc)
return Result.fail("Invalid positions") if @from > @to
return Result.fail("from < 0") if @from.negative?
return Result.fail("to > doc size") if @to > doc.node_size
return Result.fail("Slice open boundaries are not supported") if open_boundaries?

# Build the new document
new_doc = apply_replace(doc)
Result.ok(new_doc)
Result.ok(doc.replace(@from, @to, @slice.content.to_a))
rescue StandardError => e
Result.fail(e.message)
end
Expand All @@ -34,8 +33,9 @@ def get_map
end

def invert(doc)
# Find what was removed
removed = content_between(doc, @from, @to)
# Find what was removed. Wrapped in a Slice because a step's slice is a
# Slice, not a Fragment: apply reads open_start/open_end off it.
removed = Slice.new(content_between(doc, @from, @to))
ReplaceStep.new(@from, @from + @slice.size, removed)
end

Expand Down Expand Up @@ -107,30 +107,12 @@ def self.from_json(_schema, json)

private

def apply_replace(doc)
# Get content before, during, and after the replaced range
before = content_before(doc, @from)
after = content_after(doc, @to)
# Open depths describe how the slice's content joins at its boundaries, so
# they are inert when there is no content to join.
def open_boundaries?
return false if @slice.content.empty?

# Build new document
new_content = []
new_content.concat(before) unless before.empty?
new_content.concat(@slice.content.to_a) unless @slice.empty?
new_content.concat(after) unless after.empty?

rebuild_doc(doc, new_content)
end

def content_before(doc, pos)
result = []
doc.nodes_between(0, pos) { |node| result << node }
result
end

def content_after(doc, pos)
result = []
doc.nodes_between(pos, doc.node_size) { |node| result << node }
result
!@slice.open_start.zero? || !@slice.open_end.zero?
end

def content_between(doc, from, to)
Expand All @@ -143,15 +125,6 @@ def join_slices(left, right)
new_content = Fragment.new(left.content.to_a + right.content.to_a)
Slice.new(new_content, left.open_start, right.open_end)
end

def rebuild_doc(doc, new_content)
# Create a new document with the same structure but new content
attrs = doc.attrs.dup
Fragment.new(new_content)
# For simplicity, return a new Document with the new content
# In reality this would preserve the doc type
doc.class.new(content: Fragment.new(new_content), attrs: attrs)
end
end
end
end
8 changes: 8 additions & 0 deletions lib/prosereflect/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ def add_child(*)
raise NotImplementedError, "User mention nodes cannot have children"
end

def leaf?
true
end

def inline?
true
end

def content
[]
end
Expand Down
Loading
Loading