prosemirror 0.0.1 → 0.1.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/COOKBOOK.md +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -12
- data/ROADMAP.md +211 -0
- data/lib/prosemirror/coerce.rb +17 -0
- data/lib/prosemirror/content_expression.rb +94 -0
- data/lib/prosemirror/document.rb +76 -0
- data/lib/prosemirror/html_renderer.rb +20 -0
- data/lib/prosemirror/json_parser.rb +125 -0
- data/lib/prosemirror/mark.rb +19 -0
- data/lib/prosemirror/mark_type.rb +60 -0
- data/lib/prosemirror/marks/basic/em.rb +13 -0
- data/lib/prosemirror/marks/basic/link.rb +20 -0
- data/lib/prosemirror/marks/basic/strong.rb +13 -0
- data/lib/prosemirror/marks/bold.rb +11 -0
- data/lib/prosemirror/marks/code.rb +11 -0
- data/lib/prosemirror/marks/highlight.rb +16 -0
- data/lib/prosemirror/marks/italic.rb +11 -0
- data/lib/prosemirror/marks/link.rb +21 -0
- data/lib/prosemirror/marks/strike.rb +9 -0
- data/lib/prosemirror/marks/subscript.rb +11 -0
- data/lib/prosemirror/marks/superscript.rb +11 -0
- data/lib/prosemirror/marks/underline.rb +9 -0
- data/lib/prosemirror/naming.rb +16 -0
- data/lib/prosemirror/node.rb +65 -0
- data/lib/prosemirror/node_type.rb +101 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +19 -0
- data/lib/prosemirror/nodes/basic/image.rb +22 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +19 -0
- data/lib/prosemirror/nodes/blockquote.rb +12 -0
- data/lib/prosemirror/nodes/bullet_list.rb +12 -0
- data/lib/prosemirror/nodes/code_block.rb +19 -0
- data/lib/prosemirror/nodes/doc.rb +10 -0
- data/lib/prosemirror/nodes/hard_break.rb +13 -0
- data/lib/prosemirror/nodes/heading.rb +14 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +11 -0
- data/lib/prosemirror/nodes/image.rb +21 -0
- data/lib/prosemirror/nodes/list_item.rb +11 -0
- data/lib/prosemirror/nodes/ordered_list.rb +18 -0
- data/lib/prosemirror/nodes/paragraph.rb +12 -0
- data/lib/prosemirror/nodes/table.rb +14 -0
- data/lib/prosemirror/nodes/table_cell.rb +23 -0
- data/lib/prosemirror/nodes/table_header.rb +13 -0
- data/lib/prosemirror/nodes/table_row.rb +11 -0
- data/lib/prosemirror/nodes/text.rb +10 -0
- data/lib/prosemirror/rendering.rb +70 -0
- data/lib/prosemirror/schema.rb +190 -0
- data/lib/prosemirror/text_serializer.rb +46 -0
- data/lib/prosemirror/type_definition.rb +119 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +33 -2
- metadata +101 -16
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -12
- data/Rakefile +0 -16
- data/prosemirror.gemspec +0 -35
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# A parsed content expression, such as `"paragraph block*"` or `"(table_cell | table_header){1,5}"`.
|
|
5
|
+
# See https://prosemirror.net/docs/guide/#schema.content_expressions
|
|
6
|
+
class ContentExpression
|
|
7
|
+
TOKEN = /\s*(\w+|[()|+*?]|\{\s*\d+\s*(?:,\s*\d*\s*)?\})/
|
|
8
|
+
|
|
9
|
+
attr_reader :source, :tree
|
|
10
|
+
|
|
11
|
+
def initialize(source)
|
|
12
|
+
@source = source
|
|
13
|
+
@tokens = tokenize(source)
|
|
14
|
+
@tree = parse_choice
|
|
15
|
+
raise ArgumentError, "unexpected #{@tokens.first.inspect} in content expression #{source.inspect}" if @tokens.any?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The node names and group names the expression refers to.
|
|
19
|
+
def names
|
|
20
|
+
collect_names(@tree)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def tokenize(source)
|
|
26
|
+
tokens = []
|
|
27
|
+
rest = source.strip
|
|
28
|
+
until rest.empty?
|
|
29
|
+
match = TOKEN.match(rest)
|
|
30
|
+
raise ArgumentError, "invalid content expression #{source.inspect}" unless match&.begin(0)&.zero?
|
|
31
|
+
|
|
32
|
+
tokens << match[1]
|
|
33
|
+
rest = match.post_match.strip
|
|
34
|
+
end
|
|
35
|
+
tokens
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_choice
|
|
39
|
+
branches = [parse_sequence]
|
|
40
|
+
while @tokens.first == "|"
|
|
41
|
+
@tokens.shift
|
|
42
|
+
branches << parse_sequence
|
|
43
|
+
end
|
|
44
|
+
(branches.size == 1) ? branches.first : [:choice, branches]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_sequence
|
|
48
|
+
terms = []
|
|
49
|
+
terms << parse_repeat while @tokens.any? && !["|", ")"].include?(@tokens.first)
|
|
50
|
+
raise ArgumentError, "empty content expression in #{@source.inspect}" if terms.empty?
|
|
51
|
+
|
|
52
|
+
(terms.size == 1) ? terms.first : [:sequence, terms]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_repeat
|
|
56
|
+
atom = parse_atom
|
|
57
|
+
case @tokens.first
|
|
58
|
+
when "*" then @tokens.shift && [:repeat, atom, 0, nil]
|
|
59
|
+
when "+" then @tokens.shift && [:repeat, atom, 1, nil]
|
|
60
|
+
when "?" then @tokens.shift && [:repeat, atom, 0, 1]
|
|
61
|
+
when /\A\{/ then [:repeat, atom, *parse_range(@tokens.shift)]
|
|
62
|
+
else atom
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_range(token)
|
|
67
|
+
min, comma, max = token.delete("{} ").partition(",")
|
|
68
|
+
return [min.to_i, min.to_i] if comma.empty?
|
|
69
|
+
|
|
70
|
+
[min.to_i, max.empty? ? nil : max.to_i]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_atom
|
|
74
|
+
token = @tokens.shift
|
|
75
|
+
case token
|
|
76
|
+
when "("
|
|
77
|
+
expression = parse_choice
|
|
78
|
+
raise ArgumentError, "missing ) in content expression #{@source.inspect}" unless @tokens.shift == ")"
|
|
79
|
+
|
|
80
|
+
expression
|
|
81
|
+
when /\A\w+\z/ then [:name, token.to_sym]
|
|
82
|
+
else raise ArgumentError, "unexpected #{token.inspect} in content expression #{@source.inspect}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def collect_names(tree)
|
|
87
|
+
case tree.first
|
|
88
|
+
when :name then [tree[1]]
|
|
89
|
+
when :repeat then collect_names(tree[1])
|
|
90
|
+
else tree[1].flat_map { |child| collect_names(child) }.uniq
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# A document and its schema. Enumerating a document yields all its nodes, depth first.
|
|
5
|
+
#
|
|
6
|
+
# doc = Prosemirror.parse(json)
|
|
7
|
+
# doc.to_html
|
|
8
|
+
# doc.select { |node| node in { type: :heading, attrs: { level: 1 } } }
|
|
9
|
+
class Document
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
attr_reader :root, :schema
|
|
13
|
+
|
|
14
|
+
def initialize(root, schema: Prosemirror.schema)
|
|
15
|
+
@root = root
|
|
16
|
+
@schema = schema
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def content
|
|
20
|
+
root.content
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def each(&)
|
|
24
|
+
root.descendants(&)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The document as HTML. The string is `html_safe` when ActiveSupport is loaded.
|
|
28
|
+
def to_html
|
|
29
|
+
html = to_phlex.call
|
|
30
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A Phlex component rendering the document, to render it inside another Phlex component:
|
|
34
|
+
#
|
|
35
|
+
# render doc.to_phlex
|
|
36
|
+
def to_phlex
|
|
37
|
+
HTMLRenderer.new(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Lets Rails render the document in ERB templates and ViewComponents: `<%= render doc %>`.
|
|
41
|
+
def render_in(_view_context, &)
|
|
42
|
+
to_html
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The document as plain text, with blocks separated by `block_separator`.
|
|
46
|
+
def to_text(block_separator: "\n\n")
|
|
47
|
+
TextSerializer.new(schema, block_separator: block_separator).call(root)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The document as JSON, as a Hash.
|
|
51
|
+
def to_h
|
|
52
|
+
schema.serialize(root)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def as_json(*)
|
|
56
|
+
to_h
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_json(*)
|
|
60
|
+
to_h.to_json(*)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def text_content
|
|
64
|
+
root.text_content
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ==(other)
|
|
68
|
+
other.is_a?(Document) && root == other.root
|
|
69
|
+
end
|
|
70
|
+
alias_method :eql?, :==
|
|
71
|
+
|
|
72
|
+
def hash
|
|
73
|
+
root.hash
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Renders a document to HTML. Use `doc.to_phlex` to render it inside another Phlex component:
|
|
5
|
+
#
|
|
6
|
+
# render doc.to_phlex
|
|
7
|
+
class HTMLRenderer < Phlex::HTML
|
|
8
|
+
include Rendering
|
|
9
|
+
|
|
10
|
+
def initialize(document)
|
|
11
|
+
super()
|
|
12
|
+
@document = document
|
|
13
|
+
@_prosemirror_schema = document.schema
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def view_template
|
|
17
|
+
render_content([@document.root])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Builds nodes from ProseMirror JSON, and normalizes them as ProseMirror does:
|
|
5
|
+
# marks sorted in schema order, adjacent text with the same marks joined, empty text removed.
|
|
6
|
+
# Invalid parts are fixed and reported to the schema (which raises in strict mode).
|
|
7
|
+
class JSONParser
|
|
8
|
+
MAX_DEPTH = 100
|
|
9
|
+
|
|
10
|
+
def initialize(schema)
|
|
11
|
+
@schema = schema
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(json)
|
|
15
|
+
hash = json.is_a?(String) ? JSON.parse(json, max_nesting: (MAX_DEPTH * 2) + 2) : json
|
|
16
|
+
raise ArgumentError, "expected a Hash or a JSON String, got #{json.class}" unless hash.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
root = build_node(hash, "doc", 0) || Node.new(type: @schema.top_node)
|
|
19
|
+
return root if root.type == @schema.top_node
|
|
20
|
+
|
|
21
|
+
@schema.report("the top node is #{root.type}, not #{@schema.top_node}", path: "doc")
|
|
22
|
+
Node.new(type: @schema.top_node, content: [root])
|
|
23
|
+
rescue JSON::NestingError
|
|
24
|
+
raise InvalidDocument, "the document is nested more than #{MAX_DEPTH} levels deep"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_node(hash, path, depth)
|
|
30
|
+
return invalid("not a node: #{hash.inspect}", path) unless hash.is_a?(Hash)
|
|
31
|
+
return invalid("nested more than #{MAX_DEPTH} levels deep", path) if depth > MAX_DEPTH
|
|
32
|
+
|
|
33
|
+
name = fetch(hash, "type")
|
|
34
|
+
return invalid("missing node type", path) unless name.is_a?(String) || name.is_a?(Symbol)
|
|
35
|
+
return build_text(hash, path) if name.to_s == "text"
|
|
36
|
+
|
|
37
|
+
node_type = @schema.node_type(name)
|
|
38
|
+
return build_unknown(hash, name, path, depth) unless node_type
|
|
39
|
+
|
|
40
|
+
given = fetch(hash, "attrs")
|
|
41
|
+
attrs = node_type.build_attrs(given, report: reporter(path))
|
|
42
|
+
return invalid("removed #{name} node", path) unless attrs
|
|
43
|
+
|
|
44
|
+
Node.new(type: node_type.type_name, attrs: attrs, unknown_attrs: node_type.unknown_attrs(given),
|
|
45
|
+
content: build_content(hash, path, depth), marks: build_marks(fetch(hash, "marks"), path))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_text(hash, path)
|
|
49
|
+
text = fetch(hash, "text")
|
|
50
|
+
return invalid("text node without text", path) unless text.is_a?(String)
|
|
51
|
+
return nil if text.empty?
|
|
52
|
+
|
|
53
|
+
Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks"), path))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_unknown(hash, name, path, depth)
|
|
57
|
+
invalid("unknown node type #{name}", path)
|
|
58
|
+
attrs = fetch(hash, "attrs")
|
|
59
|
+
Node.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {},
|
|
60
|
+
content: build_content(hash, path, depth), marks: build_marks(fetch(hash, "marks"), path))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_content(hash, path, depth)
|
|
64
|
+
content = fetch(hash, "content")
|
|
65
|
+
return [] if content.nil?
|
|
66
|
+
return invalid("content is not a list", path) || [] unless content.is_a?(Array)
|
|
67
|
+
|
|
68
|
+
nodes = content.each_with_index.filter_map do |child, index|
|
|
69
|
+
build_node(child, "#{path}.content[#{index}]", depth + 1)
|
|
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
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_marks(marks, path)
|
|
86
|
+
return [] if marks.nil?
|
|
87
|
+
return invalid("marks is not a list", path) || [] unless marks.is_a?(Array)
|
|
88
|
+
|
|
89
|
+
built = marks.each_with_index.filter_map { |mark, index| build_mark(mark, "#{path}.marks[#{index}]") }
|
|
90
|
+
built.uniq.sort_by.with_index { |mark, index| [@schema.mark_rank(mark.type), index] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_mark(hash, path)
|
|
94
|
+
return invalid("not a mark: #{hash.inspect}", path) unless hash.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
name = fetch(hash, "type")
|
|
97
|
+
return invalid("missing mark type", path) unless name.is_a?(String) || name.is_a?(Symbol)
|
|
98
|
+
|
|
99
|
+
mark_type = @schema.mark_type(name)
|
|
100
|
+
unless mark_type
|
|
101
|
+
invalid("unknown mark type #{name}", path)
|
|
102
|
+
attrs = fetch(hash, "attrs")
|
|
103
|
+
return Mark.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
given = fetch(hash, "attrs")
|
|
107
|
+
attrs = mark_type.build_attrs(given, report: reporter(path))
|
|
108
|
+
return invalid("removed #{name} mark", path) unless attrs
|
|
109
|
+
|
|
110
|
+
Mark.new(type: mark_type.type_name, attrs: attrs, unknown_attrs: mark_type.unknown_attrs(given))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def fetch(hash, key)
|
|
114
|
+
hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reporter(path)
|
|
118
|
+
->(message) { @schema.report(message, path: path) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def invalid(message, path)
|
|
122
|
+
@schema.report(message, path: path)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# A mark applied to a node, such as `bold` or `link`. Marks are immutable values.
|
|
5
|
+
#
|
|
6
|
+
# mark in { type: :link, attrs: { href: } }
|
|
7
|
+
#
|
|
8
|
+
# As for nodes, `unknown_attrs` holds the attributes the mark's type doesn't declare.
|
|
9
|
+
class Mark < Literal::Data
|
|
10
|
+
prop :type, Symbol, &Coerce::SYMBOL
|
|
11
|
+
prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
|
|
12
|
+
prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
|
|
13
|
+
|
|
14
|
+
# A copy with some properties changed.
|
|
15
|
+
def with(**changes)
|
|
16
|
+
self.class.new(**to_h, **changes)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# The base class of mark types. A mark type is a Phlex component: `view_template` renders one element
|
|
5
|
+
# and yields to render the marked content. Attributes are Literal properties.
|
|
6
|
+
#
|
|
7
|
+
# class Bold < Prosemirror::MarkType
|
|
8
|
+
# def view_template(&) = strong(&)
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# The options follow ProseMirror's MarkSpec: https://prosemirror.net/docs/ref/#model.MarkSpec
|
|
12
|
+
class MarkType < Phlex::HTML
|
|
13
|
+
extend Literal::Properties
|
|
14
|
+
extend TypeDefinition
|
|
15
|
+
include Rendering
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def base_type?
|
|
19
|
+
self == MarkType
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The marks this mark can't coexist with: mark or group names, `"_"` for all.
|
|
23
|
+
# Defaults to marks of the same type.
|
|
24
|
+
def excludes(spec = nil)
|
|
25
|
+
return @excludes = spec if spec
|
|
26
|
+
|
|
27
|
+
inherited_setting(:@excludes, nil)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def group(*names)
|
|
31
|
+
return @group = names.flat_map { |n| n.to_s.split }.map(&:to_sym) unless names.empty?
|
|
32
|
+
|
|
33
|
+
inherited_setting(:@group, [])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether one element can wrap several adjacent nodes with this mark. Defaults to true.
|
|
37
|
+
def spanning(value = nil)
|
|
38
|
+
return @spanning = value unless value.nil?
|
|
39
|
+
|
|
40
|
+
inherited_setting(:@spanning, true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def spanning?
|
|
44
|
+
spanning
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def code(value = true)
|
|
48
|
+
@code = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def code?
|
|
52
|
+
inherited_setting(:@code, false)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def view_template
|
|
57
|
+
yield if block_given?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
module Basic
|
|
6
|
+
# ProseMirror's link. Without an allowed `href`, its text is rendered without a link.
|
|
7
|
+
class Link < MarkType
|
|
8
|
+
prop :href, _String(length: 1..)
|
|
9
|
+
prop :title, _String?
|
|
10
|
+
|
|
11
|
+
def view_template(&)
|
|
12
|
+
href = safe_url(@href)
|
|
13
|
+
return yield unless href
|
|
14
|
+
|
|
15
|
+
a(href: href, title: @title, &)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
# Tiptap's highlight. `color` must be a hex, named, rgb() or hsl() color, so it is safe in a style.
|
|
6
|
+
class Highlight < MarkType
|
|
7
|
+
COLOR = %r{\A(\#\h{3,8}|[a-z]+|(rgb|hsl)a?\([\d\s.,%/]+\))\z}i
|
|
8
|
+
|
|
9
|
+
prop :color, _String?(COLOR)
|
|
10
|
+
|
|
11
|
+
def view_template(&)
|
|
12
|
+
mark(data_color: @color, style: @color && "background-color: #{@color}; color: inherit", &)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
# Tiptap's link. Without an allowed `href`, its text is rendered without a link.
|
|
6
|
+
class Link < MarkType
|
|
7
|
+
prop :href, _String(length: 1..)
|
|
8
|
+
prop :target, _String?
|
|
9
|
+
prop :rel, _String?
|
|
10
|
+
prop :css_class, _String?, json: "class"
|
|
11
|
+
prop :title, _String?
|
|
12
|
+
|
|
13
|
+
def view_template(&)
|
|
14
|
+
href = safe_url(@href)
|
|
15
|
+
return yield unless href
|
|
16
|
+
|
|
17
|
+
a(href: href, target: @target, rel: @rel, class: @css_class, title: @title, &)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Converts names between snake_case (`bullet_list`) and camelCase (`bulletList`).
|
|
5
|
+
module Naming
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def snake(name)
|
|
9
|
+
name.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def camel(name)
|
|
13
|
+
snake(name).gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# A node of a document. Nodes are immutable values, and support pattern matching:
|
|
5
|
+
#
|
|
6
|
+
# node in { type: :heading, attrs: { level: 1 } }
|
|
7
|
+
#
|
|
8
|
+
# Text nodes have the type `:text` and their content in `text`.
|
|
9
|
+
#
|
|
10
|
+
# `attrs` holds the attributes the node's type declares. `unknown_attrs` holds the others, as written
|
|
11
|
+
# in JSON (for example from a Tiptap extension the schema doesn't know): they are never rendered,
|
|
12
|
+
# and `to_h` gives them back unchanged.
|
|
13
|
+
class Node < Literal::Data
|
|
14
|
+
prop :type, Symbol, &Coerce::SYMBOL
|
|
15
|
+
prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
|
|
16
|
+
prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
|
|
17
|
+
prop :content, _Array(Node), default: [].freeze, &Coerce::FROZEN
|
|
18
|
+
prop :marks, _Array(Mark), default: [].freeze, &Coerce::FROZEN
|
|
19
|
+
prop :text, _String?, &Coerce::FROZEN
|
|
20
|
+
|
|
21
|
+
# A copy with some properties changed.
|
|
22
|
+
def with(**changes)
|
|
23
|
+
self.class.new(**to_h, **changes)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def text?
|
|
27
|
+
@type == :text
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def first_child
|
|
31
|
+
@content.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def last_child
|
|
35
|
+
@content.last
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def child_count
|
|
39
|
+
@content.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# All text in this node and its descendants, joined without separators.
|
|
43
|
+
def text_content
|
|
44
|
+
return @text if text?
|
|
45
|
+
|
|
46
|
+
descendants.select(&:text?).map(&:text).join
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Iterate over all descendants, depth first, without the node itself.
|
|
50
|
+
def descendants
|
|
51
|
+
return enum_for(:descendants) unless block_given?
|
|
52
|
+
|
|
53
|
+
stack = @content.reverse
|
|
54
|
+
until stack.empty?
|
|
55
|
+
node = stack.pop
|
|
56
|
+
yield node
|
|
57
|
+
stack.concat(node.content.reverse)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def same_markup?(other)
|
|
62
|
+
@type == other.type && @attrs == other.attrs && @unknown_attrs == other.unknown_attrs && @marks == other.marks
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|