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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Prosemirror
|
|
6
|
+
module Nodes
|
|
7
|
+
# Tiptap's YouTube embed. `src` is the video's URL, like `https://www.youtube.com/watch?v=…`, rendered as
|
|
8
|
+
# an iframe of its embed URL. Without a YouTube URL, it renders nothing. Override `embed_host` for
|
|
9
|
+
# `https://www.youtube-nocookie.com`, as Tiptap's `nocookie` option.
|
|
10
|
+
class Youtube < NodeType
|
|
11
|
+
# The URLs Tiptap accepts: on youtube.com, youtu.be and youtube-nocookie.com.
|
|
12
|
+
URL = %r{\A(?:(?:https?:)?//)?(?:(?:www|m|music)\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)(?:/(?:[\w-]+\?v=|embed/|v/)?)[\w-]+\S*\z}
|
|
13
|
+
EMBED_HOSTS = %w[youtube.com youtube-nocookie.com].freeze
|
|
14
|
+
|
|
15
|
+
group :block
|
|
16
|
+
|
|
17
|
+
prop :src, _String(length: 1..), parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:src) || element["src"] }
|
|
18
|
+
prop :start, _Integer(0..), default: 0,
|
|
19
|
+
parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:start) }, &Coerce::INTEGER
|
|
20
|
+
prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
|
|
21
|
+
prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
|
|
22
|
+
|
|
23
|
+
# Tiptap's markup, `<div data-youtube-video><iframe>`, or an iframe of a YouTube embed URL.
|
|
24
|
+
parse_html(tag: "iframe[src]") do |element|
|
|
25
|
+
(element.parent&.key?("data-youtube-video") || Youtube.from_embed_url(element["src"])) ? {} : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def view_template
|
|
29
|
+
src = safe_url(self.class.embed_url(@src, start: @start, host: embed_host))
|
|
30
|
+
return unless src
|
|
31
|
+
|
|
32
|
+
div(data_youtube_video: "") { iframe(src:, width: @width, height: @height, allowfullscreen: true) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def embed_host = "https://www.youtube.com"
|
|
36
|
+
|
|
37
|
+
# The URL, as text that Markdown links. With `html: true`, the iframe.
|
|
38
|
+
def to_markdown(markdown, _parent, _index)
|
|
39
|
+
markdown.write(markdown.html? ? markdown.node_html(node) : "<#{markdown.url(@src)}>")
|
|
40
|
+
markdown.close_block(node)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The embed URL of a video or playlist, as Tiptap builds it, or nil.
|
|
44
|
+
def self.embed_url(src, start: 0, host: "https://www.youtube.com")
|
|
45
|
+
return unless src.match?(URL)
|
|
46
|
+
|
|
47
|
+
if (playlist = src[/[?&]list=([\w-]+)/, 1])
|
|
48
|
+
return "#{host}/embed/videoseries?list=#{playlist}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
id = src[%r{(?:youtu\.be/|/embed/|/v/|/shorts/|/live/|[?&]v=)([\w-]+)}, 1]
|
|
52
|
+
id && "#{host}/embed/#{id}#{"?start=#{start}" if start.positive?}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The video URL and start time of an embed URL, as Tiptap reads them, or nil.
|
|
56
|
+
def self.from_embed_url(url)
|
|
57
|
+
uri = URI.parse(url.to_s)
|
|
58
|
+
return unless uri.is_a?(URI::HTTP) && EMBED_HOSTS.include?(uri.host.to_s.delete_prefix("www."))
|
|
59
|
+
|
|
60
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
61
|
+
src = case uri.path
|
|
62
|
+
when "/embed/videoseries" then query["list"]&.then { |list| "https://www.youtube.com/playlist?list=#{list}" }
|
|
63
|
+
when %r{\A/embed/([\w-]+)\z} then "https://www.youtube.com/watch?v=#{Regexp.last_match(1)}"
|
|
64
|
+
end
|
|
65
|
+
{src:, start: query["start"].to_i} if src&.match?(URL)
|
|
66
|
+
rescue URI::Error, ArgumentError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/prosemirror/schema.rb
CHANGED
|
@@ -24,13 +24,17 @@ module Prosemirror
|
|
|
24
24
|
# Called with an `InvalidDocument` error for each problem fixed.
|
|
25
25
|
prop :on_invalid, _Callable?, reader: false
|
|
26
26
|
|
|
27
|
-
# Tiptap's StarterKit and common extensions.
|
|
28
|
-
|
|
27
|
+
# Tiptap's StarterKit and common extensions. `text_align: true` adds the TextAlign extension, configured
|
|
28
|
+
# for headings and paragraphs.
|
|
29
|
+
def self.tiptap(text_align: false, **)
|
|
29
30
|
new(
|
|
30
31
|
nodes: [
|
|
31
|
-
Nodes::Doc,
|
|
32
|
+
Nodes::Doc, text_align ? TextAlign::Paragraph : Nodes::Paragraph, Nodes::Text,
|
|
33
|
+
text_align ? TextAlign::Heading : Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
|
|
32
34
|
Nodes::OrderedList, Nodes::ListItem, Nodes::CodeBlock, Nodes::HardBreak, Nodes::HorizontalRule,
|
|
33
|
-
Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell
|
|
35
|
+
Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell, Nodes::TaskList,
|
|
36
|
+
Nodes::TaskItem, Nodes::Details, Nodes::DetailsSummary, Nodes::DetailsContent, Nodes::Youtube,
|
|
37
|
+
Nodes::Twitch, Nodes::Mention
|
|
34
38
|
],
|
|
35
39
|
# In Tiptap's order, which sorts and nests marks.
|
|
36
40
|
marks: [
|
|
@@ -57,6 +61,8 @@ module Prosemirror
|
|
|
57
61
|
def after_initialize
|
|
58
62
|
@node_lookup = lookup(@nodes)
|
|
59
63
|
@mark_lookup = lookup(@marks)
|
|
64
|
+
@node_names = spellings(@node_lookup)
|
|
65
|
+
@mark_names = spellings(@mark_lookup)
|
|
60
66
|
@textblocks = @nodes.to_h { |type| [type, textblock_type?(type)] }
|
|
61
67
|
@content_matches = @nodes.to_h { |type| [type, ContentMatch.compile(type.content, self)] }
|
|
62
68
|
raise ArgumentError, "the schema has no #{@top_node} node type" unless node_type(@top_node)
|
|
@@ -100,11 +106,11 @@ module Prosemirror
|
|
|
100
106
|
|
|
101
107
|
# The node type for a name, alias, or camelCase name.
|
|
102
108
|
def node_type(name)
|
|
103
|
-
@node_lookup[Naming.snake(name).to_sym]
|
|
109
|
+
@node_names[name] || @node_lookup[Naming.snake(name).to_sym]
|
|
104
110
|
end
|
|
105
111
|
|
|
106
112
|
def mark_type(name)
|
|
107
|
-
@mark_lookup[Naming.snake(name).to_sym]
|
|
113
|
+
@mark_names[name] || @mark_lookup[Naming.snake(name).to_sym]
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
# Build a document from JSON, given as a Hash or a String.
|
|
@@ -228,6 +234,16 @@ module Prosemirror
|
|
|
228
234
|
end
|
|
229
235
|
end
|
|
230
236
|
|
|
237
|
+
# The names of a lookup table, as Symbols and Strings, in snake_case and camelCase, so that most names
|
|
238
|
+
# are found without converting them.
|
|
239
|
+
def spellings(lookup)
|
|
240
|
+
table = {}
|
|
241
|
+
lookup.each { |name, type| table[name] = table[name.name] = type }
|
|
242
|
+
lookup.each { |name, type| table[Naming.camel(name).freeze] ||= type }
|
|
243
|
+
table.each_key.select { |name| name.is_a?(String) }.each { |name| table[name.to_sym] ||= table[name] }
|
|
244
|
+
table.freeze
|
|
245
|
+
end
|
|
246
|
+
|
|
231
247
|
def merge_types(types, additions)
|
|
232
248
|
additions.reduce(types) do |list, addition|
|
|
233
249
|
index = list.index { |type| type.type_name == addition.type_name }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Tiptap's TextAlign extension: a `textAlign` attribute, rendered as a `text-align` style. Included in the
|
|
5
|
+
# paragraphs and headings of `Schema.tiptap(text_align: true)`. A custom type includes it, and renders
|
|
6
|
+
# `style: text_align_style`.
|
|
7
|
+
module TextAlign
|
|
8
|
+
ALIGNMENTS = %w[left center right justify].freeze
|
|
9
|
+
|
|
10
|
+
def self.included(type)
|
|
11
|
+
# From the `text-align` style, or the old `align` attribute, which Tiptap doesn't read.
|
|
12
|
+
type.prop :text_align, type._Union?(*ALIGNMENTS), json: "textAlign", parse_html: lambda { |element|
|
|
13
|
+
(element.style["text-align"] || element["align"]).to_s.strip.downcase.then { |value| value if ALIGNMENTS.include?(value) }
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def text_align_style = @text_align && "text-align: #{@text_align}"
|
|
18
|
+
|
|
19
|
+
class Paragraph < Nodes::Paragraph
|
|
20
|
+
include TextAlign
|
|
21
|
+
|
|
22
|
+
def view_template(&) = p(style: text_align_style, &)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Heading < Nodes::Heading
|
|
26
|
+
include TextAlign
|
|
27
|
+
|
|
28
|
+
def view_template(&) = tag(:"h#{@level}", style: text_align_style, &)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Prosemirror
|
|
4
4
|
# Class-level declarations shared by node types and mark types.
|
|
5
5
|
module TypeDefinition
|
|
6
|
+
EMPTY_HASH = {}.freeze
|
|
6
7
|
# The name of the type, as a snake_case Symbol. It defaults to the class name (`BulletList` → `:bullet_list`),
|
|
7
8
|
# and subclasses of a concrete type keep its name.
|
|
8
9
|
def type_name(name = nil)
|
|
@@ -24,7 +25,12 @@ module Prosemirror
|
|
|
24
25
|
# - `parse_html:` a block reading the attribute from an HTML element, when importing HTML. By default,
|
|
25
26
|
# the HTML attribute with the JSON name is read.
|
|
26
27
|
def prop(name, type, kind = :keyword, json: nil, aliases: [], parse_html: nil, **, &)
|
|
27
|
-
|
|
28
|
+
json = (json || name).to_s
|
|
29
|
+
aliases = aliases.map(&:to_s)
|
|
30
|
+
# The keys accepted in JSON: each name as written, in snake_case and in camelCase.
|
|
31
|
+
keys = [json, *aliases].flat_map { |key| [key, Naming.snake(key), Naming.camel(key)] }.uniq.map(&:freeze).freeze
|
|
32
|
+
attribute_names[name] = {json:, aliases:, parse_html:, keys:}.freeze
|
|
33
|
+
@known_keys = nil
|
|
28
34
|
super(name, type, kind, **, &)
|
|
29
35
|
end
|
|
30
36
|
|
|
@@ -54,7 +60,10 @@ module Prosemirror
|
|
|
54
60
|
# Invalid values are converted when the meaning is clear, or replaced by their default.
|
|
55
61
|
# Returns nil when a required attribute is missing or invalid.
|
|
56
62
|
def build_attrs(given, report:)
|
|
57
|
-
given = given.is_a?(Hash)
|
|
63
|
+
given = if !given.is_a?(Hash) then EMPTY_HASH
|
|
64
|
+
elsif keys?(given) { |key| key.is_a?(String) } then given
|
|
65
|
+
else given.transform_keys(&:to_s)
|
|
66
|
+
end
|
|
58
67
|
|
|
59
68
|
literal_properties.each_with_object({}) do |property, attrs|
|
|
60
69
|
json_name = attribute_names.dig(property.name, :json)
|
|
@@ -86,14 +95,19 @@ module Prosemirror
|
|
|
86
95
|
|
|
87
96
|
# The JSON attributes this type doesn't declare, with String keys, so that `to_h` gives them back.
|
|
88
97
|
def unknown_attrs(given)
|
|
89
|
-
return
|
|
98
|
+
return EMPTY_HASH unless given.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
known = known_keys
|
|
101
|
+
return EMPTY_HASH if keys?(given) { |key| known.include?(key.is_a?(Symbol) ? key.name : key) }
|
|
90
102
|
|
|
91
|
-
known = attribute_names.values.flat_map do |names|
|
|
92
|
-
[names[:json], *names[:aliases]].flat_map { |name| [name, Naming.snake(name), Naming.camel(name)] }
|
|
93
|
-
end
|
|
94
103
|
given.transform_keys(&:to_s).except(*known)
|
|
95
104
|
end
|
|
96
105
|
|
|
106
|
+
# The JSON keys of the declared attributes, in all their spellings.
|
|
107
|
+
def known_keys
|
|
108
|
+
@known_keys ||= attribute_names.each_value.flat_map { |names| names[:keys] }.to_set.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
97
111
|
# Attributes as JSON, with their JSON names.
|
|
98
112
|
def serialize_attrs(attrs)
|
|
99
113
|
attrs.to_h { |name, value| [attribute_names.dig(name, :json) || name.to_s, value] }
|
|
@@ -122,17 +136,23 @@ module Prosemirror
|
|
|
122
136
|
superclass.send(:inherited_setting, ivar, default)
|
|
123
137
|
end
|
|
124
138
|
|
|
139
|
+
# Whether the block is true for all keys of the hash, without the Enumerator `each_key.all?` allocates.
|
|
140
|
+
def keys?(hash)
|
|
141
|
+
hash.each_key { |key| return false unless yield(key) }
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
125
145
|
def attribute_key(given, names)
|
|
126
|
-
|
|
127
|
-
[name, Naming.snake(name), Naming.camel(name)].each do |candidate|
|
|
128
|
-
return candidate if given.key?(candidate)
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
nil
|
|
146
|
+
names[:keys].find { |key| given.key?(key) }
|
|
132
147
|
end
|
|
133
148
|
|
|
149
|
+
# The gem's coercions don't use `self`: they are called directly, without Literal's `instance_exec`.
|
|
134
150
|
def coerce_attribute(property, value)
|
|
135
|
-
|
|
151
|
+
coercion = property.coercion
|
|
152
|
+
if coercion.nil? then value
|
|
153
|
+
elsif Coerce::PLAIN.include?(coercion) then coercion.call(value)
|
|
154
|
+
else property.coerce(value, context: self)
|
|
155
|
+
end
|
|
136
156
|
rescue
|
|
137
157
|
value
|
|
138
158
|
end
|
data/lib/prosemirror/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: prosemirror
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hans Lemuet
|
|
@@ -84,6 +84,7 @@ files:
|
|
|
84
84
|
- lib/prosemirror/mark.rb
|
|
85
85
|
- lib/prosemirror/mark_type.rb
|
|
86
86
|
- lib/prosemirror/markdown/builder.rb
|
|
87
|
+
- lib/prosemirror/markdown_serializer.rb
|
|
87
88
|
- lib/prosemirror/marks/basic/code.rb
|
|
88
89
|
- lib/prosemirror/marks/basic/em.rb
|
|
89
90
|
- lib/prosemirror/marks/basic/link.rb
|
|
@@ -106,22 +107,31 @@ files:
|
|
|
106
107
|
- lib/prosemirror/nodes/blockquote.rb
|
|
107
108
|
- lib/prosemirror/nodes/bullet_list.rb
|
|
108
109
|
- lib/prosemirror/nodes/code_block.rb
|
|
110
|
+
- lib/prosemirror/nodes/details.rb
|
|
111
|
+
- lib/prosemirror/nodes/details_content.rb
|
|
112
|
+
- lib/prosemirror/nodes/details_summary.rb
|
|
109
113
|
- lib/prosemirror/nodes/doc.rb
|
|
110
114
|
- lib/prosemirror/nodes/hard_break.rb
|
|
111
115
|
- lib/prosemirror/nodes/heading.rb
|
|
112
116
|
- lib/prosemirror/nodes/horizontal_rule.rb
|
|
113
117
|
- lib/prosemirror/nodes/image.rb
|
|
114
118
|
- lib/prosemirror/nodes/list_item.rb
|
|
119
|
+
- lib/prosemirror/nodes/mention.rb
|
|
115
120
|
- lib/prosemirror/nodes/ordered_list.rb
|
|
116
121
|
- lib/prosemirror/nodes/paragraph.rb
|
|
117
122
|
- lib/prosemirror/nodes/table.rb
|
|
118
123
|
- lib/prosemirror/nodes/table_cell.rb
|
|
119
124
|
- lib/prosemirror/nodes/table_header.rb
|
|
120
125
|
- lib/prosemirror/nodes/table_row.rb
|
|
126
|
+
- lib/prosemirror/nodes/task_item.rb
|
|
127
|
+
- lib/prosemirror/nodes/task_list.rb
|
|
121
128
|
- lib/prosemirror/nodes/text.rb
|
|
129
|
+
- lib/prosemirror/nodes/twitch.rb
|
|
130
|
+
- lib/prosemirror/nodes/youtube.rb
|
|
122
131
|
- lib/prosemirror/parse_rule.rb
|
|
123
132
|
- lib/prosemirror/rendering.rb
|
|
124
133
|
- lib/prosemirror/schema.rb
|
|
134
|
+
- lib/prosemirror/text_align.rb
|
|
125
135
|
- lib/prosemirror/text_serializer.rb
|
|
126
136
|
- lib/prosemirror/transformer.rb
|
|
127
137
|
- lib/prosemirror/type_definition.rb
|