prosemirror 0.1.0 → 0.2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +31 -45
  4. data/ROADMAP.md +10 -6
  5. data/TYPES.md +201 -0
  6. data/lib/prosemirror/content_match.rb +241 -0
  7. data/lib/prosemirror/html/builder.rb +146 -0
  8. data/lib/prosemirror/html/element.rb +84 -0
  9. data/lib/prosemirror/html/entities.rb +87 -0
  10. data/lib/prosemirror/html/entities.txt +2231 -0
  11. data/lib/prosemirror/html/selector.rb +95 -0
  12. data/lib/prosemirror/html/splitter.rb +68 -0
  13. data/lib/prosemirror/html/text.rb +23 -0
  14. data/lib/prosemirror/html_parser.rb +498 -0
  15. data/lib/prosemirror/json_parser.rb +1 -12
  16. data/lib/prosemirror/mark_type.rb +11 -0
  17. data/lib/prosemirror/marks/basic/code.rb +16 -0
  18. data/lib/prosemirror/marks/basic/em.rb +4 -0
  19. data/lib/prosemirror/marks/basic/link.rb +1 -0
  20. data/lib/prosemirror/marks/basic/strong.rb +4 -0
  21. data/lib/prosemirror/marks/bold.rb +8 -0
  22. data/lib/prosemirror/marks/code.rb +5 -0
  23. data/lib/prosemirror/marks/highlight.rb +3 -1
  24. data/lib/prosemirror/marks/italic.rb +4 -0
  25. data/lib/prosemirror/marks/link.rb +1 -0
  26. data/lib/prosemirror/marks/strike.rb +5 -0
  27. data/lib/prosemirror/marks/subscript.rb +2 -0
  28. data/lib/prosemirror/marks/superscript.rb +2 -0
  29. data/lib/prosemirror/marks/underline.rb +3 -0
  30. data/lib/prosemirror/node.rb +12 -0
  31. data/lib/prosemirror/node_type.rb +22 -0
  32. data/lib/prosemirror/nodes/basic/code_block.rb +1 -0
  33. data/lib/prosemirror/nodes/basic/image.rb +1 -0
  34. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -1
  35. data/lib/prosemirror/nodes/blockquote.rb +1 -0
  36. data/lib/prosemirror/nodes/bullet_list.rb +1 -0
  37. data/lib/prosemirror/nodes/code_block.rb +6 -1
  38. data/lib/prosemirror/nodes/hard_break.rb +2 -0
  39. data/lib/prosemirror/nodes/heading.rb +1 -0
  40. data/lib/prosemirror/nodes/horizontal_rule.rb +1 -0
  41. data/lib/prosemirror/nodes/image.rb +1 -0
  42. data/lib/prosemirror/nodes/list_item.rb +1 -0
  43. data/lib/prosemirror/nodes/ordered_list.rb +20 -2
  44. data/lib/prosemirror/nodes/paragraph.rb +1 -0
  45. data/lib/prosemirror/nodes/table.rb +1 -0
  46. data/lib/prosemirror/nodes/table_cell.rb +12 -2
  47. data/lib/prosemirror/nodes/table_header.rb +2 -0
  48. data/lib/prosemirror/nodes/table_row.rb +1 -0
  49. data/lib/prosemirror/parse_rule.rb +50 -0
  50. data/lib/prosemirror/schema.rb +66 -15
  51. data/lib/prosemirror/type_definition.rb +34 -4
  52. data/lib/prosemirror/version.rb +1 -1
  53. data/lib/prosemirror.rb +11 -1
  54. metadata +13 -1
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module HTML
5
+ # The CSS selectors parse rules need, matched against one element: lists of compound selectors,
6
+ # with a type (`p`, `*`), `#id`, `.class`, attribute selectors (`[href]`, `[a=b]`, `[a^=b]`,
7
+ # `[a$=b]`, `[a*=b]`, `[a~=b]`, `[a|=b]`) and `:not(…)`. Combinators aren't supported.
8
+ class Selector
9
+ IDENTIFIER = /-?[_a-zA-Z][-\w]*/
10
+ ATTRIBUTE = /\[\s*(#{IDENTIFIER})\s*(?:([~|^$*]?=)\s*(?:"([^"]*)"|'([^']*)'|(#{IDENTIFIER}))\s*)?\]/
11
+
12
+ def self.parse(source)
13
+ (@cache ||= {})[source] ||= new(source)
14
+ end
15
+
16
+ def initialize(source)
17
+ @source = source
18
+ @compounds = source.split(",").map { |part| parse_compound(part.strip) }
19
+ end
20
+
21
+ def match?(element)
22
+ @compounds.any? { |tests| tests.all? { |test| test.call(element) } }
23
+ end
24
+
25
+ private
26
+
27
+ def parse_compound(part)
28
+ invalid! if part.empty?
29
+
30
+ tests = []
31
+ rest = part
32
+ if (type = rest[/\A(?:\*|#{IDENTIFIER})/o])
33
+ tests << ->(element) { element.name == type.downcase } unless type == "*"
34
+ rest = rest[type.length..]
35
+ end
36
+ until rest.empty?
37
+ test, length = parse_simple(rest)
38
+ tests << test
39
+ rest = rest[length..]
40
+ end
41
+ tests
42
+ end
43
+
44
+ # A simple selector at the start of `rest`, as a test and the length it takes.
45
+ def parse_simple(rest)
46
+ if (match = /\A#(#{IDENTIFIER})/o.match(rest))
47
+ [id_test(match[1]), match[0].length]
48
+ elsif (match = /\A\.(#{IDENTIFIER})/o.match(rest))
49
+ [class_test(match[1]), match[0].length]
50
+ elsif (match = /\A#{ATTRIBUTE}/o.match(rest))
51
+ [attribute_test(*match.captures), match[0].length]
52
+ elsif (match = /\A:not\(([^()]*)\)/.match(rest))
53
+ [not_test(parse_compound(match[1].strip)), match[0].length]
54
+ else
55
+ invalid!
56
+ end
57
+ end
58
+
59
+ def id_test(id)
60
+ ->(element) { element["id"] == id }
61
+ end
62
+
63
+ def class_test(name)
64
+ ->(element) { element.classes.include?(name) }
65
+ end
66
+
67
+ def attribute_test(name, operator, double_quoted, single_quoted, bare)
68
+ name = name.downcase
69
+ expected = double_quoted || single_quoted || bare
70
+ lambda do |element|
71
+ value = element[name]
72
+ next false if value.nil?
73
+
74
+ case operator
75
+ when nil then true
76
+ when "=" then value == expected
77
+ when "^=" then !expected.empty? && value.start_with?(expected)
78
+ when "$=" then !expected.empty? && value.end_with?(expected)
79
+ when "*=" then !expected.empty? && value.include?(expected)
80
+ when "~=" then value.split.include?(expected)
81
+ when "|=" then value == expected || value.start_with?("#{expected}-")
82
+ end
83
+ end
84
+ end
85
+
86
+ def not_test(tests)
87
+ ->(element) { !tests.all? { |test| test.call(element) } }
88
+ end
89
+
90
+ def invalid!
91
+ raise ArgumentError, "unsupported selector #{@source.inspect}: only compound selectors, without combinators"
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Prosemirror
6
+ module HTML
7
+ # Splits HTML into pieces of about `size` bytes, between top-level elements, so Herb can parse
8
+ # them one at a time: its memory grows to hundreds of times the size of the input it parses
9
+ # (https://github.com/marcoroth/herb/issues/2839). Once Herb fixes it, this class can probably be removed.
10
+ #
11
+ # It only cuts where it can follow the structure: when a closing tag doesn't match the last open
12
+ # element, as with unclosed or implicitly closed elements, the rest stays in one piece. HTML with
13
+ # ERB tags isn't split, since Herb groups ERB blocks with the HTML between them.
14
+ class Splitter
15
+ # As Herb's lists.
16
+ VOID_TAGS = %w[
17
+ area base basefont bgsound br col embed frame hr img input keygen link meta param source track wbr
18
+ ].to_set.freeze
19
+ RAW_TEXT_TAGS = %w[script style iframe xmp noembed noframes textarea title].to_set.freeze
20
+
21
+ NAME = /[a-zA-Z][^\s\/>]*/
22
+ # The rest of an opening tag: attributes, with quoted values that can hold `>`.
23
+ TAG_REST = /(?:[^>"']|"[^"]*"|'[^']*')*>/
24
+
25
+ def initialize(size)
26
+ @size = size
27
+ end
28
+
29
+ def call(html)
30
+ return [html] if html.bytesize <= @size || html.include?("<%")
31
+
32
+ pieces = []
33
+ start = 0
34
+ open = []
35
+ scanner = StringScanner.new(html)
36
+ until scanner.eos?
37
+ break unless scanner.skip_until(/</)
38
+
39
+ if scanner.skip(/!--/)
40
+ break unless scanner.skip_until(/-->/)
41
+ elsif scanner.skip(/[!?]/)
42
+ break unless scanner.skip_until(/>/)
43
+ elsif scanner.skip(%r{/(#{NAME})\s*>}o)
44
+ break unless open.pop == scanner[1].downcase
45
+ elsif scanner.skip(/(#{NAME})/o)
46
+ name = scanner[1].downcase
47
+ break unless (rest = scanner.scan(TAG_REST))
48
+
49
+ if RAW_TEXT_TAGS.include?(name)
50
+ break unless scanner.skip_until(%r{</#{Regexp.escape(name)}\s*>}i)
51
+ elsif !VOID_TAGS.include?(name) && !rest.end_with?("/>")
52
+ open << name
53
+ end
54
+ else
55
+ next
56
+ end
57
+
58
+ if open.empty? && scanner.pos - start >= @size
59
+ pieces << html.byteslice(start...scanner.pos)
60
+ start = scanner.pos
61
+ end
62
+ end
63
+ pieces << html.byteslice(start..) if start < html.bytesize
64
+ pieces
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module HTML
5
+ # A text node, with its character references decoded.
6
+ class Text
7
+ attr_reader :value
8
+ attr_accessor :parent
9
+
10
+ def initialize(value)
11
+ @value = value
12
+ end
13
+
14
+ def text_content
15
+ @value
16
+ end
17
+
18
+ def element?
19
+ false
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Builds a document from HTML with the parse rules of the schema's node and mark types, as ProseMirror's
5
+ # DOMParser does, from which this is ported: https://prosemirror.net/docs/ref/#model.DOMParser
6
+ #
7
+ # - whitespace is collapsed, except in nodes whose whitespace is preserved, like code blocks;
8
+ # - content that doesn't fit where it is gets wrapped (text in a list becomes a list item with a
9
+ # paragraph), or moves up to a parent that accepts it;
10
+ # - required content is filled in (an empty list item gets a paragraph);
11
+ # - elements without a rule are unwrapped, and `head`, `noscript`, `object`, `script`, `style` and `title`
12
+ # are ignored.
13
+ class HTMLParser
14
+ BLOCK_TAGS = %w[
15
+ address article aside blockquote canvas dd div dl fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6
16
+ header hgroup hr li noscript ol output p pre section table tfoot ul
17
+ ].to_set.freeze
18
+ IGNORE_TAGS = %w[head noscript object script style title].to_set.freeze
19
+ LIST_TAGS = %w[ol ul].to_set.freeze
20
+
21
+ PRESERVE_WS = 1
22
+ PRESERVE_WS_FULL = 2
23
+
24
+ WHITESPACE = /[ \t\r\n\f]/
25
+ WHITESPACE_RUN = /[ \t\r\n\f]+/
26
+
27
+ # A rule that matched an element: the rule, its index, the attributes it gives, and the schema's type.
28
+ Match = Data.define(:rule, :index, :attrs, :type)
29
+
30
+ attr_reader :schema, :normalize_lists, :matched_styles
31
+
32
+ def initialize(schema)
33
+ @schema = schema
34
+ rules = (schema.marks + schema.nodes).flat_map(&:parse_rules)
35
+ rules = rules.each_with_index.sort_by { |rule, index| [-rule.priority, index] }.map(&:first)
36
+ @tags = rules.select(&:tag)
37
+ @styles = rules.select(&:style)
38
+ @matched_styles = @styles.map(&:style_property).uniq
39
+ @normalize_lists = @tags.none? { |rule| lists_contain_themselves?(rule) }
40
+ end
41
+
42
+ # The document parsed from the HTML, as its top node.
43
+ def parse(html)
44
+ context = Context.new(self)
45
+ context.add_all(HTML::Builder.new.call(html), [])
46
+ context.finish
47
+ end
48
+
49
+ # The first tag rule after `after` that matches the element in this context.
50
+ def match_tag(element, context, after = nil)
51
+ ((after ? after + 1 : 0)...@tags.size).each do |index|
52
+ rule = @tags[index]
53
+ next unless HTML::Selector.parse(rule.tag).match?(element)
54
+ next if rule.context && !context.matches_context?(rule.context)
55
+
56
+ match = build_match(rule, index, element) { |get_attrs| call_get_attrs(get_attrs, element) }
57
+ return match if match
58
+ end
59
+ nil
60
+ end
61
+
62
+ # The first style rule after `after` that matches the property and value in this context.
63
+ def match_style(property, value, context, after = nil)
64
+ ((after ? after + 1 : 0)...@styles.size).each do |index|
65
+ rule = @styles[index]
66
+ next unless rule.style_matches?(property, value)
67
+ next if rule.context && !context.matches_context?(rule.context)
68
+
69
+ match = build_match(rule, index, nil) { |get_attrs| get_attrs.call(value) }
70
+ return match if match
71
+ end
72
+ nil
73
+ end
74
+
75
+ # The schema's type for a rule: a subclass registered in the schema inherits its parent's rules.
76
+ def type_for(rule)
77
+ rule.node? ? @schema.node_type(rule.type.type_name) : @schema.mark_type(rule.type.type_name)
78
+ end
79
+
80
+ private
81
+
82
+ def build_match(rule, index, element)
83
+ type = type_for(rule)
84
+ return nil unless type
85
+
86
+ given = rule.attrs
87
+ if rule.get_attrs
88
+ result = yield(rule.get_attrs)
89
+ return nil if result == false
90
+
91
+ given = result.is_a?(Hash) ? result : nil
92
+ end
93
+ return Match.new(rule:, index:, attrs: nil, type:) if rule.ignore || rule.skip || rule.close_parent
94
+
95
+ attrs = build_attrs(type, given, element)
96
+ attrs && Match.new(rule:, index:, attrs:, type:)
97
+ end
98
+
99
+ # The rule's attributes, with the ones read from the element, built with the type's properties.
100
+ def build_attrs(type, given, element)
101
+ given = (given || {}).transform_keys(&:to_s)
102
+ given = given.merge(type.attrs_from_html(element)) if element
103
+ type.build_attrs(given, report: ->(message) { @schema.report(message, path: "html") })
104
+ end
105
+
106
+ def call_get_attrs(get_attrs, element)
107
+ (get_attrs.arity == 2) ? get_attrs.call(element, @schema) : get_attrs.call(element)
108
+ end
109
+
110
+ def lists_contain_themselves?(rule)
111
+ return false unless rule.tag.match?(/\A(ul|ol)\b/) && rule.node?
112
+
113
+ type = type_for(rule)
114
+ type && @schema.content_match(type).match_type(type)
115
+ end
116
+
117
+ # A node being built: its type, attributes and marks, its content so far, and its content match state.
118
+ class NodeContext
119
+ attr_reader :type, :attrs, :marks, :solid, :content
120
+ attr_accessor :match, :options
121
+
122
+ def initialize(type, attrs, marks, solid, options, schema)
123
+ @type = type
124
+ @attrs = attrs
125
+ @marks = marks
126
+ @solid = solid
127
+ @options = options
128
+ @schema = schema
129
+ @match = schema.content_match(type)
130
+ @content = []
131
+ end
132
+
133
+ def find_wrapping(type)
134
+ @match&.find_wrapping(type, @schema)
135
+ end
136
+
137
+ def finish
138
+ strip_trailing_whitespace unless @options.anybits?(PRESERVE_WS)
139
+ fill = @match&.fill_before([], @schema, to_end: true) || []
140
+ content = Node.join_text(@content + fill.filter_map { |type| @schema.create_and_fill(type) })
141
+ Node.new(type: @type.type_name, attrs: @attrs, content:, marks: @marks)
142
+ end
143
+
144
+ def inline_context?
145
+ @schema.content_match(@type).inline_content?
146
+ end
147
+
148
+ private
149
+
150
+ def strip_trailing_whitespace
151
+ last = @content.last
152
+ return unless last&.text? && (trailing = last.text[/#{WHITESPACE_RUN}\z/o])
153
+
154
+ if trailing.length == last.text.length
155
+ @content.pop
156
+ else
157
+ @content[-1] = last.with(text: last.text[0...-trailing.length])
158
+ end
159
+ end
160
+ end
161
+
162
+ # The state of one parse, as ProseMirror's ParseContext.
163
+ class Context
164
+ def initialize(parser)
165
+ @parser = parser
166
+ @schema = parser.schema
167
+ @nodes = [NodeContext.new(@schema.node_type(@schema.top_node), @schema.node_type(@schema.top_node).default_attrs,
168
+ [], true, 0, @schema)]
169
+ @open = 0
170
+ @needs_block = false
171
+ @local_preserve_ws = false
172
+ @linebreak = @schema.nodes.find(&:linebreak_replacement?)
173
+ end
174
+
175
+ def top
176
+ @nodes[@open]
177
+ end
178
+
179
+ def add_all(parent, marks)
180
+ parent.children.each { |child| add_dom(child, marks) }
181
+ end
182
+
183
+ def finish
184
+ @open = 0
185
+ close_extra
186
+ @nodes.first.finish
187
+ end
188
+
189
+ # Whether the parents match a rule's context, as `"list_item/"`, `"blockquote//"` or `"a/|b/"`.
190
+ def matches_context?(context)
191
+ return context.split(/\s*\|\s*/).any? { |option| matches_context?(option) } if context.include?("|")
192
+
193
+ parts = context.split("/", -1)
194
+ context_matches?(parts, parts.size - 1, @open)
195
+ end
196
+
197
+ private
198
+
199
+ def context_matches?(parts, index, depth)
200
+ while index >= 0
201
+ part = parts[index]
202
+ if part.empty?
203
+ if index == parts.size - 1 || index.zero?
204
+ index -= 1
205
+ next
206
+ end
207
+ return depth.downto(0).any? { |deeper| context_matches?(parts, index - 1, deeper) }
208
+ end
209
+
210
+ type = (depth >= 0) ? @nodes[depth].type : nil
211
+ return false if type.nil? || (type.type_name.to_s != part && !type.group.include?(part.to_sym))
212
+
213
+ depth -= 1
214
+ index -= 1
215
+ end
216
+ true
217
+ end
218
+
219
+ def add_dom(dom, marks)
220
+ dom.element? ? add_element(dom, marks) : add_text_node(dom, marks)
221
+ end
222
+
223
+ def add_text_node(dom, marks)
224
+ value = dom.value
225
+ top = self.top
226
+ preserve = if top.options.anybits?(PRESERVE_WS_FULL)
227
+ :full
228
+ else
229
+ @local_preserve_ws || top.options.anybits?(PRESERVE_WS)
230
+ end
231
+ return unless preserve == :full || top.inline_context? || value.match?(/[^ \t\r\n\f]/)
232
+
233
+ if !preserve
234
+ value = value.gsub(WHITESPACE_RUN, " ")
235
+ value = value[1..] if value.start_with?(" ") && @open == @nodes.size - 1 && strip_leading_space?(dom, top)
236
+ elsif preserve == :full
237
+ value = value.gsub(/\r\n?/, "\n")
238
+ elsif @linebreak && value.match?(/[\r\n]/) && top.find_wrapping(@linebreak)
239
+ value.split(/\r?\n|\r/, -1).each_with_index do |line, index|
240
+ insert_node(Node.new(type: @linebreak.type_name, attrs: @linebreak.default_attrs), marks, true) if index.positive?
241
+ insert_node(text_node(line), marks, !line.match?(/\S/)) unless line.empty?
242
+ end
243
+ value = ""
244
+ else
245
+ value = value.gsub(/\r?\n|\r/, " ")
246
+ end
247
+ insert_node(text_node(value), marks, !value.match?(/\S/)) unless value.empty?
248
+ end
249
+
250
+ # A leading space is dropped at the start of a node, after a line break, and after a space.
251
+ def strip_leading_space?(dom, top)
252
+ before = top.content.last
253
+ previous = dom.parent&.previous_sibling(dom)
254
+ before.nil? || (previous&.element? && previous.name == "br") || (before.text? && before.text.match?(/#{WHITESPACE}\z/o))
255
+ end
256
+
257
+ def add_element(dom, marks, match_after = nil)
258
+ outer_ws = @local_preserve_ws
259
+ top = self.top
260
+ @local_preserve_ws = true if dom.name == "pre" || dom.style["white-space"].to_s.include?("pre")
261
+ normalize_list(dom) if LIST_TAGS.include?(dom.name) && @parser.normalize_lists
262
+ match = @parser.match_tag(dom, self, match_after)
263
+ rule = match&.rule
264
+
265
+ if rule ? rule.ignore : IGNORE_TAGS.include?(dom.name)
266
+ ignore_fallback(dom, marks)
267
+ elsif !rule || rule.skip || rule.close_parent
268
+ add_element_without_rule(dom, marks, rule, top)
269
+ else
270
+ inner_marks = read_styles(dom, marks)
271
+ add_element_by_rule(dom, match, inner_marks, rule.consuming ? nil : match.index) if inner_marks
272
+ end
273
+ @local_preserve_ws = outer_ws
274
+ end
275
+
276
+ def add_element_without_rule(dom, marks, rule, top)
277
+ @open = [0, @open - 1].max if rule&.close_parent
278
+ sync = false
279
+ old_needs_block = @needs_block
280
+ if BLOCK_TAGS.include?(dom.name)
281
+ if top.content.any? && inline?(top.content.first) && @open.positive?
282
+ @open -= 1
283
+ top = self.top
284
+ end
285
+ sync = true
286
+ @needs_block = true unless top.type
287
+ elsif dom.children.empty?
288
+ return leaf_fallback(dom, marks)
289
+ end
290
+ inner_marks = rule&.skip ? marks : read_styles(dom, marks)
291
+ add_all(dom, inner_marks) if inner_marks
292
+ self.sync(top) if sync
293
+ @needs_block = old_needs_block
294
+ end
295
+
296
+ # A `br` without a rule becomes a newline, in nodes with inline content.
297
+ def leaf_fallback(dom, marks)
298
+ add_text_node(HTML::Text.new("\n"), marks) if dom.name == "br" && top.inline_context?
299
+ end
300
+
301
+ # An ignored `br` still starts inline content.
302
+ def ignore_fallback(dom, marks)
303
+ find_place(@schema.node_type(:text), marks, true) if dom.name == "br" && !top.inline_context?
304
+ end
305
+
306
+ # The marks with the ones from the element's styles, or nil when a style rule ignores the element.
307
+ def read_styles(dom, marks)
308
+ return marks if dom.style.empty?
309
+
310
+ @parser.matched_styles.each do |property|
311
+ value = dom.style[property]
312
+ next if value.nil? || value.empty?
313
+
314
+ after = nil
315
+ while (match = @parser.match_style(property, value, self, after))
316
+ return nil if match.rule.ignore
317
+
318
+ marks = if match.rule.clear_mark
319
+ marks.reject { |mark| clears?(match.rule.clear_mark, match.type, mark) }
320
+ else
321
+ marks + [Mark.new(type: match.type.type_name, attrs: match.attrs)]
322
+ end
323
+ break if match.rule.consuming
324
+
325
+ after = match.index
326
+ end
327
+ end
328
+ marks
329
+ end
330
+
331
+ def clears?(clear_mark, type, mark)
332
+ (clear_mark == true) ? mark.type == type.type_name : clear_mark.call(mark)
333
+ end
334
+
335
+ def add_element_by_rule(dom, match, marks, continue_after)
336
+ rule = match.rule
337
+ type = match.type
338
+ sync = false
339
+ if rule.node?
340
+ if !type.leaf?
341
+ inner = enter(type, match.attrs, marks, rule.preserve_whitespace)
342
+ if inner
343
+ sync = true
344
+ marks = inner
345
+ end
346
+ elsif !insert_node(Node.new(type: type.type_name, attrs: match.attrs), marks, dom.name == "br")
347
+ leaf_fallback(dom, marks)
348
+ end
349
+ else
350
+ marks += [Mark.new(type: type.type_name, attrs: match.attrs)]
351
+ end
352
+ start_in = top
353
+
354
+ if rule.node? && type.leaf?
355
+ # Leaves have no content to parse.
356
+ elsif continue_after
357
+ add_element(dom, marks, continue_after)
358
+ elsif rule.get_content
359
+ Array(rule.get_content.call(dom, @schema)).each { |node| insert_node(node, marks, false) }
360
+ else
361
+ add_all(content_element(dom, rule) || dom, marks)
362
+ end
363
+ @open -= 1 if sync && self.sync(start_in)
364
+ end
365
+
366
+ def content_element(dom, rule)
367
+ case rule.content_element
368
+ when String then dom.query(rule.content_element)
369
+ when Proc then rule.content_element.call(dom)
370
+ end
371
+ end
372
+
373
+ # Finds where a node of this type fits: in the open nodes, from the innermost, possibly with wrappers.
374
+ # Returns the marks left to apply, or nil when it fits nowhere.
375
+ def find_place(type, marks, cautious)
376
+ route = nil
377
+ sync_to = nil
378
+ penalty = 0
379
+ @open.downto(0) do |depth|
380
+ context = @nodes[depth]
381
+ found = context.find_wrapping(type)
382
+ if found && (route.nil? || route.size > found.size + penalty)
383
+ route = found
384
+ sync_to = context
385
+ break if found.empty?
386
+ end
387
+ next unless context.solid
388
+ break if cautious
389
+
390
+ penalty += 2
391
+ end
392
+ return nil unless route
393
+
394
+ sync(sync_to)
395
+ route.each { |wrapper| marks = enter_inner(wrapper, wrapper.default_attrs, marks, false) }
396
+ marks
397
+ end
398
+
399
+ def insert_node(node, marks, cautious)
400
+ type = node_type(node)
401
+ inner_marks = find_place(type, marks, cautious)
402
+ return false unless inner_marks
403
+
404
+ close_extra
405
+ top = self.top
406
+ top.match &&= top.match.match_type(type)
407
+ node_marks = (inner_marks + node.marks).each_with_object([]) do |mark, set|
408
+ mark_type = @schema.mark_type(mark.type)
409
+ set.replace(@schema.add_mark(mark, set)) if mark_type && top.type.allows_mark?(mark_type, @schema)
410
+ end
411
+ top.content << node.with(marks: node_marks)
412
+ true
413
+ end
414
+
415
+ def enter(type, attrs, marks, preserve_ws)
416
+ inner_marks = find_place(type, marks, false)
417
+ inner_marks && enter_inner(type, attrs, inner_marks, true, preserve_ws)
418
+ end
419
+
420
+ # Opens a node of this type. Returns the marks that don't apply to it, which apply to its content.
421
+ def enter_inner(type, attrs, marks, solid, preserve_ws = nil)
422
+ close_extra
423
+ top = self.top
424
+ top.match &&= top.match.match_type(type)
425
+ options = ws_options(type, preserve_ws, top.options)
426
+ applied = []
427
+ remaining = marks.reject do |mark|
428
+ mark_type = @schema.mark_type(mark.type)
429
+ next false unless mark_type && top.type.allows_mark?(mark_type, @schema)
430
+
431
+ applied = @schema.add_mark(mark, applied)
432
+ true
433
+ end
434
+ @nodes << NodeContext.new(type, attrs, applied, solid, options, @schema)
435
+ @open += 1
436
+ remaining
437
+ end
438
+
439
+ def ws_options(type, preserve_ws, base)
440
+ unless preserve_ws.nil?
441
+ return (preserve_ws ? PRESERVE_WS : 0) | ((preserve_ws == :full) ? PRESERVE_WS_FULL : 0)
442
+ end
443
+
444
+ (type.whitespace == :pre) ? (PRESERVE_WS | PRESERVE_WS_FULL) : base
445
+ end
446
+
447
+ # Finishes the nodes above the open one, and adds them to their parents.
448
+ def close_extra
449
+ (@nodes.size - 1).downto(@open + 1) { |index| @nodes[index - 1].content << @nodes[index].finish }
450
+ @nodes.slice!((@open + 1)..)
451
+ end
452
+
453
+ def sync(to)
454
+ @open.downto(0) do |index|
455
+ if @nodes[index].equal?(to)
456
+ @open = index
457
+ return true
458
+ elsif @local_preserve_ws
459
+ @nodes[index].options |= PRESERVE_WS
460
+ end
461
+ end
462
+ false
463
+ end
464
+
465
+ # Moves lists that directly follow a list item into it, as some tools produce them.
466
+ def normalize_list(dom)
467
+ previous_item = nil
468
+ kept = []
469
+ dom.children.each do |child|
470
+ name = child.element? ? child.name : nil
471
+ if name && LIST_TAGS.include?(name) && previous_item
472
+ previous_item.append(child)
473
+ else
474
+ kept << child
475
+ if name == "li"
476
+ previous_item = child
477
+ elsif name
478
+ previous_item = nil
479
+ end
480
+ end
481
+ end
482
+ dom.children.replace(kept)
483
+ end
484
+
485
+ def text_node(value)
486
+ Node.new(type: :text, text: value)
487
+ end
488
+
489
+ def node_type(node)
490
+ @schema.node_type(node.type)
491
+ end
492
+
493
+ def inline?(node)
494
+ node.text? || node_type(node)&.inline?
495
+ end
496
+ end
497
+ end
498
+ end
@@ -68,18 +68,7 @@ module Prosemirror
68
68
  nodes = content.each_with_index.filter_map do |child, index|
69
69
  build_node(child, "#{path}.content[#{index}]", depth + 1)
70
70
  end
71
- join_text(nodes)
72
- end
73
-
74
- def join_text(nodes)
75
- nodes.each_with_object([]) do |node, joined|
76
- previous = joined.last
77
- if previous&.text? && node.text? && previous.marks == node.marks
78
- joined[-1] = previous.with(text: previous.text + node.text)
79
- else
80
- joined << node
81
- end
82
- end
71
+ Node.join_text(nodes)
83
72
  end
84
73
 
85
74
  def build_marks(marks, path)