prosereflect 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +63 -0
  3. data/.github/workflows/links.yml +97 -0
  4. data/.github/workflows/rake.yml +4 -0
  5. data/.github/workflows/release.yml +5 -0
  6. data/.gitignore +4 -0
  7. data/.rubocop.yml +19 -1
  8. data/.rubocop_todo.yml +119 -183
  9. data/CLAUDE.md +78 -0
  10. data/Gemfile +8 -4
  11. data/README.adoc +2 -0
  12. data/Rakefile +3 -3
  13. data/docs/Gemfile +10 -0
  14. data/docs/INDEX.adoc +45 -0
  15. data/docs/_advanced/index.adoc +15 -0
  16. data/docs/_advanced/schema.adoc +112 -0
  17. data/docs/_advanced/step-map.adoc +66 -0
  18. data/docs/_advanced/steps.adoc +88 -0
  19. data/docs/_advanced/test-builder.adoc +61 -0
  20. data/docs/_advanced/transform.adoc +92 -0
  21. data/docs/_config.yml +174 -0
  22. data/docs/_features/html-input.adoc +69 -0
  23. data/docs/_features/html-output.adoc +45 -0
  24. data/docs/_features/index.adoc +15 -0
  25. data/docs/_features/marks.adoc +86 -0
  26. data/docs/_features/node-types.adoc +124 -0
  27. data/docs/_features/user-mentions.adoc +47 -0
  28. data/docs/_guides/custom-nodes.adoc +107 -0
  29. data/docs/_guides/index.adoc +13 -0
  30. data/docs/_guides/round-trip-html.adoc +91 -0
  31. data/docs/_guides/serialization.adoc +109 -0
  32. data/docs/_pages/index.adoc +67 -0
  33. data/docs/_reference/document-api.adoc +49 -0
  34. data/docs/_reference/index.adoc +14 -0
  35. data/docs/_reference/node-api.adoc +79 -0
  36. data/docs/_reference/schema-api.adoc +95 -0
  37. data/docs/_reference/transform-api.adoc +77 -0
  38. data/docs/_understanding/document-model.adoc +65 -0
  39. data/docs/_understanding/fragment.adoc +52 -0
  40. data/docs/_understanding/index.adoc +14 -0
  41. data/docs/_understanding/resolved-position.adoc +53 -0
  42. data/docs/_understanding/slice.adoc +54 -0
  43. data/docs/lychee.toml +63 -0
  44. data/lib/prosereflect/attribute/base.rb +4 -6
  45. data/lib/prosereflect/attribute/bold.rb +2 -4
  46. data/lib/prosereflect/attribute/href.rb +1 -3
  47. data/lib/prosereflect/attribute/id.rb +7 -7
  48. data/lib/prosereflect/attribute.rb +4 -7
  49. data/lib/prosereflect/blockquote.rb +19 -11
  50. data/lib/prosereflect/bullet_list.rb +36 -29
  51. data/lib/prosereflect/code_block.rb +23 -27
  52. data/lib/prosereflect/code_block_wrapper.rb +12 -13
  53. data/lib/prosereflect/document.rb +14 -22
  54. data/lib/prosereflect/fragment.rb +249 -0
  55. data/lib/prosereflect/hard_break.rb +6 -6
  56. data/lib/prosereflect/heading.rb +14 -15
  57. data/lib/prosereflect/horizontal_rule.rb +23 -14
  58. data/lib/prosereflect/image.rb +32 -23
  59. data/lib/prosereflect/input/html.rb +179 -104
  60. data/lib/prosereflect/input.rb +7 -0
  61. data/lib/prosereflect/list_item.rb +11 -12
  62. data/lib/prosereflect/mark/base.rb +9 -11
  63. data/lib/prosereflect/mark/bold.rb +1 -3
  64. data/lib/prosereflect/mark/code.rb +1 -3
  65. data/lib/prosereflect/mark/italic.rb +1 -3
  66. data/lib/prosereflect/mark/link.rb +1 -3
  67. data/lib/prosereflect/mark/strike.rb +1 -3
  68. data/lib/prosereflect/mark/subscript.rb +1 -3
  69. data/lib/prosereflect/mark/superscript.rb +1 -3
  70. data/lib/prosereflect/mark/underline.rb +1 -3
  71. data/lib/prosereflect/mark.rb +9 -5
  72. data/lib/prosereflect/node.rb +171 -33
  73. data/lib/prosereflect/ordered_list.rb +17 -14
  74. data/lib/prosereflect/output/html.rb +279 -50
  75. data/lib/prosereflect/output.rb +7 -0
  76. data/lib/prosereflect/paragraph.rb +11 -13
  77. data/lib/prosereflect/parser.rb +56 -66
  78. data/lib/prosereflect/resolved_pos.rb +256 -0
  79. data/lib/prosereflect/schema/attribute.rb +57 -0
  80. data/lib/prosereflect/schema/content_match.rb +656 -0
  81. data/lib/prosereflect/schema/fragment.rb +166 -0
  82. data/lib/prosereflect/schema/mark.rb +121 -0
  83. data/lib/prosereflect/schema/mark_type.rb +130 -0
  84. data/lib/prosereflect/schema/node.rb +236 -0
  85. data/lib/prosereflect/schema/node_type.rb +274 -0
  86. data/lib/prosereflect/schema/schema_main.rb +190 -0
  87. data/lib/prosereflect/schema/spec.rb +92 -0
  88. data/lib/prosereflect/schema.rb +39 -0
  89. data/lib/prosereflect/table.rb +12 -13
  90. data/lib/prosereflect/table_cell.rb +13 -13
  91. data/lib/prosereflect/table_header.rb +17 -17
  92. data/lib/prosereflect/table_row.rb +12 -12
  93. data/lib/prosereflect/text.rb +35 -11
  94. data/lib/prosereflect/transform/attr_step.rb +157 -0
  95. data/lib/prosereflect/transform/insert_step.rb +115 -0
  96. data/lib/prosereflect/transform/mapping.rb +82 -0
  97. data/lib/prosereflect/transform/mark_step.rb +269 -0
  98. data/lib/prosereflect/transform/replace_around_step.rb +181 -0
  99. data/lib/prosereflect/transform/replace_step.rb +157 -0
  100. data/lib/prosereflect/transform/slice.rb +91 -0
  101. data/lib/prosereflect/transform/step.rb +89 -0
  102. data/lib/prosereflect/transform/step_map.rb +126 -0
  103. data/lib/prosereflect/transform/structure.rb +120 -0
  104. data/lib/prosereflect/transform/transform.rb +341 -0
  105. data/lib/prosereflect/transform.rb +26 -0
  106. data/lib/prosereflect/user.rb +15 -15
  107. data/lib/prosereflect/version.rb +1 -1
  108. data/lib/prosereflect.rb +30 -17
  109. data/prosereflect.gemspec +17 -16
  110. data/spec/fixtures/documents/formatted_text.yaml +14 -0
  111. data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
  112. data/spec/fixtures/documents/lists_doc.yaml +32 -0
  113. data/spec/fixtures/documents/mixed_content.yaml +40 -0
  114. data/spec/fixtures/documents/nested_doc.yaml +20 -0
  115. data/spec/fixtures/documents/simple_doc.yaml +6 -0
  116. data/spec/fixtures/documents/table_doc.yaml +32 -0
  117. data/spec/fixtures/documents/transform_test.yaml +14 -0
  118. data/spec/fixtures/schema/custom_schema.rb +37 -0
  119. data/spec/fixtures/schema/test_schema.rb +46 -0
  120. data/spec/fixtures/test_builder/helpers.rb +212 -0
  121. data/spec/prosereflect/document_spec.rb +332 -330
  122. data/spec/prosereflect/fragment_spec.rb +273 -0
  123. data/spec/prosereflect/hard_break_spec.rb +125 -125
  124. data/spec/prosereflect/input/html_spec.rb +718 -522
  125. data/spec/prosereflect/node_spec.rb +311 -182
  126. data/spec/prosereflect/output/html_spec.rb +105 -105
  127. data/spec/prosereflect/output/whitespace_spec.rb +248 -0
  128. data/spec/prosereflect/paragraph_spec.rb +275 -274
  129. data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
  130. data/spec/prosereflect/parser_spec.rb +185 -180
  131. data/spec/prosereflect/resolved_pos_spec.rb +74 -0
  132. data/spec/prosereflect/schema/conftest.rb +68 -0
  133. data/spec/prosereflect/schema/content_match_spec.rb +237 -0
  134. data/spec/prosereflect/schema/mark_spec.rb +274 -0
  135. data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
  136. data/spec/prosereflect/schema/node_type_spec.rb +142 -0
  137. data/spec/prosereflect/schema/schema_spec.rb +194 -0
  138. data/spec/prosereflect/table_cell_spec.rb +183 -183
  139. data/spec/prosereflect/table_row_spec.rb +149 -149
  140. data/spec/prosereflect/table_spec.rb +320 -318
  141. data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
  142. data/spec/prosereflect/text_spec.rb +133 -132
  143. data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
  144. data/spec/prosereflect/transform/mapping_spec.rb +226 -0
  145. data/spec/prosereflect/transform/replace_spec.rb +832 -0
  146. data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
  147. data/spec/prosereflect/transform/slice_spec.rb +48 -0
  148. data/spec/prosereflect/transform/step_map_spec.rb +70 -0
  149. data/spec/prosereflect/transform/step_spec.rb +211 -0
  150. data/spec/prosereflect/transform/structure_spec.rb +98 -0
  151. data/spec/prosereflect/transform/transform_spec.rb +238 -0
  152. data/spec/prosereflect/user_spec.rb +31 -28
  153. data/spec/prosereflect_spec.rb +28 -26
  154. data/spec/spec_helper.rb +7 -6
  155. data/spec/support/matchers.rb +6 -6
  156. data/spec/support/shared_examples.rb +49 -49
  157. metadata +96 -5
  158. data/spec/prosereflect/version_spec.rb +0 -11
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "step_map"
4
+ require_relative "mapping"
5
+
6
+ module Prosereflect
7
+ module Transform
8
+ # Base class for all document transformations.
9
+ # A step represents an atomic document change.
10
+ class Step
11
+ # Apply this step to a document
12
+ # Returns a Result with the new document or an error
13
+ def apply(_doc)
14
+ raise NotImplementedError, "#{self.class} must implement #apply"
15
+ end
16
+
17
+ # Get the step map for position tracking
18
+ def get_map
19
+ raise NotImplementedError, "#{self.class} must implement #get_map"
20
+ end
21
+
22
+ # Merge this step with another if possible
23
+ # Returns a new step or nil if not mergeable
24
+ def merge(_other)
25
+ nil
26
+ end
27
+
28
+ # Return an inverted step that undoes this one
29
+ # Takes the document as input to compute the inverse
30
+ def invert(_doc)
31
+ raise NotImplementedError, "#{self.class} must implement #invert"
32
+ end
33
+
34
+ # Get a JSON representation
35
+ def to_json(*_args)
36
+ {
37
+ "stepType" => step_type,
38
+ "pos" => pos,
39
+ "to" => to,
40
+ }.compact
41
+ end
42
+
43
+ # Create a step from JSON
44
+ def self.from_json(_schema, _json)
45
+ raise NotImplementedError, "#{self.class} must implement #from_json"
46
+ end
47
+
48
+ # The type name of this step
49
+ def step_type
50
+ raise NotImplementedError, "#{self.class} must implement #step_type"
51
+ end
52
+
53
+ # Position where this step applies
54
+ def pos
55
+ 0
56
+ end
57
+
58
+ # End position (for range steps)
59
+ def to
60
+ pos
61
+ end
62
+
63
+ # Result of applying a step
64
+ class Result
65
+ attr_reader :doc, :failed
66
+
67
+ def initialize(doc: nil, failed: nil)
68
+ @doc = doc
69
+ @failed = failed
70
+ end
71
+
72
+ # Check if the step was successfully applied
73
+ def ok?
74
+ !@failed && @doc
75
+ end
76
+
77
+ # Create a successful result
78
+ def self.ok(doc)
79
+ new(doc: doc)
80
+ end
81
+
82
+ # Create a failed result
83
+ def self.fail(reason)
84
+ new(failed: reason)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Transform
5
+ # Maps positions through a step.
6
+ # Represents how positions change when a step is applied.
7
+ class StepMap
8
+ attr_reader :ranges # Array of [old_start, old_end, new_start, new_end]
9
+
10
+ def initialize(ranges = [])
11
+ @ranges = ranges
12
+ end
13
+
14
+ # Map a position through this step map
15
+ # Returns the new position
16
+ def map(pos)
17
+ offset = 0
18
+ @ranges.each do |old_start, old_end, new_start, new_end|
19
+ if pos <= old_start
20
+ return pos + (new_start - old_start)
21
+ elsif pos < old_end
22
+ return new_start + (pos - old_start)
23
+ elsif pos >= old_end
24
+ offset += (new_end - old_end)
25
+ end
26
+ end
27
+ pos + offset
28
+ end
29
+
30
+ # Map a position, returning result with deletion information
31
+ def map_result(pos, on_del: nil) # rubocop:disable Lint:UnusedMethodArgument
32
+ new_pos = map(pos)
33
+ deleted = deleted?(pos)
34
+ Result.new(pos: new_pos, deleted: deleted, transformed: new_pos != pos)
35
+ end
36
+
37
+ # Check if a position was deleted by this step
38
+ def deleted?(pos)
39
+ @ranges.any? do |old_start, old_end, _new_start, _new_end|
40
+ pos >= old_start && pos < old_end
41
+ end
42
+ end
43
+
44
+ # Add another map to this one (composition)
45
+ def add_map(other)
46
+ return StepMap.new(other.ranges.dup) if @ranges.empty?
47
+ return StepMap.new(@ranges.dup) if other.ranges.empty?
48
+
49
+ StepMap.new(merge_ranges_arrays(@ranges.dup, other.ranges.dup))
50
+ end
51
+
52
+ def merge_ranges_arrays(ranges1, ranges2)
53
+ return ranges1 if ranges2.empty?
54
+ return ranges2 if ranges1.empty?
55
+
56
+ head1 = ranges1.first
57
+ head2 = ranges2.first
58
+ merged_head = compute_merged_head(head1, head2, ranges1, ranges2)
59
+ tail = compute_tail(head1, head2, ranges1, ranges2)
60
+ [merged_head] + tail
61
+ end
62
+
63
+ def compute_merged_head(head1, head2, _ranges1, _ranges2)
64
+ if head1[1] <= head2[0]
65
+ head1
66
+ elsif head2[1] <= head1[0]
67
+ head2
68
+ else
69
+ merge_ranges(head1, head2)
70
+ end
71
+ end
72
+
73
+ def compute_tail(head1, head2, ranges1, ranges2)
74
+ if head1[1] <= head2[0]
75
+ merge_ranges_arrays(ranges1[1..], ranges2)
76
+ elsif head2[1] <= head1[0]
77
+ merge_ranges_arrays(ranges1, ranges2[1..])
78
+ else
79
+ merge_ranges_arrays(ranges1[1..], ranges2[1..])
80
+ end
81
+ end
82
+
83
+ # Create an empty step map
84
+ def self.empty
85
+ new
86
+ end
87
+
88
+ # Create a step map for a single deletion
89
+ def self.delete(from, to)
90
+ new([[from, to, from, from]])
91
+ end
92
+
93
+ # Create a step map for a single replacement
94
+ def self.replace(from, to, target_from, target_to)
95
+ delta = target_to - target_from
96
+ new([[from, to, target_from, target_from + delta]])
97
+ end
98
+
99
+ def to_s
100
+ "<StepMap #{@ranges.inspect}>"
101
+ end
102
+
103
+ def inspect
104
+ to_s
105
+ end
106
+
107
+ # Result of mapping a position
108
+ Result = Struct.new(:pos, :deleted, :transformed, keyword_init: true) do
109
+ def initialize(pos: 0, deleted: false, transformed: false)
110
+ super
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def merge_ranges(range1, range2)
117
+ [
118
+ [range1[0], range2[0]].min,
119
+ [range1[1], range2[1]].max,
120
+ [range1[2], range2[2]].min,
121
+ [range1[3], range2[3]].max,
122
+ ]
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "step"
4
+ require_relative "replace_step"
5
+
6
+ module Prosereflect
7
+ module Transform
8
+ # Structure predicates and helpers for document manipulation
9
+ class Structure
10
+ class << self
11
+ # Check if we can split at a position
12
+ # Returns true if the position allows a split (e.g., inside a text node)
13
+ def can_split?(doc, pos, types = nil)
14
+ return false if pos.negative? || pos > doc.node_size
15
+
16
+ resolved = doc.resolve(pos)
17
+ parent = resolved.parent
18
+
19
+ return false unless parent.respond_to?(:content)
20
+
21
+ node_type = parent.respond_to?(:type) ? parent.type : nil
22
+
23
+ if types
24
+ types.all? { |t| t.respond_to?(:name) ? t.name == node_type : t == node_type }
25
+ else
26
+ true
27
+ end
28
+ end
29
+
30
+ # Find the target depth for lifting content out of a wrapper
31
+ # Returns the depth to which content should be lifted
32
+ def lift_target(fragment, from, to)
33
+ depth = 0
34
+ pos = 0
35
+
36
+ fragment.content.each do |node|
37
+ node_end = pos + node.node_size
38
+ break if pos >= to
39
+
40
+ # This node is within the range being lifted
41
+ if pos < to && node_end > from && node.respond_to?(:defining?) && node.defining?
42
+ depth += 1
43
+ end
44
+
45
+ pos = node_end
46
+ end
47
+
48
+ depth
49
+ end
50
+
51
+ # Find wrapper nodes needed to wrap a range
52
+ # Returns array of node types that would wrap the range
53
+ def find_wrapping(fragment, _from, to, node_type, attrs = nil)
54
+ wrappers = []
55
+ current_depth = 0
56
+ pos = 0
57
+
58
+ fragment.content.each do |node|
59
+ node_end = pos + node.node_size
60
+ break if pos >= to
61
+
62
+ if node.respond_to?(:defining?) && node.defining? && current_depth.zero?
63
+ # Found a defining node at the boundary
64
+ wrappers << build_wrapper(node_type, attrs)
65
+ end
66
+
67
+ pos = node_end
68
+ end
69
+
70
+ wrappers
71
+ end
72
+
73
+ # Check if nodes can be joined at a position
74
+ def can_join?(doc, pos)
75
+ return false if pos <= 0 || pos >= doc.node_size
76
+
77
+ resolved = doc.resolve(pos)
78
+
79
+ # We need to be at a boundary between two children
80
+ # Check the node at the depth where we're at a child boundary
81
+ depth = resolved.depth
82
+ return false unless depth.positive?
83
+
84
+ parent = resolved.node(depth - 1)
85
+ return false unless parent.respond_to?(:content)
86
+ return false if parent.content.nil?
87
+
88
+ index = resolved.index(depth)
89
+ return false unless index.positive? && index < parent.content.size
90
+
91
+ prev_node = parent.content[index - 1]
92
+ next_node = parent.content[index]
93
+
94
+ prev_node.type == next_node.type
95
+ end
96
+
97
+ # Find positions where a join can happen
98
+ def join_point?(doc, pos)
99
+ return false unless can_join?(doc, pos)
100
+
101
+ pos
102
+ end
103
+
104
+ private
105
+
106
+ def build_wrapper(node_type, attrs)
107
+ if node_type.is_a?(String)
108
+ Prosereflect::Node.from_h(
109
+ "type" => node_type,
110
+ "attrs" => attrs || {},
111
+ "content" => [],
112
+ )
113
+ else
114
+ node_type
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "step"
4
+ require_relative "step_map"
5
+ require_relative "mapping"
6
+ require_relative "slice"
7
+ require_relative "replace_step"
8
+ require_relative "replace_around_step"
9
+ require_relative "mark_step"
10
+ require_relative "attr_step"
11
+ require_relative "insert_step"
12
+
13
+ module Prosereflect
14
+ module Transform
15
+ # A chainable document transformation.
16
+ # Accumulates steps and their mappings.
17
+ class Transform
18
+ attr_reader :steps, :mapping
19
+
20
+ def initialize(doc)
21
+ @doc = doc
22
+ @steps = []
23
+ @mapping = Mapping.new
24
+ end
25
+
26
+ # Add a mark to all content in range
27
+ def add_mark(from, to, mark)
28
+ add_step(AddMarkStep.new(from, to, mark))
29
+ end
30
+
31
+ # Remove a mark from all content in range
32
+ def remove_mark(from, to, mark)
33
+ add_step(RemoveMarkStep.new(from, to, mark))
34
+ end
35
+
36
+ # Insert content at position
37
+ def insert(pos, content)
38
+ add_step(InsertStep.new(pos, content))
39
+ end
40
+
41
+ # Delete content in range
42
+ def delete(from, to)
43
+ add_step(DeleteStep.new(from, to))
44
+ end
45
+
46
+ # Replace content in range with slice
47
+ def replace(from, to, slice = Slice.empty)
48
+ add_step(ReplaceStep.new(from, to, slice))
49
+ end
50
+
51
+ # Replace content with specific nodes
52
+ def replace_with(from, to, *nodes)
53
+ content = Fragment.new(nodes.flatten)
54
+ slice = Slice.new(content)
55
+ add_step(ReplaceStep.new(from, to, slice))
56
+ end
57
+
58
+ # Set attribute on node at position
59
+ def set_node_attribute(pos, attrs)
60
+ add_step(AttrStep.new(pos, attrs))
61
+ end
62
+
63
+ # Set document attribute
64
+ def set_doc_attribute(attrs)
65
+ add_step(DocAttrStep.new(attrs))
66
+ end
67
+
68
+ # Apply all accumulated steps to the document
69
+ # Returns self for chaining
70
+ def apply
71
+ @steps.each do |step|
72
+ result = step.apply(@doc)
73
+ raise ApplyError, "Step #{step.class} failed: #{result.failed}" unless result.ok?
74
+
75
+ @doc = result.doc
76
+ end
77
+ self
78
+ end
79
+
80
+ # Apply and return the transformed document
81
+ def doc
82
+ # Apply pending steps
83
+ apply if @steps.any?
84
+ @doc
85
+ end
86
+
87
+ # Check if any steps have been applied
88
+ def empty?
89
+ @steps.empty?
90
+ end
91
+
92
+ # Get the number of steps
93
+ def size
94
+ @steps.length
95
+ end
96
+
97
+ # Add a step and track its mapping
98
+ def add_step(step)
99
+ @steps << step
100
+ @mapping.add_map(step.get_map)
101
+ self
102
+ end
103
+
104
+ # Get the mapping for all applied steps
105
+ def maps
106
+ @mapping.to_a
107
+ end
108
+
109
+ # Create a new transform with the same document
110
+ def clone
111
+ Transform.new(@doc)
112
+ end
113
+
114
+ # Roll back the last step
115
+ def rollback
116
+ return self if @steps.empty?
117
+
118
+ step = @steps.pop
119
+ @mapping = Mapping.new(maps: @mapping.to_a[0...-1])
120
+
121
+ inverted = step.invert(@doc)
122
+ result = inverted.apply(@doc)
123
+ if result.ok?
124
+ @doc = result.doc
125
+ end
126
+
127
+ self
128
+ end
129
+
130
+ # Check if we can step forward
131
+ def can_apply?
132
+ @steps.all? { |step| step.apply(@doc).ok? }
133
+ end
134
+
135
+ # Add mark using schema
136
+ def add_mark_by_type(from, to, mark_type_name, schema, attrs = nil)
137
+ mark_type = schema.mark_type(mark_type_name)
138
+ mark = mark_type.create(attrs)
139
+ add_mark(from, to, mark)
140
+ end
141
+
142
+ # Remove mark using schema
143
+ def remove_mark_by_type(from, to, mark_type_name, schema)
144
+ mark_type = schema.mark_type(mark_type_name)
145
+ mark = mark_type.create
146
+ remove_mark(from, to, mark)
147
+ end
148
+
149
+ # Lift content out of a wrapper node
150
+ # range: NodeRange representing the content to lift
151
+ # target: depth to lift to
152
+ def lift(range, target)
153
+ from_ = range.from
154
+ to_ = range.to
155
+ depth = range.depth
156
+
157
+ gap_start = position_before_depth(from_, depth + 1)
158
+ gap_end = position_after_depth(to_, depth + 1)
159
+ start = gap_start
160
+ end_pos = gap_end
161
+
162
+ before = Fragment.empty
163
+ open_start = 0
164
+ d = depth
165
+ splitting = false
166
+ while d > target
167
+ if splitting || node_index_at_depth(from_, d).positive?
168
+ splitting = true
169
+ before = Fragment.from(node_at_depth(from_, d).copy(before))
170
+ open_start += 1
171
+ else
172
+ start -= 1
173
+ end
174
+ d -= 1
175
+ end
176
+
177
+ after = Fragment.empty
178
+ open_end = 0
179
+ d = depth
180
+ splitting = false
181
+ while d > target
182
+ if splitting || position_after_depth(to_, d + 1) < node_end_at_depth(to_, d)
183
+ splitting = true
184
+ after = Fragment.from(node_at_depth(to_, d).copy(after))
185
+ open_end += 1
186
+ else
187
+ end_pos += 1
188
+ end
189
+ d -= 1
190
+ end
191
+
192
+ replace_around_step(
193
+ start,
194
+ end_pos,
195
+ gap_start,
196
+ gap_end,
197
+ Slice.new(before.append(after), open_start, open_end),
198
+ before.size - open_start,
199
+ true,
200
+ )
201
+ self
202
+ end
203
+
204
+ # Wrap content in nodes
205
+ # range: NodeRange representing the content to wrap
206
+ # wrappers: array of NodeTypeWithAttrs representing wrapper nodes
207
+ def wrap(range, wrappers)
208
+ content = Fragment.empty
209
+ i = wrappers.length - 1
210
+ while i >= 0
211
+ if content.size.positive?
212
+ match = wrappers[i].type.content_match.match_fragment(content)
213
+ unless match&.valid_end
214
+ raise TransformError, "Wrapper type given to Transform.wrap does not form valid content of its parent wrapper"
215
+ end
216
+ end
217
+ content = Fragment.from(
218
+ wrappers[i].type.create(wrappers[i].attrs, content),
219
+ )
220
+ i -= 1
221
+ end
222
+
223
+ start = range.start
224
+ end_pos = range.end
225
+ replace_around_step(
226
+ start,
227
+ end_pos,
228
+ start,
229
+ end_pos,
230
+ Slice.new(content, 0, 0),
231
+ wrappers.length,
232
+ true,
233
+ )
234
+ self
235
+ end
236
+
237
+ # Split a node at a position
238
+ def split(pos, depth = 1)
239
+ resolved = @doc.resolve(pos)
240
+ before = Fragment.empty
241
+ open_start = 0
242
+ open_end = 0
243
+
244
+ d = depth
245
+ while d.positive?
246
+ node = resolved.node(d)
247
+ if d == depth
248
+ before = Fragment.from(node.copy(Fragment.empty))
249
+ open_start = node.is_a?(Prosereflect::Node) && node.content&.size.to_i.positive? ? 1 : 0
250
+ open_end = 0
251
+ else
252
+ before = Fragment.from(node.copy(before))
253
+ open_start += 1
254
+ end
255
+ d -= 1
256
+ end
257
+
258
+ step = ReplaceAroundStep.new(
259
+ pos,
260
+ pos,
261
+ pos,
262
+ pos,
263
+ Slice.new(before, open_start, open_end),
264
+ open_start,
265
+ structure: false,
266
+ )
267
+ add_step(step)
268
+ self
269
+ end
270
+
271
+ # Join two nodes at a position
272
+ def join(pos, depth = 1)
273
+ resolved = @doc.resolve(pos)
274
+ d = depth
275
+ while d.positive?
276
+ # Check if we can join at this depth
277
+ parent = resolved.node(d - 1) if d >= 1
278
+ idx = resolved.index(d)
279
+
280
+ if parent&.content && idx.positive? && idx < parent.content.size
281
+ before_node = parent.content[idx - 1]
282
+ after_node = parent.content[idx]
283
+
284
+ if before_node.type == after_node.type
285
+ # Join point is at the boundary between before_node and after_node
286
+ join_from = resolved.start(d) - before_node.node_size
287
+ join_to = resolved.start(d) + after_node.node_size
288
+ replace(join_from, join_to, Slice.empty)
289
+ end
290
+ end
291
+ d -= 1
292
+ end
293
+ self
294
+ end
295
+
296
+ class ApplyError < StandardError; end
297
+ class TransformError < StandardError; end
298
+
299
+ def to_s
300
+ "<Transform steps=#{@steps.length}>"
301
+ end
302
+
303
+ def inspect
304
+ to_s
305
+ end
306
+
307
+ private
308
+
309
+ def replace_around_step(from, to, gap_from, gap_to, slice, insert, structure)
310
+ add_step(ReplaceAroundStep.new(from, to, gap_from, gap_to, slice, insert, structure: structure))
311
+ end
312
+
313
+ def position_before_depth(pos, depth)
314
+ resolved = @doc.resolve(pos)
315
+ node = resolved.node(depth)
316
+ pos - (node.is_a?(Prosereflect::Node) ? 1 : 0)
317
+ end
318
+
319
+ def position_after_depth(pos, depth)
320
+ resolved = @doc.resolve(pos)
321
+ node = resolved.node(depth)
322
+ pos + (node.is_a?(Prosereflect::Node) ? 1 : 0)
323
+ end
324
+
325
+ def node_index_at_depth(pos, depth)
326
+ resolved = @doc.resolve(pos)
327
+ resolved.index(depth)
328
+ end
329
+
330
+ def node_at_depth(pos, depth)
331
+ resolved = @doc.resolve(pos)
332
+ resolved.node(depth)
333
+ end
334
+
335
+ def node_end_at_depth(pos, depth)
336
+ resolved = @doc.resolve(pos)
337
+ resolved.end(depth)
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transform/step_map"
4
+ require_relative "transform/mapping"
5
+ require_relative "transform/slice"
6
+ require_relative "transform/step"
7
+ require_relative "transform/replace_step"
8
+ require_relative "transform/mark_step"
9
+ require_relative "transform/attr_step"
10
+ require_relative "transform/insert_step"
11
+ require_relative "transform/structure"
12
+ require_relative "transform/transform"
13
+
14
+ module Prosereflect
15
+ # Transform system for chainable document transformations
16
+ # Based on ProseMirror's transform/step system
17
+ module Transform
18
+ class Error < StandardError; end
19
+
20
+ # Raised when a step cannot be applied to a document
21
+ class ApplyError < Error; end
22
+
23
+ # Raised when a step cannot be inverted
24
+ class InvertError < Error; end
25
+ end
26
+ end