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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +31 -45
- data/ROADMAP.md +10 -6
- data/TYPES.md +201 -0
- data/lib/prosemirror/content_match.rb +241 -0
- data/lib/prosemirror/html/builder.rb +146 -0
- data/lib/prosemirror/html/element.rb +84 -0
- data/lib/prosemirror/html/entities.rb +87 -0
- data/lib/prosemirror/html/entities.txt +2231 -0
- data/lib/prosemirror/html/selector.rb +95 -0
- data/lib/prosemirror/html/splitter.rb +68 -0
- data/lib/prosemirror/html/text.rb +23 -0
- data/lib/prosemirror/html_parser.rb +498 -0
- data/lib/prosemirror/json_parser.rb +1 -12
- data/lib/prosemirror/mark_type.rb +11 -0
- data/lib/prosemirror/marks/basic/code.rb +16 -0
- data/lib/prosemirror/marks/basic/em.rb +4 -0
- data/lib/prosemirror/marks/basic/link.rb +1 -0
- data/lib/prosemirror/marks/basic/strong.rb +4 -0
- data/lib/prosemirror/marks/bold.rb +8 -0
- data/lib/prosemirror/marks/code.rb +5 -0
- data/lib/prosemirror/marks/highlight.rb +3 -1
- data/lib/prosemirror/marks/italic.rb +4 -0
- data/lib/prosemirror/marks/link.rb +1 -0
- data/lib/prosemirror/marks/strike.rb +5 -0
- data/lib/prosemirror/marks/subscript.rb +2 -0
- data/lib/prosemirror/marks/superscript.rb +2 -0
- data/lib/prosemirror/marks/underline.rb +3 -0
- data/lib/prosemirror/node.rb +12 -0
- data/lib/prosemirror/node_type.rb +22 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +1 -0
- data/lib/prosemirror/nodes/basic/image.rb +1 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -1
- data/lib/prosemirror/nodes/blockquote.rb +1 -0
- data/lib/prosemirror/nodes/bullet_list.rb +1 -0
- data/lib/prosemirror/nodes/code_block.rb +6 -1
- data/lib/prosemirror/nodes/hard_break.rb +2 -0
- data/lib/prosemirror/nodes/heading.rb +1 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +1 -0
- data/lib/prosemirror/nodes/image.rb +1 -0
- data/lib/prosemirror/nodes/list_item.rb +1 -0
- data/lib/prosemirror/nodes/ordered_list.rb +20 -2
- data/lib/prosemirror/nodes/paragraph.rb +1 -0
- data/lib/prosemirror/nodes/table.rb +1 -0
- data/lib/prosemirror/nodes/table_cell.rb +12 -2
- data/lib/prosemirror/nodes/table_header.rb +2 -0
- data/lib/prosemirror/nodes/table_row.rb +1 -0
- data/lib/prosemirror/parse_rule.rb +50 -0
- data/lib/prosemirror/schema.rb +66 -15
- data/lib/prosemirror/type_definition.rb +34 -4
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +11 -1
- 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)
|