yrby 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 +80 -1
- data/README.md +251 -9
- data/ext/yrby/src/lexical_html.rs +1316 -0
- data/ext/yrby/src/lib.rs +224 -38
- data/ext/yrby/src/prosemirror_html.rs +736 -267
- data/ext/yrby/src/read.rs +71 -4
- data/ext/yrby/src/render_rules.rs +529 -0
- data/lib/y/lexxy.rb +100 -0
- data/lib/y/rendering.rb +282 -0
- data/lib/y/tiptap.rb +63 -0
- data/lib/y/version.rb +1 -1
- data/lib/y.rb +4 -0
- metadata +6 -1
data/lib/y/lexxy.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# The Lexxy renderer: Y::Lexical (core Lexical) plus the Lexxy-specific
|
|
5
|
+
# schema, applied beneath the app's rules — an app rule for one of these
|
|
6
|
+
# types simply replaces it. This is the byte-parity class: the fixture
|
|
7
|
+
# tests hold `Y::Lexxy.new(doc).to_html` identical to a live editor's own
|
|
8
|
+
# serialized value.
|
|
9
|
+
#
|
|
10
|
+
# The schema doubles as the reference for augmenting a renderer: simple
|
|
11
|
+
# nodes are declarative hashes, nodes with logic are plain methods mapped
|
|
12
|
+
# in NODES.
|
|
13
|
+
class Lexxy < Lexical
|
|
14
|
+
# A cursor-placement placeholder; empty ones export to nothing.
|
|
15
|
+
def self.provisional_paragraph(node)
|
|
16
|
+
node.content.empty? ? "" : "<p>#{node.content}</p>"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Adjacent previewable images; the class carries the image count
|
|
20
|
+
# (ActionText's convention).
|
|
21
|
+
def self.gallery(node)
|
|
22
|
+
%(<div class="attachment-gallery attachment-gallery--#{node.child_types.length}">#{node.content}</div>)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Lexxy wraps tables in a styled figure.
|
|
26
|
+
def self.table(node)
|
|
27
|
+
%(<figure class="lexxy-content__table-wrapper"><table><tbody>#{node.content}</tbody></table></figure>)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Class + background match Lexxy's own header-cell export.
|
|
31
|
+
def self.table_cell(node)
|
|
32
|
+
header = node.attrs["__headerState"].is_a?(Numeric) && node.attrs["__headerState"].positive?
|
|
33
|
+
return "<td>#{node.content}</td>" unless header
|
|
34
|
+
|
|
35
|
+
style = %(style="background-color: rgb(242, 243, 245);")
|
|
36
|
+
%(<th class="lexxy-content__table-cell--header" #{style}>#{node.content}</th>)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Attribute order follows Lexxy's export — checked items put aria-checked
|
|
40
|
+
# before value; items holding a nested list append the
|
|
41
|
+
# lexxy-nested-listitem class after value.
|
|
42
|
+
def self.list_item(node)
|
|
43
|
+
out = +"<li"
|
|
44
|
+
checked = node.attrs["__checked"]
|
|
45
|
+
out << %( aria-checked="#{checked}") unless checked.nil?
|
|
46
|
+
out << %( value="#{node.attrs["__value"] || 1}")
|
|
47
|
+
out << %( class="lexxy-nested-listitem") if node.child_types.include?("list")
|
|
48
|
+
"#{out}>#{node.content}</li>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# An upload, in the exact shape ActionText round-trips: attribute order
|
|
52
|
+
# and presence mirror Lexxy's exportDOM (nulls omitted, `previewable`
|
|
53
|
+
# only when true, `presentation="gallery"` always).
|
|
54
|
+
def self.upload(node)
|
|
55
|
+
out = +"<action-text-attachment"
|
|
56
|
+
out << attachment_attr(node, "sgid", "sgid")
|
|
57
|
+
out << %( previewable="true") if node.attrs["previewable"] == true
|
|
58
|
+
[%w[url src], %w[alt altText], %w[caption caption],
|
|
59
|
+
%w[content-type contentType], %w[filename fileName],
|
|
60
|
+
%w[filesize fileSize], %w[width width], %w[height height]]
|
|
61
|
+
.each { |html_name, stored| out << attachment_attr(node, html_name, stored) }
|
|
62
|
+
%(#{out} presentation="gallery"></action-text-attachment>)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A content attachment (mention, embed): `content` carries the escaped
|
|
66
|
+
# inner HTML; `plainText` is not exported.
|
|
67
|
+
def self.mention(node)
|
|
68
|
+
out = +"<action-text-attachment"
|
|
69
|
+
out << attachment_attr(node, "sgid", "sgid")
|
|
70
|
+
out << attachment_attr(node, "content", "innerHtml")
|
|
71
|
+
out << attachment_attr(node, "content-type", "contentType")
|
|
72
|
+
"#{out}></action-text-attachment>"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A stored nil (unset) is skipped; a stored empty string still emits.
|
|
76
|
+
def self.attachment_attr(node, html_name, stored)
|
|
77
|
+
return "" if node.attrs[stored].nil?
|
|
78
|
+
|
|
79
|
+
%( #{html_name}="#{RenderRules.escape_attr(node.attrs[stored])}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
NODES = {
|
|
83
|
+
# Lexxy's replacement for Lexical's CodeNode.
|
|
84
|
+
"early_escape_code" => { tag: "pre", attrs: { "data-language" => :language } },
|
|
85
|
+
"horizontal_divider" => { tag: "hr", void: true },
|
|
86
|
+
"provisonal_paragraph" => method(:provisional_paragraph), # (sic: Lexxy's spelling)
|
|
87
|
+
"image_gallery" => method(:gallery),
|
|
88
|
+
"table" => { contains: :blocks, render: method(:table) },
|
|
89
|
+
"wrapped_table_node" => { contains: :blocks, render: method(:table) },
|
|
90
|
+
"tablecell" => { contains: :blocks, render: method(:table_cell) },
|
|
91
|
+
"listitem" => { contains: :blocks, render: method(:list_item) },
|
|
92
|
+
"action_text_attachment" => method(:upload),
|
|
93
|
+
"custom_action_text_attachment" => method(:mention)
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
def initialize(doc, nodes: {}, &)
|
|
97
|
+
super(doc, nodes: NODES.merge(nodes.transform_keys(&:to_s)), &)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/y/rendering.rb
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Y
|
|
6
|
+
# Custom render rules for Y::Lexical and Y::ProseMirror.
|
|
7
|
+
#
|
|
8
|
+
# Both renderers accept a `nodes:` hash mapping a node type (Lexical's
|
|
9
|
+
# `__type`, ProseMirror's node name) to a rule; Y::ProseMirror also accepts
|
|
10
|
+
# `marks:`. A rule is consulted before the built-in schema, so it can add a
|
|
11
|
+
# custom node or override a built-in one.
|
|
12
|
+
#
|
|
13
|
+
# The usual way in is the block form — one `rules.node` call per type,
|
|
14
|
+
# keyword options for markup-as-data, a Ruby block for logic (see Builder
|
|
15
|
+
# below). `contains:` says what's inside the node: :inline (formatted
|
|
16
|
+
# text, the default), :blocks (child block nodes), or :none (a leaf). The
|
|
17
|
+
# nodes:/marks: keywords take the same rules as plain hashes, for shipping
|
|
18
|
+
# rule sets as data.
|
|
19
|
+
#
|
|
20
|
+
# Two kinds of rule:
|
|
21
|
+
#
|
|
22
|
+
# - Declarative (keyword options / a Hash): markup as data, rendered
|
|
23
|
+
# natively at full speed.
|
|
24
|
+
# Y::ProseMirror.new(doc) do |rules|
|
|
25
|
+
# rules.node "callout", tag: "aside",
|
|
26
|
+
# attrs: { "class" => ["callout callout--", :kind] },
|
|
27
|
+
# contains: :blocks
|
|
28
|
+
# end
|
|
29
|
+
# `tag` is the element; `attrs` values are templates — a String literal,
|
|
30
|
+
# a Symbol referencing one of the node's stored attributes, or an Array
|
|
31
|
+
# mixing both (an attribute that resolves empty is omitted); `text` is a
|
|
32
|
+
# template for literal text content; `void: true` emits no closing tag;
|
|
33
|
+
# `contains` declares what lives inside the node and renders there:
|
|
34
|
+
# :inline (default) for formatted text, :blocks for child block nodes
|
|
35
|
+
# (a container), :none for a leaf.
|
|
36
|
+
#
|
|
37
|
+
# - Callback (a block; in the hash form, a callable or `render:` plus
|
|
38
|
+
# `contains`):
|
|
39
|
+
# Y::Lexical.new(doc) do |rules|
|
|
40
|
+
# rules.node "video_embed" do |node|
|
|
41
|
+
# %(<video src="#{ERB::Util.html_escape(node.attrs["src"])}"></video>)
|
|
42
|
+
# end
|
|
43
|
+
# end
|
|
44
|
+
# The block runs after the document read has finished (never while the
|
|
45
|
+
# document is locked) and receives a RenderRules::Node with the node's
|
|
46
|
+
# type, stored attributes, children already rendered to HTML, and
|
|
47
|
+
# child_types (its element/block children by type). Its return value is
|
|
48
|
+
# spliced in verbatim — it is trusted HTML, so escape any attribute
|
|
49
|
+
# values you interpolate.
|
|
50
|
+
#
|
|
51
|
+
# Mark rules (ProseMirror only) are declarative: `tag` plus `attrs`
|
|
52
|
+
# templates whose Symbol refs resolve against the mark's own attributes. A
|
|
53
|
+
# custom mark wraps outside every built-in mark; several custom marks nest
|
|
54
|
+
# alphabetically. A rule for a built-in mark's stored name replaces its
|
|
55
|
+
# wrap (the markup changes, the semantics don't — an overridden code mark
|
|
56
|
+
# still excludes the other formatting).
|
|
57
|
+
module RenderRules
|
|
58
|
+
# What a callback receives. `attrs` keys are as stored (Lexical's own
|
|
59
|
+
# props keep their "__" prefix); `content` is the node's children,
|
|
60
|
+
# already rendered to an HTML string; `child_types` lists the node's
|
|
61
|
+
# element/block children by type, in document order — the structural
|
|
62
|
+
# facts attrs and content can't answer (a gallery's image count, whether
|
|
63
|
+
# a list item holds a nested list).
|
|
64
|
+
Node = Data.define(:type, :attrs, :content, :child_types)
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# Compile the user-facing config into [rules_json, callbacks]. Structural
|
|
69
|
+
# validation happens in the native parser, which raises ArgumentError.
|
|
70
|
+
def compile(nodes, marks)
|
|
71
|
+
callbacks = {}
|
|
72
|
+
spec = {}
|
|
73
|
+
unless nodes.empty?
|
|
74
|
+
spec["nodes"] = nodes.to_h do |type, rule|
|
|
75
|
+
[type.to_s, compile_node(type.to_s, rule, callbacks)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
unless marks.empty?
|
|
79
|
+
spec["marks"] = marks.to_h do |name, rule|
|
|
80
|
+
[name.to_s, compile_mark(name.to_s, rule)]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
[JSON.generate(spec), callbacks]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def compile_node(type, rule, callbacks)
|
|
87
|
+
if rule.respond_to?(:call)
|
|
88
|
+
callbacks[type] = rule
|
|
89
|
+
return { "callback" => true }
|
|
90
|
+
end
|
|
91
|
+
raise ArgumentError, "rule for #{type.inspect} must be a Hash or a callable" unless rule.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
if rule[:render]
|
|
94
|
+
callbacks[type] = rule[:render]
|
|
95
|
+
compiled = { "callback" => true }
|
|
96
|
+
compiled["content"] = rule[:contains].to_s if rule[:contains]
|
|
97
|
+
return compiled
|
|
98
|
+
end
|
|
99
|
+
compile_declarative_node(rule)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def compile_declarative_node(rule)
|
|
103
|
+
compiled = {}
|
|
104
|
+
compiled["tag"] = rule[:tag].to_s if rule[:tag]
|
|
105
|
+
compiled["void"] = true if rule[:void]
|
|
106
|
+
compiled["attrs"] = compile_attrs(rule[:attrs]) if rule[:attrs]
|
|
107
|
+
compiled["text"] = compile_parts(rule[:text]) if rule[:text]
|
|
108
|
+
compiled["content"] = rule[:contains].to_s if rule[:contains]
|
|
109
|
+
compiled
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def compile_mark(name, rule)
|
|
113
|
+
raise ArgumentError, "mark rule for #{name.inspect} must be a Hash" unless rule.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
compiled = {}
|
|
116
|
+
compiled["tag"] = rule[:tag].to_s if rule[:tag]
|
|
117
|
+
compiled["attrs"] = compile_attrs(rule[:attrs]) if rule[:attrs]
|
|
118
|
+
compiled
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def compile_attrs(attrs)
|
|
122
|
+
attrs.map { |name, template| [name.to_s, compile_parts(template)] }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A template: String literal, Symbol attribute reference, or an Array of
|
|
126
|
+
# both.
|
|
127
|
+
def compile_parts(template)
|
|
128
|
+
Array(template).map do |part|
|
|
129
|
+
case part
|
|
130
|
+
when Symbol then { "ref" => part.to_s }
|
|
131
|
+
else { "lit" => part.to_s }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The escaping the native renderers use, for blocks that build markup
|
|
137
|
+
# from stored values. Text content escapes `&`, `<`, `>` (quotes stay
|
|
138
|
+
# literal, matching the browser serializer); attribute values also
|
|
139
|
+
# escape `"`. Prefer these over ERB::Util.html_escape when byte parity
|
|
140
|
+
# with editor output matters — html_escape also rewrites apostrophes.
|
|
141
|
+
def escape_text(value)
|
|
142
|
+
value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def escape_attr(value)
|
|
146
|
+
escape_text(value).gsub('"', """)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Yielded by Y::Lexical.new / Y::ProseMirror.new: register rules one
|
|
150
|
+
# call per node type. Keyword options are the markup-as-data; a block is
|
|
151
|
+
# the logic; both together say what a callback node contains and how to
|
|
152
|
+
# render it:
|
|
153
|
+
#
|
|
154
|
+
# Y::ProseMirror.new(doc) do |rules|
|
|
155
|
+
# rules.node "callout", tag: "aside", contains: :blocks
|
|
156
|
+
# rules.node "video" do |node|
|
|
157
|
+
# %(<video src="#{RenderRules.escape_attr(node.attrs["src"])}"></video>)
|
|
158
|
+
# end
|
|
159
|
+
# rules.node "columns", contains: :blocks do |node|
|
|
160
|
+
# %(<div class="cols--#{node.child_types.length}">#{node.content}</div>)
|
|
161
|
+
# end
|
|
162
|
+
# rules.mark "comment", tag: "span", attrs: { "data-comment-id" => :id }
|
|
163
|
+
# end
|
|
164
|
+
#
|
|
165
|
+
# It compiles to the same rule hashes the nodes:/marks: keywords take, so
|
|
166
|
+
# both forms mean the same thing; the keywords remain the data form for
|
|
167
|
+
# shipping rule sets (Y::Lexxy::NODES is one).
|
|
168
|
+
class Builder
|
|
169
|
+
attr_reader :nodes, :marks
|
|
170
|
+
|
|
171
|
+
def initialize(marks_allowed:)
|
|
172
|
+
@nodes = {}
|
|
173
|
+
@marks = {}
|
|
174
|
+
@marks_allowed = marks_allowed
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def node(type, **options, &block)
|
|
178
|
+
@nodes[type.to_s] =
|
|
179
|
+
if block && options.empty?
|
|
180
|
+
block
|
|
181
|
+
elsif block
|
|
182
|
+
options.merge(render: block)
|
|
183
|
+
else
|
|
184
|
+
options
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def mark(name, **options)
|
|
189
|
+
raise ArgumentError, "marks are ProseMirror-only" unless @marks_allowed
|
|
190
|
+
|
|
191
|
+
@marks[name.to_s] = options
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Resolve callback segments depth-first, so a callback's `node.content`
|
|
196
|
+
# is finished HTML even when callback nodes nest.
|
|
197
|
+
def splice(segments, callbacks)
|
|
198
|
+
segments.map do |segment|
|
|
199
|
+
next segment if segment.is_a?(String)
|
|
200
|
+
|
|
201
|
+
type, attrs_json, content, child_types = segment
|
|
202
|
+
node = Node.new(type: type, attrs: JSON.parse(attrs_json),
|
|
203
|
+
content: splice(content, callbacks),
|
|
204
|
+
child_types: child_types)
|
|
205
|
+
callbacks.fetch(type).call(node).to_s
|
|
206
|
+
end.join
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Y::Lexical and Y::ProseMirror are plain Ruby facades over the native
|
|
211
|
+
# renderers (Y::NativeLexical / Y::NativeProseMirror, private constants):
|
|
212
|
+
# they compile the rules config, hold the callbacks, and splice deferred
|
|
213
|
+
# segments after a render. The native handle does everything else.
|
|
214
|
+
class Lexical
|
|
215
|
+
# `Y::Lexical.new(doc, nodes: { "type" => rule })` — see Y::RenderRules
|
|
216
|
+
# for the rule forms. This is core Lexical only: paragraphs, headings,
|
|
217
|
+
# quotes, code, lists, tables, links, text formatting. Editor-specific
|
|
218
|
+
# nodes arrive as rules — Y::Lexxy subclasses this with the Lexxy schema;
|
|
219
|
+
# a different Lexical editor brings its own rule set the same way.
|
|
220
|
+
def initialize(doc, nodes: {})
|
|
221
|
+
builder = RenderRules::Builder.new(marks_allowed: false)
|
|
222
|
+
yield builder if block_given?
|
|
223
|
+
nodes = nodes.transform_keys(&:to_s).merge(builder.nodes)
|
|
224
|
+
rules_json, @render_callbacks = RenderRules.compile(nodes, {})
|
|
225
|
+
@native = NativeLexical.new(doc, rules_json)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def to_html(root = nil)
|
|
229
|
+
result = root.nil? ? @native.to_html : @native.to_html(root)
|
|
230
|
+
return result unless result.is_a?(Array)
|
|
231
|
+
|
|
232
|
+
RenderRules.splice(result, @render_callbacks)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# What node types this document actually contains — the discovery aid
|
|
236
|
+
# for writing rules. Facts per type: "count", "attrs" (names as stored),
|
|
237
|
+
# "children" (child node types), "text" (whether it holds text runs),
|
|
238
|
+
# and "handled" ("builtin", "rule", or nil — nil marks the types you
|
|
239
|
+
# still need a rule for). Children plus text is how you pick contains:.
|
|
240
|
+
def node_types(root = nil)
|
|
241
|
+
json = root.nil? ? @native.node_types : @native.node_types(root)
|
|
242
|
+
json && JSON.parse(json)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
class ProseMirror
|
|
247
|
+
# `Y::ProseMirror.new(doc, nodes: {...}, marks: {...})` — see
|
|
248
|
+
# Y::RenderRules for the rule forms. This is core ProseMirror only:
|
|
249
|
+
# prosemirror-schema-basic plus the prosemirror-tables family, and the
|
|
250
|
+
# full mark set (marks are native — see `rules.mark` for overrides).
|
|
251
|
+
# Editor-specific nodes arrive as rules — Y::Tiptap subclasses this with
|
|
252
|
+
# Tiptap's extension nodes; a different ProseMirror editor brings its
|
|
253
|
+
# own rule set the same way.
|
|
254
|
+
def initialize(doc, nodes: {}, marks: {})
|
|
255
|
+
builder = RenderRules::Builder.new(marks_allowed: true)
|
|
256
|
+
yield builder if block_given?
|
|
257
|
+
nodes = nodes.transform_keys(&:to_s).merge(builder.nodes)
|
|
258
|
+
marks = marks.transform_keys(&:to_s).merge(builder.marks)
|
|
259
|
+
rules_json, @render_callbacks = RenderRules.compile(nodes, marks)
|
|
260
|
+
@native = NativeProseMirror.new(doc, rules_json)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def to_html(root = nil)
|
|
264
|
+
result = root.nil? ? @native.to_html : @native.to_html(root)
|
|
265
|
+
return result unless result.is_a?(Array)
|
|
266
|
+
|
|
267
|
+
RenderRules.splice(result, @render_callbacks)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# What node types this document actually contains — the discovery aid
|
|
271
|
+
# for writing rules. Facts per type: "count", "attrs" (names as stored),
|
|
272
|
+
# "children" (child node types), "text" (whether it holds text runs),
|
|
273
|
+
# and "handled" ("builtin", "rule", or nil — nil marks the types you
|
|
274
|
+
# still need a rule for). Children plus text is how you pick contains:.
|
|
275
|
+
def node_types(root = nil)
|
|
276
|
+
json = root.nil? ? @native.node_types : @native.node_types(root)
|
|
277
|
+
json && JSON.parse(json)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private_constant :NativeLexical, :NativeProseMirror
|
|
282
|
+
end
|
data/lib/y/tiptap.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# The Tiptap renderer: Y::ProseMirror (core ProseMirror — schema-basic plus
|
|
5
|
+
# tables) plus Tiptap's extension nodes, applied beneath the app's rules —
|
|
6
|
+
# an app rule for one of these types simply replaces it. This is the
|
|
7
|
+
# byte-parity class: the fixture tests hold `Y::Tiptap.new(doc).to_html`
|
|
8
|
+
# identical to a live editor's own `getHTML()`.
|
|
9
|
+
#
|
|
10
|
+
# Tiptap's marks (underline, highlight, sub/superscript, textStyle) render
|
|
11
|
+
# natively in the base class — mark serialization is text-run machinery
|
|
12
|
+
# the rule system can't express.
|
|
13
|
+
class Tiptap < ProseMirror
|
|
14
|
+
# Tiptap's TaskItem markup: the data-checked flag (false when unset), a
|
|
15
|
+
# label wrapping the checkbox, and the item body in a div.
|
|
16
|
+
def self.task_item(node)
|
|
17
|
+
checked = node.attrs["checked"] == true
|
|
18
|
+
out = %(<li data-checked="#{checked}" data-type="taskItem">)
|
|
19
|
+
out << %(<label><input type="checkbox")
|
|
20
|
+
out << %( checked="checked") if checked
|
|
21
|
+
out << "><span></span></label><div>"
|
|
22
|
+
"#{out}#{node.content}</div></li>"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Tiptap's Mention extension (no app-configured HTMLAttributes): data
|
|
26
|
+
# attributes when present, the suggestion char, and @label (falling back
|
|
27
|
+
# to @id) as the text.
|
|
28
|
+
def self.mention(node)
|
|
29
|
+
char = node.attrs["mentionSuggestionChar"] || "@"
|
|
30
|
+
out = +%(<span data-type="mention")
|
|
31
|
+
%w[id label].each do |key|
|
|
32
|
+
next if node.attrs[key].nil?
|
|
33
|
+
|
|
34
|
+
out << %( data-#{key}="#{RenderRules.escape_attr(node.attrs[key])}")
|
|
35
|
+
end
|
|
36
|
+
out << %( data-mention-suggestion-char="#{RenderRules.escape_attr(char)}">)
|
|
37
|
+
out << RenderRules.escape_text(char)
|
|
38
|
+
out << RenderRules.escape_text(node.attrs["label"] || node.attrs["id"] || "")
|
|
39
|
+
"#{out}</span>"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The details family follows tiptap-php's renderHTML (the Tiptap
|
|
43
|
+
# extension is Pro-only, so there's no free getHTML() to capture
|
|
44
|
+
# against).
|
|
45
|
+
def self.details(node)
|
|
46
|
+
open = node.attrs["open"] == true ? %( open="open") : ""
|
|
47
|
+
"<details#{open}>#{node.content}</details>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
NODES = {
|
|
51
|
+
"taskList" => { tag: "ul", attrs: { "data-type" => "taskList" }, contains: :blocks },
|
|
52
|
+
"taskItem" => { contains: :blocks, render: method(:task_item) },
|
|
53
|
+
"mention" => method(:mention),
|
|
54
|
+
"details" => { contains: :blocks, render: method(:details) },
|
|
55
|
+
"detailsSummary" => { tag: "summary" },
|
|
56
|
+
"detailsContent" => { tag: "div", attrs: { "data-type" => "detailsContent" }, contains: :blocks }
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
def initialize(doc, nodes: {}, marks: {}, &)
|
|
60
|
+
super(doc, nodes: NODES.merge(nodes.transform_keys(&:to_s)), marks: marks, &)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/y/version.rb
CHANGED
data/lib/y.rb
CHANGED
|
@@ -12,6 +12,10 @@ rescue LoadError
|
|
|
12
12
|
require_relative "y/yrby"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
require_relative "y/rendering"
|
|
16
|
+
require_relative "y/lexxy"
|
|
17
|
+
require_relative "y/tiptap"
|
|
18
|
+
|
|
15
19
|
module Y
|
|
16
20
|
# Doc, Error, and the protocol module functions are defined in the Rust
|
|
17
21
|
# extension. The ActionCable integration (Y::ActionCable::Sync) lives in the
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yrby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- JP Camara
|
|
@@ -83,11 +83,16 @@ files:
|
|
|
83
83
|
- README.md
|
|
84
84
|
- ext/yrby/Cargo.toml
|
|
85
85
|
- ext/yrby/extconf.rb
|
|
86
|
+
- ext/yrby/src/lexical_html.rs
|
|
86
87
|
- ext/yrby/src/lib.rs
|
|
87
88
|
- ext/yrby/src/prosemirror_html.rs
|
|
88
89
|
- ext/yrby/src/protocol.rs
|
|
89
90
|
- ext/yrby/src/read.rs
|
|
91
|
+
- ext/yrby/src/render_rules.rs
|
|
90
92
|
- lib/y.rb
|
|
93
|
+
- lib/y/lexxy.rb
|
|
94
|
+
- lib/y/rendering.rb
|
|
95
|
+
- lib/y/tiptap.rb
|
|
91
96
|
- lib/y/version.rb
|
|
92
97
|
- lib/yrby.rb
|
|
93
98
|
homepage: https://github.com/jpcamara/yrby
|