prosemirror 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/COOKBOOK.md +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -12
- data/ROADMAP.md +215 -0
- data/TYPES.md +201 -0
- data/lib/prosemirror/coerce.rb +17 -0
- data/lib/prosemirror/content_expression.rb +94 -0
- data/lib/prosemirror/content_match.rb +241 -0
- data/lib/prosemirror/document.rb +76 -0
- data/lib/prosemirror/html/builder.rb +146 -0
- data/lib/prosemirror/html/element.rb +84 -0
- data/lib/prosemirror/html/entities.rb +87 -0
- data/lib/prosemirror/html/entities.txt +2231 -0
- data/lib/prosemirror/html/selector.rb +95 -0
- data/lib/prosemirror/html/splitter.rb +68 -0
- data/lib/prosemirror/html/text.rb +23 -0
- data/lib/prosemirror/html_parser.rb +498 -0
- data/lib/prosemirror/html_renderer.rb +20 -0
- data/lib/prosemirror/json_parser.rb +114 -0
- data/lib/prosemirror/mark.rb +19 -0
- data/lib/prosemirror/mark_type.rb +71 -0
- data/lib/prosemirror/marks/basic/code.rb +16 -0
- data/lib/prosemirror/marks/basic/em.rb +17 -0
- data/lib/prosemirror/marks/basic/link.rb +21 -0
- data/lib/prosemirror/marks/basic/strong.rb +17 -0
- data/lib/prosemirror/marks/bold.rb +19 -0
- data/lib/prosemirror/marks/code.rb +16 -0
- data/lib/prosemirror/marks/highlight.rb +18 -0
- data/lib/prosemirror/marks/italic.rb +15 -0
- data/lib/prosemirror/marks/link.rb +22 -0
- data/lib/prosemirror/marks/strike.rb +14 -0
- data/lib/prosemirror/marks/subscript.rb +13 -0
- data/lib/prosemirror/marks/superscript.rb +13 -0
- data/lib/prosemirror/marks/underline.rb +12 -0
- data/lib/prosemirror/naming.rb +16 -0
- data/lib/prosemirror/node.rb +77 -0
- data/lib/prosemirror/node_type.rb +123 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +20 -0
- data/lib/prosemirror/nodes/basic/image.rb +23 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +22 -0
- data/lib/prosemirror/nodes/blockquote.rb +13 -0
- data/lib/prosemirror/nodes/bullet_list.rb +13 -0
- data/lib/prosemirror/nodes/code_block.rb +24 -0
- data/lib/prosemirror/nodes/doc.rb +10 -0
- data/lib/prosemirror/nodes/hard_break.rb +15 -0
- data/lib/prosemirror/nodes/heading.rb +15 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +12 -0
- data/lib/prosemirror/nodes/image.rb +22 -0
- data/lib/prosemirror/nodes/list_item.rb +12 -0
- data/lib/prosemirror/nodes/ordered_list.rb +36 -0
- data/lib/prosemirror/nodes/paragraph.rb +13 -0
- data/lib/prosemirror/nodes/table.rb +15 -0
- data/lib/prosemirror/nodes/table_cell.rb +33 -0
- data/lib/prosemirror/nodes/table_header.rb +15 -0
- data/lib/prosemirror/nodes/table_row.rb +12 -0
- data/lib/prosemirror/nodes/text.rb +10 -0
- data/lib/prosemirror/parse_rule.rb +50 -0
- data/lib/prosemirror/rendering.rb +70 -0
- data/lib/prosemirror/schema.rb +241 -0
- data/lib/prosemirror/text_serializer.rb +46 -0
- data/lib/prosemirror/type_definition.rb +149 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +43 -2
- metadata +113 -16
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -12
- data/Rakefile +0 -16
- data/prosemirror.gemspec +0 -35
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
Node.join_text(nodes)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_marks(marks, path)
|
|
75
|
+
return [] if marks.nil?
|
|
76
|
+
return invalid("marks is not a list", path) || [] unless marks.is_a?(Array)
|
|
77
|
+
|
|
78
|
+
built = marks.each_with_index.filter_map { |mark, index| build_mark(mark, "#{path}.marks[#{index}]") }
|
|
79
|
+
built.uniq.sort_by.with_index { |mark, index| [@schema.mark_rank(mark.type), index] }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_mark(hash, path)
|
|
83
|
+
return invalid("not a mark: #{hash.inspect}", path) unless hash.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
name = fetch(hash, "type")
|
|
86
|
+
return invalid("missing mark type", path) unless name.is_a?(String) || name.is_a?(Symbol)
|
|
87
|
+
|
|
88
|
+
mark_type = @schema.mark_type(name)
|
|
89
|
+
unless mark_type
|
|
90
|
+
invalid("unknown mark type #{name}", path)
|
|
91
|
+
attrs = fetch(hash, "attrs")
|
|
92
|
+
return Mark.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {})
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
given = fetch(hash, "attrs")
|
|
96
|
+
attrs = mark_type.build_attrs(given, report: reporter(path))
|
|
97
|
+
return invalid("removed #{name} mark", path) unless attrs
|
|
98
|
+
|
|
99
|
+
Mark.new(type: mark_type.type_name, attrs: attrs, unknown_attrs: mark_type.unknown_attrs(given))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fetch(hash, key)
|
|
103
|
+
hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def reporter(path)
|
|
107
|
+
->(message) { @schema.report(message, path: path) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def invalid(message, path)
|
|
111
|
+
@schema.report(message, path: path)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
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,71 @@
|
|
|
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
|
+
# Whether this mark can't coexist with marks of the other type.
|
|
31
|
+
def excludes?(other)
|
|
32
|
+
case excludes
|
|
33
|
+
when nil then other.type_name == type_name
|
|
34
|
+
when "_" then true
|
|
35
|
+
else
|
|
36
|
+
names = excludes.split.map(&:to_sym)
|
|
37
|
+
names.include?(other.type_name) || names.intersect?(other.group)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def group(*names)
|
|
42
|
+
return @group = names.flat_map { |n| n.to_s.split }.map(&:to_sym) unless names.empty?
|
|
43
|
+
|
|
44
|
+
inherited_setting(:@group, [])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether one element can wrap several adjacent nodes with this mark. Defaults to true.
|
|
48
|
+
def spanning(value = nil)
|
|
49
|
+
return @spanning = value unless value.nil?
|
|
50
|
+
|
|
51
|
+
inherited_setting(:@spanning, true)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def spanning?
|
|
55
|
+
spanning
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def code(value = true)
|
|
59
|
+
@code = value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def code?
|
|
63
|
+
inherited_setting(:@code, false)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def view_template
|
|
68
|
+
yield if block_given?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
module Basic
|
|
6
|
+
# ProseMirror's inline code, which can be combined with other marks.
|
|
7
|
+
class Code < MarkType
|
|
8
|
+
code
|
|
9
|
+
|
|
10
|
+
parse_html tag: "code"
|
|
11
|
+
|
|
12
|
+
def view_template(&) = code(&)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
module Basic
|
|
6
|
+
class Em < MarkType
|
|
7
|
+
aliases :italic
|
|
8
|
+
parse_html tag: "i"
|
|
9
|
+
parse_html tag: "em"
|
|
10
|
+
parse_html style: "font-style=italic"
|
|
11
|
+
parse_html style: "font-style=normal", clear_mark: true
|
|
12
|
+
|
|
13
|
+
def view_template(&) = em(&)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
|
|
11
|
+
|
|
12
|
+
def view_template(&)
|
|
13
|
+
href = safe_url(@href)
|
|
14
|
+
return yield unless href
|
|
15
|
+
|
|
16
|
+
a(href: href, title: @title, &)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
module Basic
|
|
6
|
+
class Strong < MarkType
|
|
7
|
+
aliases :bold
|
|
8
|
+
parse_html tag: "strong"
|
|
9
|
+
parse_html(tag: "b") { |element| element.style["font-weight"] != "normal" && {} }
|
|
10
|
+
parse_html style: "font-weight=400", clear_mark: true
|
|
11
|
+
parse_html(style: "font-weight") { |value| value.match?(Bold::BOLD) && {} }
|
|
12
|
+
|
|
13
|
+
def view_template(&) = strong(&)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
class Bold < MarkType
|
|
6
|
+
# The `font-weight` values that mean bold.
|
|
7
|
+
BOLD = /\A(bold(er)?|[5-9]\d{2,})\z/
|
|
8
|
+
|
|
9
|
+
aliases :strong
|
|
10
|
+
|
|
11
|
+
parse_html tag: "strong"
|
|
12
|
+
parse_html(tag: "b") { |element| element.style["font-weight"] != "normal" && {} }
|
|
13
|
+
parse_html style: "font-weight=400", clear_mark: true
|
|
14
|
+
parse_html(style: "font-weight") { |value| value.match?(BOLD) && {} }
|
|
15
|
+
|
|
16
|
+
def view_template(&) = strong(&)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
# Tiptap's inline code, which can't be combined with other marks.
|
|
6
|
+
class Code < MarkType
|
|
7
|
+
code
|
|
8
|
+
excludes "_"
|
|
9
|
+
|
|
10
|
+
parse_html tag: "code"
|
|
11
|
+
parse_html tag: "tt"
|
|
12
|
+
|
|
13
|
+
def view_template(&) = code(&)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
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), parse_html: ->(element) { element["data-color"] || element.style["background-color"] }
|
|
10
|
+
|
|
11
|
+
parse_html tag: "mark"
|
|
12
|
+
|
|
13
|
+
def view_template(&)
|
|
14
|
+
mark(data_color: @color, style: @color && "background-color: #{@color}; color: inherit", &)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
class Italic < MarkType
|
|
6
|
+
aliases :em
|
|
7
|
+
parse_html tag: "em"
|
|
8
|
+
parse_html(tag: "i") { |element| element.style["font-style"] != "normal" && {} }
|
|
9
|
+
parse_html style: "font-style=normal", clear_mark: true
|
|
10
|
+
parse_html style: "font-style=italic"
|
|
11
|
+
|
|
12
|
+
def view_template(&) = em(&)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
|
|
13
|
+
|
|
14
|
+
def view_template(&)
|
|
15
|
+
href = safe_url(@href)
|
|
16
|
+
return yield unless href
|
|
17
|
+
|
|
18
|
+
a(href: href, target: @target, rel: @rel, class: @css_class, title: @title, &)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
class Strike < MarkType
|
|
6
|
+
parse_html tag: "s"
|
|
7
|
+
parse_html tag: "del"
|
|
8
|
+
parse_html tag: "strike"
|
|
9
|
+
parse_html(style: "text-decoration", consuming: false) { |value| value.include?("line-through") ? {} : false }
|
|
10
|
+
|
|
11
|
+
def view_template(&) = s(&)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
class Subscript < MarkType
|
|
6
|
+
excludes "superscript"
|
|
7
|
+
parse_html tag: "sub"
|
|
8
|
+
parse_html(style: "vertical-align") { |value| (value == "sub") ? {} : false }
|
|
9
|
+
|
|
10
|
+
def view_template(&) = sub(&)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
class Superscript < MarkType
|
|
6
|
+
excludes "subscript"
|
|
7
|
+
parse_html tag: "sup"
|
|
8
|
+
parse_html(style: "vertical-align") { |value| (value == "super") ? {} : false }
|
|
9
|
+
|
|
10
|
+
def view_template(&) = sup(&)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Marks
|
|
5
|
+
class Underline < MarkType
|
|
6
|
+
parse_html tag: "u"
|
|
7
|
+
parse_html(style: "text-decoration", consuming: false) { |value| value.include?("underline") ? {} : false }
|
|
8
|
+
|
|
9
|
+
def view_template(&) = u(&)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
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,77 @@
|
|
|
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
|
+
# The nodes with adjacent text nodes that have the same marks joined, as in ProseMirror.
|
|
22
|
+
def self.join_text(nodes)
|
|
23
|
+
nodes.each_with_object([]) do |node, joined|
|
|
24
|
+
previous = joined.last
|
|
25
|
+
if previous&.text? && node.text? && previous.marks == node.marks
|
|
26
|
+
joined[-1] = previous.with(text: previous.text + node.text)
|
|
27
|
+
else
|
|
28
|
+
joined << node
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A copy with some properties changed.
|
|
34
|
+
def with(**changes)
|
|
35
|
+
self.class.new(**to_h, **changes)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def text?
|
|
39
|
+
@type == :text
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def first_child
|
|
43
|
+
@content.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def last_child
|
|
47
|
+
@content.last
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def child_count
|
|
51
|
+
@content.size
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# All text in this node and its descendants, joined without separators.
|
|
55
|
+
def text_content
|
|
56
|
+
return @text if text?
|
|
57
|
+
|
|
58
|
+
descendants.select(&:text?).map(&:text).join
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Iterate over all descendants, depth first, without the node itself.
|
|
62
|
+
def descendants
|
|
63
|
+
return enum_for(:descendants) unless block_given?
|
|
64
|
+
|
|
65
|
+
stack = @content.reverse
|
|
66
|
+
until stack.empty?
|
|
67
|
+
node = stack.pop
|
|
68
|
+
yield node
|
|
69
|
+
stack.concat(node.content.reverse)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def same_markup?(other)
|
|
74
|
+
@type == other.type && @attrs == other.attrs && @unknown_attrs == other.unknown_attrs && @marks == other.marks
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# The base class of node types. A node type is a Phlex component: `view_template` renders a node,
|
|
5
|
+
# and yields to render its content. Attributes are Literal properties, available as instance variables.
|
|
6
|
+
#
|
|
7
|
+
# class Heading < Prosemirror::NodeType
|
|
8
|
+
# group :block
|
|
9
|
+
# content "inline*"
|
|
10
|
+
# prop :level, _Integer(1..6), default: 1
|
|
11
|
+
#
|
|
12
|
+
# def view_template(&) = tag(:"h#{@level}", &)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# The options follow ProseMirror's NodeSpec: https://prosemirror.net/docs/ref/#model.NodeSpec
|
|
16
|
+
class NodeType < Phlex::HTML
|
|
17
|
+
extend Literal::Properties
|
|
18
|
+
extend TypeDefinition
|
|
19
|
+
include Rendering
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def base_type?
|
|
23
|
+
self == NodeType
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The content expression, such as `"block+"`. Nodes without one are leaves.
|
|
27
|
+
def content(expression = nil)
|
|
28
|
+
return @content = ContentExpression.new(expression) if expression
|
|
29
|
+
|
|
30
|
+
inherited_setting(:@content, nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The groups this type belongs to, such as `:block` or `:inline`.
|
|
34
|
+
def group(*names)
|
|
35
|
+
return @group = names.flat_map { |n| n.to_s.split }.map(&:to_sym) unless names.empty?
|
|
36
|
+
|
|
37
|
+
inherited_setting(:@group, [])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inline(value = true)
|
|
41
|
+
@inline = value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inline?
|
|
45
|
+
inherited_setting(:@inline, false) || group.include?(:inline)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def block?
|
|
49
|
+
!inline?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def leaf?
|
|
53
|
+
content.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The marks allowed inside this node: mark or group names, `"_"` for all, `""` for none.
|
|
57
|
+
def marks(spec = nil)
|
|
58
|
+
return @marks = spec if spec
|
|
59
|
+
|
|
60
|
+
inherited_setting(:@marks, nil)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `:pre` to keep whitespace when parsing. Implied by `code`.
|
|
64
|
+
def whitespace(value = nil)
|
|
65
|
+
return @whitespace = value if value
|
|
66
|
+
|
|
67
|
+
inherited_setting(:@whitespace, code? ? :pre : :normal)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def code(value = true)
|
|
71
|
+
@code = value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def code?
|
|
75
|
+
inherited_setting(:@code, false)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The text of a leaf node in `to_text`, as a String or a block called with the node.
|
|
79
|
+
def leaf_text(text = nil, &block)
|
|
80
|
+
return @leaf_text = text || block if text || block
|
|
81
|
+
|
|
82
|
+
inherited_setting(:@leaf_text, nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def text_for(node)
|
|
86
|
+
value = leaf_text
|
|
87
|
+
value.respond_to?(:call) ? value.call(node).to_s : value.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# This inline node stands for a newline, as ProseMirror's `linebreakReplacement`: when importing
|
|
91
|
+
# HTML whose whitespace is preserved, newlines become nodes of this type.
|
|
92
|
+
def linebreak_replacement(value = true)
|
|
93
|
+
@linebreak_replacement = value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def linebreak_replacement?
|
|
97
|
+
inherited_setting(:@linebreak_replacement, false)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Whether marks of this type can be applied inside this node, according to its `marks` option.
|
|
101
|
+
def allows_mark?(mark_type, schema)
|
|
102
|
+
case marks
|
|
103
|
+
when nil then schema.content_match(self).inline_content?
|
|
104
|
+
when "_" then true
|
|
105
|
+
when "" then false
|
|
106
|
+
else
|
|
107
|
+
names = marks.split.map(&:to_sym)
|
|
108
|
+
names.include?(mark_type.type_name) || names.intersect?(mark_type.group)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The node being rendered.
|
|
114
|
+
def node
|
|
115
|
+
@_prosemirror_node
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Render the node's content when no `view_template` is defined.
|
|
119
|
+
def view_template
|
|
120
|
+
yield if block_given?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
module Basic
|
|
6
|
+
# ProseMirror's code block, without attributes.
|
|
7
|
+
class CodeBlock < NodeType
|
|
8
|
+
group :block
|
|
9
|
+
content "text*"
|
|
10
|
+
marks ""
|
|
11
|
+
code
|
|
12
|
+
parse_html tag: "pre", preserve_whitespace: :full
|
|
13
|
+
|
|
14
|
+
def view_template(&)
|
|
15
|
+
pre { code(&) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|