prosemirror 0.4.0 → 0.6.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 +14 -0
- data/README.md +49 -4
- data/ROADMAP.md +9 -5
- data/TYPES.md +55 -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 +65 -13
- data/lib/prosemirror/markdown_serializer.rb +518 -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 +15 -0
- data/lib/prosemirror/nodes/code_block.rb +15 -0
- data/lib/prosemirror/nodes/details.rb +27 -0
- data/lib/prosemirror/nodes/details_content.rb +12 -0
- data/lib/prosemirror/nodes/details_summary.rb +12 -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/mention.rb +36 -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/task_item.rb +40 -0
- data/lib/prosemirror/nodes/task_list.rb +24 -0
- data/lib/prosemirror/nodes/text.rb +4 -0
- data/lib/prosemirror/nodes/twitch.rb +94 -0
- data/lib/prosemirror/nodes/youtube.rb +71 -0
- data/lib/prosemirror/schema.rb +22 -6
- data/lib/prosemirror/text_align.rb +31 -0
- data/lib/prosemirror/type_definition.rb +33 -13
- data/lib/prosemirror/version.rb +1 -1
- metadata +11 -1
|
@@ -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
|
|
@@ -16,6 +16,10 @@ module Prosemirror
|
|
|
16
16
|
def view_template(&)
|
|
17
17
|
ol(start: (@order unless @order == 1), &)
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
def to_markdown(markdown, parent, index)
|
|
21
|
+
Nodes::OrderedList.numbered(markdown, node, @order, markdown.list_after_list?(parent, index) ? ")" : ".")
|
|
22
|
+
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
21
25
|
end
|
|
@@ -8,6 +8,21 @@ module Prosemirror
|
|
|
8
8
|
parse_html tag: "ul"
|
|
9
9
|
|
|
10
10
|
def view_template(&) = ul(&)
|
|
11
|
+
|
|
12
|
+
# `-` items, or `*` right after another bullet or task list, which would otherwise continue it.
|
|
13
|
+
def to_markdown(markdown, parent, index)
|
|
14
|
+
marker = self.class.marker(markdown, parent, index)
|
|
15
|
+
markdown.render_list(node, " ") { marker }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Bullet and task lists share markers.
|
|
19
|
+
def self.marker(markdown, parent, index)
|
|
20
|
+
after_list = markdown.list_after_list?(parent, index) do |sibling|
|
|
21
|
+
type = markdown.schema.node_type(sibling.type)
|
|
22
|
+
!type.nil? && (type <= BulletList || type <= TaskList)
|
|
23
|
+
end
|
|
24
|
+
after_list ? "* " : "- "
|
|
25
|
+
end
|
|
11
26
|
end
|
|
12
27
|
end
|
|
13
28
|
end
|
|
@@ -19,6 +19,21 @@ module Prosemirror
|
|
|
19
19
|
def view_template(&)
|
|
20
20
|
pre { code(class: @language && "language-#{@language}", &) }
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
def to_markdown(markdown, _parent, _index)
|
|
24
|
+
self.class.fenced(markdown, node, @language)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A fenced code block, with a fence longer than any run of backticks in the code.
|
|
28
|
+
def self.fenced(markdown, node, language)
|
|
29
|
+
code = node.text_content
|
|
30
|
+
fence = "`" * [3, (code.scan(/`{3,}/).map(&:length).max || 0) + 1].max
|
|
31
|
+
markdown.write("#{fence}#{language}\n")
|
|
32
|
+
markdown.text(code, escape: false)
|
|
33
|
+
markdown.ensure_new_line
|
|
34
|
+
markdown.write(fence)
|
|
35
|
+
markdown.close_block(node)
|
|
36
|
+
end
|
|
22
37
|
end
|
|
23
38
|
end
|
|
24
39
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's details: a summary, and content shown when it's open. `open` is the attribute Tiptap keeps with
|
|
6
|
+
# `persist: true`.
|
|
7
|
+
class Details < NodeType
|
|
8
|
+
group :block
|
|
9
|
+
content "details_summary details_content"
|
|
10
|
+
|
|
11
|
+
prop :open, _Boolean, default: false, parse_html: ->(element) { element.key?("open") }
|
|
12
|
+
parse_html tag: "details"
|
|
13
|
+
|
|
14
|
+
def view_template(&) = details(open: @open, &)
|
|
15
|
+
|
|
16
|
+
# As HTML, which GitHub and Markdown import read, with the content in Markdown between blank lines.
|
|
17
|
+
def to_markdown(markdown, _parent, _index)
|
|
18
|
+
summary, content = node.content
|
|
19
|
+
markdown.text("<details#{" open" if @open}>\n#{markdown.node_html(summary)}", escape: false)
|
|
20
|
+
markdown.close_block(node)
|
|
21
|
+
markdown.render(content, node, 1)
|
|
22
|
+
markdown.text("</details>", escape: false)
|
|
23
|
+
markdown.close_block(node)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
class DetailsContent < NodeType
|
|
6
|
+
content "block+"
|
|
7
|
+
parse_html tag: %(div[data-type="detailsContent"])
|
|
8
|
+
|
|
9
|
+
def view_template(&) = div(data_type: "detailsContent", &)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -10,6 +10,11 @@ module Prosemirror
|
|
|
10
10
|
parse_html tag: "br"
|
|
11
11
|
|
|
12
12
|
def view_template = br
|
|
13
|
+
|
|
14
|
+
# A backslash before the newline, unless only line breaks follow.
|
|
15
|
+
def to_markdown(markdown, parent, index)
|
|
16
|
+
markdown.write("\\\n") if parent.content[(index + 1)..].any? { |sibling| sibling.type != node.type }
|
|
17
|
+
end
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
end
|
|
@@ -10,6 +10,12 @@ module Prosemirror
|
|
|
10
10
|
(1..6).each { |level| parse_html tag: "h#{level}", attrs: {level:} }
|
|
11
11
|
|
|
12
12
|
def view_template(&) = tag(:"h#{@level}", &)
|
|
13
|
+
|
|
14
|
+
def to_markdown(markdown, _parent, _index)
|
|
15
|
+
markdown.write("#{"#" * @level} ")
|
|
16
|
+
markdown.render_inline(node, from_block_start: false)
|
|
17
|
+
markdown.close_block(node)
|
|
18
|
+
end
|
|
13
19
|
end
|
|
14
20
|
end
|
|
15
21
|
end
|
|
@@ -17,6 +17,22 @@ module Prosemirror
|
|
|
17
17
|
src = safe_url(@src)
|
|
18
18
|
img(src: src, alt: @alt, title: @title, width: @width, height: @height) if src
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# ``, or HTML with `html: true` when it has dimensions. Images in a row share a line,
|
|
22
|
+
# as Markdown would give them back: in separate paragraphs, each would come back after an empty one.
|
|
23
|
+
def to_markdown(markdown, parent, index)
|
|
24
|
+
markdown.write(" ") if index.positive? && parent.content[index - 1].type == node.type
|
|
25
|
+
if markdown.html? && (@width || @height)
|
|
26
|
+
markdown.write(markdown.node_html(node))
|
|
27
|
+
else
|
|
28
|
+
markdown.write(self.class.markdown_image(markdown, @src, @alt, @title))
|
|
29
|
+
end
|
|
30
|
+
markdown.close_block(node) unless parent.content[index + 1]&.type == node.type
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.markdown_image(markdown, src, alt, title)
|
|
34
|
+
"}#{markdown.title(title)})"
|
|
35
|
+
end
|
|
20
36
|
end
|
|
21
37
|
end
|
|
22
38
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's mention: `@label`, or `@id` without a label. `mentionSuggestionChar` is the character typed
|
|
6
|
+
# to insert it, `@` by default, which tells mentions of different kinds apart.
|
|
7
|
+
class Mention < NodeType
|
|
8
|
+
group :inline
|
|
9
|
+
inline
|
|
10
|
+
|
|
11
|
+
prop :id, _Union?(String, Integer), parse_html: ->(element) { element["data-id"] }
|
|
12
|
+
prop :label, _String?, parse_html: ->(element) { element["data-label"] }
|
|
13
|
+
prop :mention_suggestion_char, _String(length: 1..), default: "@", json: "mentionSuggestionChar",
|
|
14
|
+
parse_html: ->(element) { element["data-mention-suggestion-char"] }
|
|
15
|
+
leaf_text { |node| display_text(node.attrs) }
|
|
16
|
+
parse_html tag: %(span[data-type="mention"])
|
|
17
|
+
|
|
18
|
+
def view_template
|
|
19
|
+
span(data: {type: "mention", id: @id, label: @label, mention_suggestion_char: @mention_suggestion_char}) do
|
|
20
|
+
self.class.display_text(node.attrs)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Its text, or with `html: true`, its HTML, which Markdown import reads back.
|
|
25
|
+
def to_markdown(markdown, _parent, _index)
|
|
26
|
+
if markdown.html?
|
|
27
|
+
markdown.write(markdown.node_html(node))
|
|
28
|
+
else
|
|
29
|
+
markdown.text(self.class.display_text(node.attrs))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.display_text(attrs) = "#{attrs[:mention_suggestion_char] || "@"}#{attrs[:label] || attrs[:id]}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -31,6 +31,17 @@ module Prosemirror
|
|
|
31
31
|
def view_template(&)
|
|
32
32
|
ol(start: (@start unless @start == 1), type: @type, &)
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
def to_markdown(markdown, parent, index)
|
|
36
|
+
self.class.numbered(markdown, node, @start, markdown.list_after_list?(parent, index) ? ")" : ".")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Items numbered from `start`, with the numbers aligned. `)` instead of `.` keeps a list right after
|
|
40
|
+
# another ordered list apart from it.
|
|
41
|
+
def self.numbered(markdown, node, start, delimiter = ".")
|
|
42
|
+
width = (start + node.content.size - 1).to_s.length
|
|
43
|
+
markdown.render_list(node, " " * (width + 2)) { |index| "#{(start + index).to_s.rjust(width)}#{delimiter} " }
|
|
44
|
+
end
|
|
34
45
|
end
|
|
35
46
|
end
|
|
36
47
|
end
|
|
@@ -8,6 +8,14 @@ module Prosemirror
|
|
|
8
8
|
parse_html tag: "p"
|
|
9
9
|
|
|
10
10
|
def view_template(&) = p(&)
|
|
11
|
+
|
|
12
|
+
# Markdown has no empty paragraphs: they are left out.
|
|
13
|
+
def to_markdown(markdown, _parent, _index)
|
|
14
|
+
return if node.content.empty?
|
|
15
|
+
|
|
16
|
+
markdown.render_inline(node)
|
|
17
|
+
markdown.close_block(node)
|
|
18
|
+
end
|
|
11
19
|
end
|
|
12
20
|
end
|
|
13
21
|
end
|
|
@@ -10,6 +10,42 @@ module Prosemirror
|
|
|
10
10
|
def view_template(&)
|
|
11
11
|
table { tbody(&) }
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
# A GitHub Flavored Markdown table: the first row is the header, and gives the columns' alignments. A
|
|
15
|
+
# cell's paragraphs, images and line breaks are joined with `<br>`, and its other blocks, like lists, are
|
|
16
|
+
# written as HTML, which Markdown import reads back with the herb gem.
|
|
17
|
+
def to_markdown(markdown, _parent, _index)
|
|
18
|
+
rows = node.content.map { |row| row.content.map { |cell| cell_markdown(markdown, cell) } }
|
|
19
|
+
columns = rows.map(&:size).max.to_i
|
|
20
|
+
return markdown.close_block(node) if columns.zero?
|
|
21
|
+
|
|
22
|
+
aligns = node.content.first.content.map { |cell| cell.attrs[:align] }
|
|
23
|
+
delimiter = Array.new(columns) do |index|
|
|
24
|
+
{"left" => ":--", "center" => ":-:", "right" => "--:"}.fetch(aligns[index].to_s, "---")
|
|
25
|
+
end
|
|
26
|
+
lines = [rows.first, delimiter, *rows.drop(1)].map do |cells|
|
|
27
|
+
"| #{Array.new(columns) { |index| cells[index].to_s }.join(" | ")} |"
|
|
28
|
+
end
|
|
29
|
+
markdown.text(lines.join("\n"), escape: false)
|
|
30
|
+
markdown.close_block(node)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def cell_markdown(markdown, cell)
|
|
36
|
+
parts = cell.content.map do |block|
|
|
37
|
+
type = markdown.schema.node_type(block.type)
|
|
38
|
+
if type && (type <= Paragraph || type <= Image)
|
|
39
|
+
serializer = MarkdownSerializer.new(markdown.schema, html: markdown.html?)
|
|
40
|
+
serializer.render(block, cell, 0)
|
|
41
|
+
serializer.out.strip.gsub(/\\\n|\n+/, "<br>")
|
|
42
|
+
else
|
|
43
|
+
markdown.node_html(block).gsub("\n", " ")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
# A space after a line break is dropped, as in HTML.
|
|
47
|
+
parts.join("<br>").gsub(/(?:<br>[[:space:]]*)+/, "<br>").delete_prefix("<br>").delete_suffix("<br>").gsub("|", "\\|")
|
|
48
|
+
end
|
|
13
49
|
end
|
|
14
50
|
end
|
|
15
51
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's task item, with its content in a `div` after the checkbox. Its content is Tiptap's with
|
|
6
|
+
# `nested: true`, which also accepts documents written without it.
|
|
7
|
+
class TaskItem < NodeType
|
|
8
|
+
content "paragraph block*"
|
|
9
|
+
|
|
10
|
+
# From Tiptap's `data-checked`, or the checkbox.
|
|
11
|
+
prop :checked, _Boolean, default: false, parse_html: lambda { |element|
|
|
12
|
+
if element.key?("data-checked")
|
|
13
|
+
["", "true"].include?(element["data-checked"])
|
|
14
|
+
else
|
|
15
|
+
element.query(%(input[type="checkbox"]))&.key?("checked") || false
|
|
16
|
+
end
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
parse_html tag: %(li[data-type="taskItem"]), priority: 51, content_element: "div"
|
|
20
|
+
parse_html tag: "li.task-list-item", priority: 51, context: "task_list/"
|
|
21
|
+
|
|
22
|
+
def view_template(&)
|
|
23
|
+
li(data_checked: @checked.to_s, data_type: "taskItem") do
|
|
24
|
+
label do
|
|
25
|
+
input(type: "checkbox", checked: @checked)
|
|
26
|
+
span
|
|
27
|
+
end
|
|
28
|
+
div(&)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `[x] ` before its content, without the space when its first paragraph is empty.
|
|
33
|
+
def to_markdown(markdown, _parent, _index)
|
|
34
|
+
markdown.write(@checked ? "[x]" : "[ ]")
|
|
35
|
+
markdown.write(" ") unless node.content.first&.content.to_a.empty?
|
|
36
|
+
markdown.render_content(node)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's task list. Also imported from GitHub's HTML, as commonmarker renders it, when every item has a
|
|
6
|
+
# checkbox.
|
|
7
|
+
class TaskList < NodeType
|
|
8
|
+
group :block, :list
|
|
9
|
+
content "task_item+"
|
|
10
|
+
parse_html tag: %(ul[data-type="taskList"]), priority: 51
|
|
11
|
+
parse_html(tag: "ul.contains-task-list", priority: 51) do |element|
|
|
12
|
+
element.element_children.all? { |item| item.matches?("li.task-list-item") } && {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def view_template(&) = ul(data_type: "taskList", &)
|
|
16
|
+
|
|
17
|
+
# `- [x]` items. Its markers alternate with bullet lists', or Markdown would join them.
|
|
18
|
+
def to_markdown(markdown, parent, index)
|
|
19
|
+
marker = BulletList.marker(markdown, parent, index)
|
|
20
|
+
markdown.render_list(node, " ") { marker }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Prosemirror
|
|
6
|
+
module Nodes
|
|
7
|
+
# Tiptap's Twitch embed. `src` is the URL of a video, clip or channel, rendered as an iframe of its embed
|
|
8
|
+
# URL. Without a Twitch URL, it renders nothing. Twitch only plays embeds on the domain named by `parent`:
|
|
9
|
+
# override `parent` in a subclass, as Tiptap's `parent` option, which defaults to `localhost`.
|
|
10
|
+
class Twitch < NodeType
|
|
11
|
+
# The URLs Tiptap accepts.
|
|
12
|
+
URL = %r{\A(?:https?://)?(?:www\.)?(?:twitch\.tv|clips\.twitch\.tv)/(?:videos/\d+|\w+/clip/[\w-]+|[\w-]+/?)?(?:\?.*)?\z}
|
|
13
|
+
|
|
14
|
+
group :block
|
|
15
|
+
|
|
16
|
+
prop :src, _String(length: 1..), parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:src) || element["src"] }
|
|
17
|
+
prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
|
|
18
|
+
prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
|
|
19
|
+
prop :autoplay, _Boolean, default: false, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:autoplay) }
|
|
20
|
+
prop :muted, _Boolean, default: false, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:muted) }
|
|
21
|
+
prop :time, _String?, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:time) }
|
|
22
|
+
|
|
23
|
+
# Tiptap's markup, `<div data-twitch-video><iframe>`, or an iframe of a Twitch embed URL.
|
|
24
|
+
parse_html(tag: "iframe[src]") do |element|
|
|
25
|
+
(element.parent&.key?("data-twitch-video") || Twitch.from_embed_url(element["src"])) ? {} : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def view_template
|
|
29
|
+
src = safe_url(self.class.embed_url(@src, parent:, autoplay: @autoplay, muted: @muted, time: @time))
|
|
30
|
+
return unless src
|
|
31
|
+
|
|
32
|
+
div(data_twitch_video: "") { iframe(src:, width: @width, height: @height, allowfullscreen: true) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The domain of the site the embed is on.
|
|
36
|
+
def parent = "localhost"
|
|
37
|
+
|
|
38
|
+
# The URL, as text that Markdown links. With `html: true`, the iframe.
|
|
39
|
+
def to_markdown(markdown, _parent, _index)
|
|
40
|
+
markdown.write(markdown.html? ? markdown.node_html(node) : "<#{markdown.url(@src)}>")
|
|
41
|
+
markdown.close_block(node)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The video, clip or channel of a Twitch URL, as `[kind, id]`, or nil.
|
|
45
|
+
def self.identify(src)
|
|
46
|
+
return unless src.match?(URL)
|
|
47
|
+
|
|
48
|
+
path = src.split("?").first
|
|
49
|
+
if (clip = path[%r{clips\.twitch\.tv/([\w-]+)}, 1] || path[%r{twitch\.tv/[\w-]+/clip/([\w-]+)}, 1])
|
|
50
|
+
[:clip, clip]
|
|
51
|
+
elsif (video = path[%r{twitch\.tv/videos/(\d+)}, 1])
|
|
52
|
+
[:video, video]
|
|
53
|
+
elsif (channel = path[%r{twitch\.tv/([\w-]+)/?\z}, 1])
|
|
54
|
+
[:channel, channel]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The embed URL, as Tiptap builds it, or nil.
|
|
59
|
+
def self.embed_url(src, parent:, autoplay: false, muted: false, time: nil)
|
|
60
|
+
kind, id = identify(src)
|
|
61
|
+
return unless kind
|
|
62
|
+
|
|
63
|
+
options = {"autoplay" => ("true" if autoplay), "muted" => ("true" if muted)}
|
|
64
|
+
query = if kind == :clip
|
|
65
|
+
{"clip" => id, "parent" => parent, **options}
|
|
66
|
+
else
|
|
67
|
+
{kind.to_s => id, "parent" => parent, "allowfullscreen" => "true", **options, "time" => (time if kind == :video)}
|
|
68
|
+
end
|
|
69
|
+
base = (kind == :clip) ? "https://clips.twitch.tv/embed" : "https://player.twitch.tv/"
|
|
70
|
+
"#{base}?#{URI.encode_www_form(query.compact)}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The URL and options of an embed URL, as Tiptap reads them, or nil.
|
|
74
|
+
def self.from_embed_url(url)
|
|
75
|
+
uri = URI.parse(url.to_s)
|
|
76
|
+
return unless uri.is_a?(URI::HTTP)
|
|
77
|
+
|
|
78
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
79
|
+
src = case [uri.host.to_s.delete_prefix("www."), uri.path]
|
|
80
|
+
in ["player.twitch.tv", _]
|
|
81
|
+
if query["video"] then "https://www.twitch.tv/videos/#{query["video"]}"
|
|
82
|
+
elsif query["channel"] then "https://www.twitch.tv/#{query["channel"]}"
|
|
83
|
+
end
|
|
84
|
+
in ["clips.twitch.tv", "/embed"]
|
|
85
|
+
query["clip"]&.then { |clip| "https://clips.twitch.tv/#{clip}" }
|
|
86
|
+
else nil
|
|
87
|
+
end
|
|
88
|
+
{src:, autoplay: query["autoplay"] == "true", muted: query["muted"] == "true", time: query["time"]} if src&.match?(URL)
|
|
89
|
+
rescue URI::Error, ArgumentError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|