Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
130 changes: 128 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,19 @@ 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's characters occupy [start, start + text.length).
Comment thread
HassanAkbar marked this conversation as resolved.
Outdated
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 +303,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 +338,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
Comment thread
HassanAkbar marked this conversation as resolved.

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