prosemirror 0.4.0 → 0.5.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 +6 -0
- data/README.md +18 -0
- data/ROADMAP.md +5 -3
- data/TYPES.md +43 -0
- data/lib/prosemirror/checker.rb +2 -2
- data/lib/prosemirror/coerce.rb +7 -0
- data/lib/prosemirror/document.rb +6 -0
- data/lib/prosemirror/html/element.rb +1 -1
- data/lib/prosemirror/html/selector.rb +12 -4
- data/lib/prosemirror/html_parser.rb +9 -2
- data/lib/prosemirror/json_parser.rb +71 -40
- data/lib/prosemirror/mark.rb +8 -3
- data/lib/prosemirror/mark_type.rb +15 -0
- data/lib/prosemirror/markdown/builder.rb +19 -9
- data/lib/prosemirror/markdown_serializer.rb +516 -0
- data/lib/prosemirror/marks/basic/code.rb +2 -0
- data/lib/prosemirror/marks/basic/em.rb +2 -0
- data/lib/prosemirror/marks/basic/link.rb +2 -0
- data/lib/prosemirror/marks/basic/strong.rb +2 -0
- data/lib/prosemirror/marks/bold.rb +2 -0
- data/lib/prosemirror/marks/code.rb +2 -0
- data/lib/prosemirror/marks/italic.rb +2 -0
- data/lib/prosemirror/marks/link.rb +2 -0
- data/lib/prosemirror/marks/strike.rb +2 -0
- data/lib/prosemirror/node.rb +21 -6
- data/lib/prosemirror/node_type.rb +13 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +4 -0
- data/lib/prosemirror/nodes/basic/image.rb +4 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -0
- data/lib/prosemirror/nodes/blockquote.rb +4 -0
- data/lib/prosemirror/nodes/bullet_list.rb +6 -0
- data/lib/prosemirror/nodes/code_block.rb +15 -0
- data/lib/prosemirror/nodes/hard_break.rb +5 -0
- data/lib/prosemirror/nodes/heading.rb +6 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
- data/lib/prosemirror/nodes/image.rb +16 -0
- data/lib/prosemirror/nodes/list_item.rb +4 -0
- data/lib/prosemirror/nodes/ordered_list.rb +11 -0
- data/lib/prosemirror/nodes/paragraph.rb +8 -0
- data/lib/prosemirror/nodes/table.rb +36 -0
- data/lib/prosemirror/nodes/text.rb +4 -0
- data/lib/prosemirror/schema.rb +14 -2
- data/lib/prosemirror/type_definition.rb +33 -13
- data/lib/prosemirror/version.rb +1 -1
- metadata +2 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Writes a document as Markdown, as prosemirror-markdown's MarkdownSerializerState, from which this is
|
|
5
|
+
# ported: https://github.com/ProseMirror/prosemirror-markdown
|
|
6
|
+
#
|
|
7
|
+
# Node types write themselves in `to_markdown`, with the methods of this class, and mark types declare
|
|
8
|
+
# their syntax with `markdown`. Formatting without Markdown syntax, like underline, keeps only its text,
|
|
9
|
+
# or becomes HTML with `html: true`.
|
|
10
|
+
class MarkdownSerializer
|
|
11
|
+
# No syntax: the mark's text only.
|
|
12
|
+
BLANK_MARK = {open: "", close: "", mixable: true}.freeze
|
|
13
|
+
# Unicode spaces, as CommonMark's flanking rules count them, non-breaking spaces included.
|
|
14
|
+
SPACES_START = /\A[[:space:]]*/
|
|
15
|
+
SPACES_ONLY = /\A[[:space:]]*\z/
|
|
16
|
+
# Stands for the content when rendering a mark's HTML, to split it into its opening and closing tags.
|
|
17
|
+
CONTENT = "\u0000"
|
|
18
|
+
# `[text](href "title")`, or `<href>` when the text is the URL.
|
|
19
|
+
LINK = {
|
|
20
|
+
open: lambda { |markdown, mark, parent, index|
|
|
21
|
+
markdown.in_autolink = markdown.plain_url?(mark, parent, index)
|
|
22
|
+
markdown.in_autolink ? "<" : "["
|
|
23
|
+
},
|
|
24
|
+
close: lambda { |markdown, mark, _parent, _index|
|
|
25
|
+
autolink = markdown.in_autolink
|
|
26
|
+
markdown.in_autolink = nil
|
|
27
|
+
autolink ? ">" : "](#{markdown.url(mark.attrs[:href])}#{markdown.title(mark.attrs[:title])})"
|
|
28
|
+
},
|
|
29
|
+
mixable: true
|
|
30
|
+
}.freeze
|
|
31
|
+
# Inline code, with more backticks than the code holds.
|
|
32
|
+
CODE = {
|
|
33
|
+
open: ->(markdown, _mark, parent, index) { markdown.backticks_for(parent.content[index], -1) },
|
|
34
|
+
close: ->(markdown, _mark, parent, index) { markdown.backticks_for(parent.content[index - 1], 1) },
|
|
35
|
+
escape: false
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
attr_reader :schema, :out
|
|
39
|
+
attr_accessor :in_autolink, :in_tight_list
|
|
40
|
+
|
|
41
|
+
def initialize(schema, html: false)
|
|
42
|
+
@schema = schema
|
|
43
|
+
@html = html
|
|
44
|
+
@delim = +""
|
|
45
|
+
@out = +""
|
|
46
|
+
@closed = nil
|
|
47
|
+
@in_autolink = nil
|
|
48
|
+
@at_block_start = false
|
|
49
|
+
@in_tight_list = false
|
|
50
|
+
@html_marks = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The document's content as Markdown, ending with a newline.
|
|
54
|
+
def call(root)
|
|
55
|
+
render_content(root)
|
|
56
|
+
@out.empty? ? @out : @out.sub(/\n*\z/, "\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether formatting without Markdown syntax is written as HTML.
|
|
60
|
+
def html?
|
|
61
|
+
@html
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Render a block, prefixing each line with `delim`, and the first one with `first_delim`. `node` is closed
|
|
65
|
+
# at the end of the block.
|
|
66
|
+
def wrap_block(delim, first_delim, node)
|
|
67
|
+
old = @delim
|
|
68
|
+
write(first_delim || delim)
|
|
69
|
+
@delim += delim
|
|
70
|
+
yield
|
|
71
|
+
@delim = old
|
|
72
|
+
close_block(node)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def ensure_new_line
|
|
76
|
+
@out << "\n" unless at_blank?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Writes the content as it is, after closing the previous block and adding the current delimiters.
|
|
80
|
+
def write(content = nil)
|
|
81
|
+
flush_close
|
|
82
|
+
@out << @delim if !@delim.empty? && at_blank?
|
|
83
|
+
@out << content if content
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def close_block(node)
|
|
87
|
+
@closed = node
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Writes text, escaped unless `escape` is false.
|
|
91
|
+
def text(text, escape: true)
|
|
92
|
+
lines = text.split("\n", -1)
|
|
93
|
+
lines.each_with_index do |line, index|
|
|
94
|
+
write
|
|
95
|
+
# Markdown drops spaces at the start of a block, and after a hard break.
|
|
96
|
+
line = line.lstrip if escape && index.zero? && (@at_block_start || @out.end_with?("\\\n#{@delim}"))
|
|
97
|
+
# An exclamation mark before a link would make it an image.
|
|
98
|
+
@out << "\\!" if !escape && line.start_with?("[") && @out.end_with?("!") && !@out.end_with?("\\!") && @out.chop!
|
|
99
|
+
@out << (escape ? esc(line, start_of_line: @at_block_start) : line)
|
|
100
|
+
@out << "\n" unless index == lines.size - 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Renders a node with its type's `to_markdown`.
|
|
105
|
+
def render(node, parent, index)
|
|
106
|
+
type = @schema.node_type(node.type)
|
|
107
|
+
if type
|
|
108
|
+
component = type.new(**node.attrs)
|
|
109
|
+
component.instance_variable_set(:@_prosemirror_node, node)
|
|
110
|
+
component.instance_variable_set(:@_prosemirror_schema, @schema)
|
|
111
|
+
component.to_markdown(self, parent, index)
|
|
112
|
+
elsif !node.content.empty?
|
|
113
|
+
render_content(node)
|
|
114
|
+
close_block(node)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Renders the content of `parent` as blocks.
|
|
119
|
+
def render_content(parent)
|
|
120
|
+
parent.content.each_with_index { |node, index| render(node, parent, index) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Renders the content of `parent` as inline content, with its marks.
|
|
124
|
+
def render_inline(parent, from_block_start: true)
|
|
125
|
+
parent = without_unflanked_marks(parent)
|
|
126
|
+
@at_block_start = from_block_start
|
|
127
|
+
active = []
|
|
128
|
+
trailing = +""
|
|
129
|
+
progress = lambda do |node, index|
|
|
130
|
+
marks = node ? node.marks : []
|
|
131
|
+
|
|
132
|
+
# Marks on a final hard break would put a newline just before their closing syntax.
|
|
133
|
+
if node && hard_break?(node)
|
|
134
|
+
marks = marks.select do |mark|
|
|
135
|
+
following = parent.content[index + 1]
|
|
136
|
+
following&.marks&.include?(mark) && (!following.text? || following.text.match?(/\S/))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
leading = trailing
|
|
141
|
+
trailing = +""
|
|
142
|
+
# Whitespace inside emphasis isn't allowed: it moves out of the marks.
|
|
143
|
+
if node&.text? && marks.any? { |mark| mark_spec(mark)[:expel_enclosing_whitespace] && !active.include?(mark) }
|
|
144
|
+
lead = node.text[SPACES_START]
|
|
145
|
+
rest = node.text[lead.length..]
|
|
146
|
+
unless lead.empty?
|
|
147
|
+
leading += lead
|
|
148
|
+
node = rest.empty? ? nil : node.with(text: rest)
|
|
149
|
+
marks = active unless node
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
if node&.text? && marks.any? { |mark| mark_spec(mark)[:expel_enclosing_whitespace] && !mark_ahead?(parent, index + 1, mark) }
|
|
153
|
+
rest, trail = split_trailing_space(node.text)
|
|
154
|
+
unless trail.empty?
|
|
155
|
+
trailing = trail
|
|
156
|
+
node = rest.empty? ? nil : node.with(text: rest)
|
|
157
|
+
marks = active unless node
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
inner = marks.last
|
|
162
|
+
no_escape = inner && mark_spec(inner)[:escape] == false
|
|
163
|
+
length = marks.size - (no_escape ? 1 : 0)
|
|
164
|
+
marks = reorder_mixable(marks, active, length)
|
|
165
|
+
|
|
166
|
+
# Close the marks that changed, write the expelled whitespace, then open the new marks.
|
|
167
|
+
keep = 0
|
|
168
|
+
keep += 1 while keep < [active.size, length].min && marks[keep] == active[keep]
|
|
169
|
+
text(mark_string(active.pop, false, parent, index), escape: false) while keep < active.size
|
|
170
|
+
text(leading) unless leading.empty?
|
|
171
|
+
|
|
172
|
+
if node
|
|
173
|
+
marks = longest_first(marks, active.size, length, parent, index)
|
|
174
|
+
while active.size < length
|
|
175
|
+
mark = marks[active.size]
|
|
176
|
+
active << mark
|
|
177
|
+
text(mark_string(mark, true, parent, index), escape: false)
|
|
178
|
+
@at_block_start = false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if no_escape && node.text?
|
|
182
|
+
text(mark_string(inner, true, parent, index) + node.text + mark_string(inner, false, parent, index + 1), escape: false)
|
|
183
|
+
else
|
|
184
|
+
render(node, parent, index)
|
|
185
|
+
end
|
|
186
|
+
@at_block_start = false
|
|
187
|
+
end
|
|
188
|
+
@at_block_start = false if node&.text? && !node.text.empty?
|
|
189
|
+
end
|
|
190
|
+
parent.content.each_with_index { |node, index| progress.call(node, index) }
|
|
191
|
+
progress.call(nil, parent.content.size)
|
|
192
|
+
# Markdown drops spaces at the end of a block too.
|
|
193
|
+
strip_trailing_spaces
|
|
194
|
+
@at_block_start = false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Renders a list. `delim` indents the lines of an item after its first, and the block gives the marker
|
|
198
|
+
# of an item's first line from its index. A list is tight when no item holds more than one block, other
|
|
199
|
+
# than nested lists.
|
|
200
|
+
def render_list(node, delim)
|
|
201
|
+
flush_close(1) if @in_tight_list && !(@closed && @closed.type == node.type)
|
|
202
|
+
|
|
203
|
+
tight = node.content.all? do |item|
|
|
204
|
+
item.content.count { |child| !@schema.node_type(child.type)&.group&.include?(:list) } <= 1
|
|
205
|
+
end
|
|
206
|
+
previous_tight = @in_tight_list
|
|
207
|
+
@in_tight_list = tight
|
|
208
|
+
node.content.each_with_index do |child, index|
|
|
209
|
+
flush_close(1) if index.positive? && tight
|
|
210
|
+
wrap_block(delim, yield(index), node) { render(child, node, index) }
|
|
211
|
+
end
|
|
212
|
+
@in_tight_list = previous_tight
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Whether the list follows a list of the same type, which it would continue in Markdown with the same
|
|
216
|
+
# marker. Alternating lists in a row switch markers.
|
|
217
|
+
def list_after_list?(parent, index)
|
|
218
|
+
run = 0
|
|
219
|
+
run += 1 while index - run - 1 >= 0 && parent.content[index - run - 1].type == parent.content[index].type
|
|
220
|
+
run.odd?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The string escaped so that it reads as text in Markdown. At the start of a line, characters that
|
|
224
|
+
# start a block are escaped too. `<` before a tag and `&` before a character reference are escaped, so
|
|
225
|
+
# that they aren't read as HTML.
|
|
226
|
+
def esc(string, start_of_line: false)
|
|
227
|
+
string = string.gsub(/[`*\\~\[\]_]/) do |char|
|
|
228
|
+
index = Regexp.last_match.begin(0)
|
|
229
|
+
# Between letters or digits, `_` can't be emphasis. Not between underscores: `__END__`.
|
|
230
|
+
intraword = char == "_" && index.positive? && index + 1 < string.length &&
|
|
231
|
+
string[index - 1].match?(/[[:alnum:]]/) && string[index + 1].match?(/[[:alnum:]]/)
|
|
232
|
+
intraword ? char : "\\#{char}"
|
|
233
|
+
end
|
|
234
|
+
string = string.gsub(%r{<(?=[A-Za-z/!?])}, "\\<").gsub(/&(?=#?\w+;)/) { "\\&" }
|
|
235
|
+
# URLs and email addresses in text would become links: GitHub Flavored Markdown links them.
|
|
236
|
+
string = string.gsub(%r{\b(https?|ftp)://}i) { "#{Regexp.last_match(1)}\\://" }
|
|
237
|
+
.gsub(/\bwww\./i) { "#{Regexp.last_match(0)[0, 3]}\\." }
|
|
238
|
+
.gsub(/(?<=[\w.+-])@(?=[\w-]+\.\w)/, "\\@")
|
|
239
|
+
if start_of_line
|
|
240
|
+
string = string.sub(/\A(\+ |[-*>])/) { "\\#{Regexp.last_match(1)}" }
|
|
241
|
+
.sub(/\A(\s*)(\#{1,6})(\s|\z)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}#{Regexp.last_match(3)}" }
|
|
242
|
+
.sub(/\A(\s*\d+)\.\s/) { "#{Regexp.last_match(1)}\\. " }
|
|
243
|
+
end
|
|
244
|
+
string
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def repeat(string, count)
|
|
248
|
+
string * count
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The opening or closing syntax of a mark.
|
|
252
|
+
def mark_string(mark, open, parent, index)
|
|
253
|
+
value = mark_spec(mark)[open ? :open : :close]
|
|
254
|
+
value.respond_to?(:call) ? value.call(self, mark, parent, index) : value
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The mark's syntax: its type's `markdown`, its HTML with `html: true`, or nothing.
|
|
258
|
+
def mark_spec(mark)
|
|
259
|
+
type = @schema.mark_type(mark.type)
|
|
260
|
+
type&.markdown_spec || ((@html && type) ? html_mark(mark, type) : BLANK_MARK)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The backticks that open (`side` -1) or close (1) inline code around this node's text: one more than
|
|
264
|
+
# its longest run of backticks, with a space when it has some.
|
|
265
|
+
def backticks_for(node, side)
|
|
266
|
+
length = node&.text? ? node.text.scan(/`+/).map(&:length).max.to_i : 0
|
|
267
|
+
ticks = "`" * (length + 1)
|
|
268
|
+
return ticks if length.zero?
|
|
269
|
+
|
|
270
|
+
side.negative? ? "#{ticks} " : " #{ticks}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Whether a link can be written as an autolink, `<https://…>`: its text is its URL, without a title.
|
|
274
|
+
def plain_url?(link, parent, index)
|
|
275
|
+
href = link.attrs[:href].to_s
|
|
276
|
+
return false if link.attrs[:title] || !href.match?(/\A\w+:/)
|
|
277
|
+
|
|
278
|
+
content = parent.content[index]
|
|
279
|
+
return false unless content&.text? && content.text == href && content.marks.last == link
|
|
280
|
+
|
|
281
|
+
index == parent.content.size - 1 || !parent.content[index + 1].marks.include?(link)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# A link destination, percent-encoded as Markdown import gives it back, with its parentheses escaped.
|
|
285
|
+
def url(href)
|
|
286
|
+
Markdown::Builder.escape_url(href.to_s).gsub(/[()]/) { "\\#{Regexp.last_match(0)}" }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# A link title, quoted.
|
|
290
|
+
def title(value)
|
|
291
|
+
value ? %( "#{value.gsub('"', '\\"')}") : ""
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# A node's HTML, for formatting without Markdown syntax.
|
|
295
|
+
def node_html(node)
|
|
296
|
+
Document.new(Node.new(type: @schema.top_node, content: [node]), schema: @schema).to_phlex.call
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
private
|
|
300
|
+
|
|
301
|
+
def flush_close(size = 2)
|
|
302
|
+
return unless @closed
|
|
303
|
+
|
|
304
|
+
@out << "\n" unless at_blank?
|
|
305
|
+
if size > 1
|
|
306
|
+
delim = @delim.sub(/\s+\z/, "")
|
|
307
|
+
(size - 1).times { @out << delim << "\n" }
|
|
308
|
+
end
|
|
309
|
+
@closed = nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Removes the spaces and tabs ending the output after other text. Reads bytes from the end: the output
|
|
313
|
+
# can be long, and a regular expression would scan it all for each block.
|
|
314
|
+
def strip_trailing_spaces
|
|
315
|
+
size = @out.bytesize
|
|
316
|
+
size -= 1 while size.positive? && [32, 9].include?(@out.getbyte(size - 1))
|
|
317
|
+
return if size == @out.bytesize || size.zero? || [10, 13].include?(@out.getbyte(size - 1))
|
|
318
|
+
|
|
319
|
+
@out.replace(@out.byteslice(0, size))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def at_blank?
|
|
323
|
+
@out.empty? || @out.end_with?("\n")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def hard_break?(node)
|
|
327
|
+
@schema.node_type(node.type)&.linebreak_replacement?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Whether the mark continues after `index`, past hard breaks.
|
|
331
|
+
def mark_ahead?(parent, index, mark)
|
|
332
|
+
loop do
|
|
333
|
+
following = parent.content[index]
|
|
334
|
+
return false unless following
|
|
335
|
+
return following.marks.include?(mark) unless hard_break?(following)
|
|
336
|
+
|
|
337
|
+
index += 1
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The marks with the mixable ones about to open sorted by how far they go: the longest opens first,
|
|
342
|
+
# outside the others, so that it doesn't close and reopen when a shorter one ends, like bold around a
|
|
343
|
+
# link and the text after it.
|
|
344
|
+
def longest_first(marks, from, length, parent, index)
|
|
345
|
+
opening = marks[from...length]
|
|
346
|
+
return marks if opening.size < 2 || !opening.all? { |mark| mark_spec(mark)[:mixable] }
|
|
347
|
+
|
|
348
|
+
sorted = opening.each_with_index.sort_by { |mark, position| [-run_length(parent, index, mark), position] }.map(&:first)
|
|
349
|
+
marks[0...from] + sorted + marks[length..]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# How many nodes from `index` on have the mark, without the spaces at the end, which move out of it.
|
|
353
|
+
def run_length(parent, index, mark)
|
|
354
|
+
length = 0
|
|
355
|
+
length += 1 while parent.content[index + length]&.marks&.include?(mark)
|
|
356
|
+
length -= 1 while length > 1 && (node = parent.content[index + length - 1]).text? && node.text.match?(SPACES_ONLY)
|
|
357
|
+
length
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# The parent without the emphasis marks Markdown can't write where they are: as CommonMark's flanking
|
|
361
|
+
# rules require, `**` opens before a non-space, and when it's punctuation, after a space or punctuation,
|
|
362
|
+
# like `Torra**,**`, which can't be bold. Their text stays.
|
|
363
|
+
#
|
|
364
|
+
# When punctuation at an edge of the run is the problem, it moves out of the mark first, as in
|
|
365
|
+
# `l’‘*Association*` for an italic `‘Association`. Each run that can't be written is handled in turn.
|
|
366
|
+
def without_unflanked_marks(parent)
|
|
367
|
+
content = parent.content
|
|
368
|
+
while (run = unflanked_run(content))
|
|
369
|
+
content = fitted(content, *run)
|
|
370
|
+
end
|
|
371
|
+
content.equal?(parent.content) ? parent : parent.with(content: content)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The first emphasis mark, with the indexes of its run, that can't be written, or nil.
|
|
375
|
+
def unflanked_run(content)
|
|
376
|
+
content.each_with_index do |node, index|
|
|
377
|
+
node.marks.each do |mark|
|
|
378
|
+
next unless mark_spec(mark)[:expel_enclosing_whitespace]
|
|
379
|
+
next if index.positive? && content[index - 1].marks.include?(mark) # inside a run
|
|
380
|
+
|
|
381
|
+
last = index
|
|
382
|
+
last += 1 while content[last + 1]&.marks&.include?(mark)
|
|
383
|
+
return [mark, index, last] unless flanks(content, index, last).all?
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The content with the mark's edge punctuation moved out of its run, when that's enough, or else
|
|
390
|
+
# without the mark on the run.
|
|
391
|
+
def fitted(content, mark, first, last)
|
|
392
|
+
opens, closes = flanks(content, first, last)
|
|
393
|
+
trimmed = content.dup
|
|
394
|
+
run_first = first
|
|
395
|
+
run_last = last
|
|
396
|
+
if !closes && (split = split_edge(trimmed[last], mark, /[[:punct:]]+\z/, after: true))
|
|
397
|
+
trimmed[last, 1] = split
|
|
398
|
+
end
|
|
399
|
+
if !opens && (split = split_edge(trimmed[first], mark, /\A[[:punct:]]+/, after: false))
|
|
400
|
+
trimmed[first, 1] = split
|
|
401
|
+
run_first += 1
|
|
402
|
+
run_last += 1
|
|
403
|
+
end
|
|
404
|
+
return trimmed if trimmed.size > content.size && flanks(trimmed, run_first, run_last).all?
|
|
405
|
+
|
|
406
|
+
content.each_with_index.map { |node, index| (first..last).cover?(index) ? node.with(marks: node.marks - [mark]) : node }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The text node split into its punctuation edge without the mark, and its rest with it, or nil.
|
|
410
|
+
def split_edge(node, mark, pattern, after:)
|
|
411
|
+
return nil unless node.text?
|
|
412
|
+
|
|
413
|
+
edge = node.text[pattern]
|
|
414
|
+
return nil if edge.nil? || edge.length == node.text.length
|
|
415
|
+
|
|
416
|
+
rest = after ? node.text[0...-edge.length] : node.text[edge.length..]
|
|
417
|
+
outside = node.with(text: edge, marks: node.marks - [mark])
|
|
418
|
+
inside = node.with(text: rest)
|
|
419
|
+
after ? [inside, outside] : [outside, inside]
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Whether the delimiters around the nodes from `first` to `last` can open and close emphasis, as
|
|
423
|
+
# `[opens, closes]`. The characters around them are the ones written: the backticks of code, the
|
|
424
|
+
# brackets of links.
|
|
425
|
+
def flanks(content, first, last)
|
|
426
|
+
run_marks = content[first].marks
|
|
427
|
+
text = content[first..last].map { |node| written(node, run_marks) }.join
|
|
428
|
+
lead = text[SPACES_START]
|
|
429
|
+
inner, trail = split_trailing_space(text[lead.length..])
|
|
430
|
+
return [true, true] if inner.empty?
|
|
431
|
+
|
|
432
|
+
before = lead.empty? ? neighbor_char(content, first, -1, run_marks) : " "
|
|
433
|
+
after = trail.empty? ? neighbor_char(content, last, 1, run_marks) : " "
|
|
434
|
+
[left_flanking?(before, inner[0]), right_flanking?(inner[-1], after)]
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The written character next to a node, before it (`step` -1) or after it (1), or nil at an edge.
|
|
438
|
+
def neighbor_char(content, index, step, run_marks)
|
|
439
|
+
loop do
|
|
440
|
+
index += step
|
|
441
|
+
return nil if index.negative? || index >= content.size
|
|
442
|
+
|
|
443
|
+
text = written(content[index], run_marks)
|
|
444
|
+
return step.negative? ? text[-1] : text[0] unless text.empty?
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# The text without its trailing spaces, and the spaces. Linear: a regular expression for them would
|
|
449
|
+
# backtrack over long texts.
|
|
450
|
+
def split_trailing_space(text)
|
|
451
|
+
last = text.rindex(/[^[:space:]]/)
|
|
452
|
+
last ? [text[0..last], text[(last + 1)..]] : ["", text]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# A node's text with the syntax of its code and link marks, other than the ones of the run.
|
|
456
|
+
def written(node, run_marks)
|
|
457
|
+
text = node.text? ? node.text : "" # an object, like an image: neither space nor punctuation
|
|
458
|
+
node.marks.each do |mark|
|
|
459
|
+
next if run_marks.include?(mark)
|
|
460
|
+
|
|
461
|
+
text = "`#{text}`" if mark_spec(mark)[:escape] == false
|
|
462
|
+
text = "[#{text})" if mark_spec(mark).equal?(LINK)
|
|
463
|
+
end
|
|
464
|
+
text
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def left_flanking?(before, following)
|
|
468
|
+
!space?(following) && (!punctuation?(following) || space?(before) || punctuation?(before))
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def right_flanking?(preceding, after)
|
|
472
|
+
!space?(preceding) && (!punctuation?(preceding) || space?(after) || punctuation?(after))
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def space?(char)
|
|
476
|
+
char.nil? || char.match?(/[[:space:]]/)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def punctuation?(char)
|
|
480
|
+
!char.nil? && char.match?(/[\p{P}\p{S}]/)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Marks like bold and italic can open and close in any order: they are put in the order of the open
|
|
484
|
+
# marks, so that fewer of them close and reopen.
|
|
485
|
+
def reorder_mixable(marks, active, length)
|
|
486
|
+
marks = marks.dup
|
|
487
|
+
(0...length).each do |i|
|
|
488
|
+
mark = marks[i]
|
|
489
|
+
break unless mark_spec(mark)[:mixable]
|
|
490
|
+
|
|
491
|
+
active.each_with_index do |other, j|
|
|
492
|
+
break unless mark_spec(other)[:mixable]
|
|
493
|
+
next unless mark == other
|
|
494
|
+
|
|
495
|
+
if i > j
|
|
496
|
+
marks = marks[0...j] + [mark] + marks[j...i] + marks[(i + 1)...length] + marks[length..]
|
|
497
|
+
elsif j > i
|
|
498
|
+
marks = marks[0...i] + marks[(i + 1)...j] + [mark] + marks[j...length] + marks[length..]
|
|
499
|
+
end
|
|
500
|
+
break
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
marks
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# The HTML a mark type renders, split around its content.
|
|
507
|
+
def html_mark(mark, type)
|
|
508
|
+
@html_marks[mark] ||= begin
|
|
509
|
+
component = type.new(**mark.attrs)
|
|
510
|
+
component.instance_variable_set(:@_prosemirror_schema, @schema)
|
|
511
|
+
open, close = component.call { CONTENT }.split(CONTENT, 2)
|
|
512
|
+
{open: open.to_s, close: close.to_s, mixable: true}.freeze
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
@@ -10,6 +10,8 @@ module Prosemirror
|
|
|
10
10
|
parse_html style: "font-weight=400", clear_mark: true
|
|
11
11
|
parse_html(style: "font-weight") { |value| value.match?(Bold::BOLD) && {} }
|
|
12
12
|
|
|
13
|
+
markdown open: "**", close: "**", mixable: true, expel_enclosing_whitespace: true
|
|
14
|
+
|
|
13
15
|
def view_template(&) = strong(&)
|
|
14
16
|
end
|
|
15
17
|
end
|
|
@@ -13,6 +13,8 @@ module Prosemirror
|
|
|
13
13
|
parse_html style: "font-weight=400", clear_mark: true
|
|
14
14
|
parse_html(style: "font-weight") { |value| value.match?(BOLD) && {} }
|
|
15
15
|
|
|
16
|
+
markdown open: "**", close: "**", mixable: true, expel_enclosing_whitespace: true
|
|
17
|
+
|
|
16
18
|
def view_template(&) = strong(&)
|
|
17
19
|
end
|
|
18
20
|
end
|
|
@@ -11,6 +11,8 @@ module Prosemirror
|
|
|
11
11
|
prop :title, _String?
|
|
12
12
|
parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
|
|
13
13
|
|
|
14
|
+
markdown(**MarkdownSerializer::LINK)
|
|
15
|
+
|
|
14
16
|
def view_template(&)
|
|
15
17
|
href = safe_url(@href)
|
|
16
18
|
return yield unless href
|
|
@@ -8,6 +8,8 @@ module Prosemirror
|
|
|
8
8
|
parse_html tag: "strike"
|
|
9
9
|
parse_html(style: "text-decoration", consuming: false) { |value| value.include?("line-through") ? {} : false }
|
|
10
10
|
|
|
11
|
+
markdown open: "~~", close: "~~", mixable: true, expel_enclosing_whitespace: true
|
|
12
|
+
|
|
11
13
|
def view_template(&) = s(&)
|
|
12
14
|
end
|
|
13
15
|
end
|
data/lib/prosemirror/node.rb
CHANGED
|
@@ -14,15 +14,30 @@ module Prosemirror
|
|
|
14
14
|
# Whitespace, including non-breaking spaces.
|
|
15
15
|
BLANK = /\A[[:space:]]*\z/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
prop :
|
|
21
|
-
prop :
|
|
22
|
-
prop :
|
|
17
|
+
EMPTY_HASH = {}.freeze
|
|
18
|
+
EMPTY_ARRAY = [].freeze
|
|
19
|
+
|
|
20
|
+
prop :type, Symbol
|
|
21
|
+
prop :attrs, _Hash(Symbol, _Any?), default: EMPTY_HASH
|
|
22
|
+
prop :unknown_attrs, _Hash(String, _Any?), default: EMPTY_HASH
|
|
23
|
+
prop :content, _Array(Node), default: EMPTY_ARRAY
|
|
24
|
+
prop :marks, _Array(Mark), default: EMPTY_ARRAY
|
|
25
|
+
prop :text, _String?
|
|
26
|
+
|
|
27
|
+
# The type as a Symbol, and frozen values. Done here rather than with Literal coercion blocks, which
|
|
28
|
+
# cost an `instance_exec` each, for each of the many nodes a document has.
|
|
29
|
+
def initialize(type:, attrs: EMPTY_HASH, unknown_attrs: EMPTY_HASH, content: EMPTY_ARRAY, marks: EMPTY_ARRAY, text: nil)
|
|
30
|
+
super(type: type.is_a?(String) ? type.to_sym : type, attrs: Coerce.frozen(attrs), unknown_attrs: Coerce.frozen(unknown_attrs),
|
|
31
|
+
content: Coerce.frozen(content), marks: Coerce.frozen(marks), text: Coerce.frozen(text))
|
|
32
|
+
end
|
|
23
33
|
|
|
24
34
|
# The nodes with adjacent text nodes that have the same marks joined, as in ProseMirror.
|
|
25
35
|
def self.join_text(nodes)
|
|
36
|
+
return nodes unless (1...nodes.size).any? do |index|
|
|
37
|
+
previous, node = nodes[index - 1], nodes[index]
|
|
38
|
+
previous.text? && node.text? && previous.marks == node.marks
|
|
39
|
+
end
|
|
40
|
+
|
|
26
41
|
nodes.each_with_object([]) do |node, joined|
|
|
27
42
|
previous = joined.last
|
|
28
43
|
if previous&.text? && node.text? && previous.marks == node.marks
|
|
@@ -119,5 +119,18 @@ module Prosemirror
|
|
|
119
119
|
def view_template
|
|
120
120
|
yield if block_given?
|
|
121
121
|
end
|
|
122
|
+
|
|
123
|
+
# Write the node as Markdown, with the MarkdownSerializer's methods. `parent` and `index` locate the node.
|
|
124
|
+
# By default, the content is written, as a block for block nodes.
|
|
125
|
+
def to_markdown(markdown, _parent, _index)
|
|
126
|
+
return if self.class.leaf?
|
|
127
|
+
|
|
128
|
+
if markdown.schema.textblock?(self.class)
|
|
129
|
+
markdown.render_inline(node)
|
|
130
|
+
else
|
|
131
|
+
markdown.render_content(node)
|
|
132
|
+
end
|
|
133
|
+
markdown.close_block(node) if self.class.block?
|
|
134
|
+
end
|
|
122
135
|
end
|
|
123
136
|
end
|