yrby 0.8.1 → 0.9.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 +90 -0
- data/Cargo.lock +33 -11
- data/README.md +18 -3
- data/ext/yrby/Cargo.toml +3 -1
- data/ext/yrby/crates/html-core/Cargo.toml +2 -2
- data/ext/yrby/crates/html-core/src/lib.rs +34 -3
- data/ext/yrby/crates/lexical-html/Cargo.toml +3 -3
- data/ext/yrby/crates/lexical-html/src/lib.rs +46 -16
- data/ext/yrby/crates/prosemirror-html/Cargo.toml +3 -3
- data/ext/yrby/crates/prosemirror-html/src/lib.rs +30 -5
- data/ext/yrby/src/array.rs +396 -0
- data/ext/yrby/src/lib.rs +945 -36
- data/ext/yrby/src/map.rs +374 -0
- data/ext/yrby/src/read.rs +3 -3
- data/ext/yrby/src/shared.rs +561 -0
- data/ext/yrby/src/text.rs +294 -0
- data/ext/yrby/src/xml_text.rs +749 -0
- data/lib/y/anchor.rb +34 -0
- data/lib/y/collaborative/attribute.rb +36 -0
- data/lib/y/collaborative/helper.rb +13 -10
- data/lib/y/collaborative.rb +14 -11
- data/lib/y/doc.rb +36 -0
- data/lib/y/lexical_markdown.rb +175 -0
- data/lib/y/lexical_writer.rb +142 -0
- data/lib/y/lexxy.rb +25 -0
- data/lib/y/rendering.rb +3 -3
- data/lib/y/text.rb +13 -0
- data/lib/y/version.rb +1 -1
- data/lib/y/xml_text.rb +36 -0
- data/lib/y.rb +6 -0
- metadata +12 -1
data/lib/y/anchor.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Y
|
|
6
|
+
# A block's identity as a plain value. A live handle already stays on its
|
|
7
|
+
# block while people edit, but it lives in one process; an anchor can be
|
|
8
|
+
# stored and sent, then turned back into the block's ordinal with
|
|
9
|
+
# `Doc#block_at(anchor)` or into a handle with `Doc#find(anchor)`. It is a
|
|
10
|
+
# Yjs relative position that names the block itself, plus the root's name.
|
|
11
|
+
#
|
|
12
|
+
# anchor = block.anchor
|
|
13
|
+
# doc.block_at(anchor) # => 3 (the block's ordinal now), or nil once it is gone
|
|
14
|
+
# doc.find(anchor) # => the live handle, or nil
|
|
15
|
+
# Y::Anchor.from_json(anchor.to_json) == anchor
|
|
16
|
+
Anchor = Data.define(:position, :root) do
|
|
17
|
+
def self.from_h(hash)
|
|
18
|
+
hash = hash.transform_keys(&:to_s)
|
|
19
|
+
new(position: hash.fetch("position").transform_keys(&:to_s), root: hash.fetch("root"))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_json(json)
|
|
23
|
+
from_h(JSON.parse(json))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ "position" => position, "root" => root }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_json(*)
|
|
31
|
+
to_h.to_json(*)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -22,6 +22,42 @@ module Y
|
|
|
22
22
|
# this to return before it acknowledges or broadcasts the change.
|
|
23
23
|
def append(update) = document_row.append(update)
|
|
24
24
|
|
|
25
|
+
# A signed grant for this document, what a page or a Ruby client
|
|
26
|
+
# subscribes with. It's a signed GlobalID scoped to this attribute, and
|
|
27
|
+
# Y::Collaborative.locate turns it back into the record. Pass
|
|
28
|
+
# expires_in: to limit how long it lasts. Without it GlobalID's own
|
|
29
|
+
# default applies, which is one month under Rails. The option goes
|
|
30
|
+
# through only when it's given, because an explicit nil would mean
|
|
31
|
+
# "never expire".
|
|
32
|
+
def grant(expires_in: nil)
|
|
33
|
+
options = { for: Y::Collaborative.sgid_purpose(name) }
|
|
34
|
+
options[:expires_in] = expires_in if expires_in
|
|
35
|
+
record.to_sgid(**options).to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The channel that accepts this document's grant. A gem with its own
|
|
39
|
+
# grants and channel, such as lexxy-realtime, returns its own document
|
|
40
|
+
# with both.
|
|
41
|
+
def channel = "Y::DocumentChannel"
|
|
42
|
+
|
|
43
|
+
# Edits the document from Ruby the way a browser does: yields the
|
|
44
|
+
# current document, records what the block changed, and broadcasts it so
|
|
45
|
+
# open editors apply it. Returns the update, or nil when the block
|
|
46
|
+
# changed nothing.
|
|
47
|
+
#
|
|
48
|
+
# post.collaborative_document(:body).edit do |doc|
|
|
49
|
+
# Y::Lexxy.append_paragraph(doc, "Reviewed by ops.")
|
|
50
|
+
# end
|
|
51
|
+
def edit
|
|
52
|
+
doc = y_doc
|
|
53
|
+
update = doc.edit { yield doc }
|
|
54
|
+
return unless update
|
|
55
|
+
|
|
56
|
+
append(update)
|
|
57
|
+
Y::ActionCable.broadcast(key, update)
|
|
58
|
+
update
|
|
59
|
+
end
|
|
60
|
+
|
|
25
61
|
# Builds a new Y::Doc from storage on every call.
|
|
26
62
|
def y_doc
|
|
27
63
|
Y::Doc.new.tap do |doc|
|
|
@@ -9,15 +9,18 @@ module Y
|
|
|
9
9
|
#
|
|
10
10
|
# renders the auto-connecting element:
|
|
11
11
|
#
|
|
12
|
-
# <yrby-document grant="<signed
|
|
12
|
+
# <yrby-document channel="Y::DocumentChannel" grant="<signed grant>" name="body"></yrby-document>
|
|
13
13
|
#
|
|
14
14
|
# Importing "yrby-client/element" registers the element. It subscribes to
|
|
15
|
-
#
|
|
15
|
+
# the document's channel by itself and exposes the synced Y.Doc through its
|
|
16
16
|
# `doc` property and `yrby:synced` event, much like the element behind
|
|
17
17
|
# turbo_stream_from.
|
|
18
18
|
#
|
|
19
|
-
# The grant
|
|
20
|
-
#
|
|
19
|
+
# The grant and channel come from record.collaborative_document(name), so
|
|
20
|
+
# the tag works for any gem that returns its own document, such as
|
|
21
|
+
# lexxy-realtime. Pass channel: for an app's own Y::DocumentChannel
|
|
22
|
+
# subclass. The grant is scoped to this record and attribute. Only render
|
|
23
|
+
# the tag on pages where
|
|
21
24
|
# the user may edit the record, because by default anyone holding the
|
|
22
25
|
# grant can open the document. To also check the user's current
|
|
23
26
|
# permissions when they subscribe, use Y::DocumentChannel.authorize_document.
|
|
@@ -30,18 +33,18 @@ module Y
|
|
|
30
33
|
# expires_in: limits how long the grant lasts. refresh: is a URL the
|
|
31
34
|
# element fetches a new grant from when a subscription is rejected, so it
|
|
32
35
|
# can resubscribe without a page load. The app's action there re-runs its
|
|
33
|
-
# own authorization and renders { grant: record.
|
|
36
|
+
# own authorization and renders { grant: record.collaborative_document(name).grant }:
|
|
34
37
|
#
|
|
35
38
|
# <%= collaborative_document_tag @post, :body, expires_in: 10.minutes,
|
|
36
39
|
# refresh: grant_post_path(@post) %>
|
|
37
40
|
module Helper
|
|
38
41
|
def collaborative_document_tag(record, name, expires_in: nil, refresh: nil, **, &)
|
|
39
|
-
|
|
40
|
-
attributes = { grant: grant, name: name }
|
|
42
|
+
document = record.collaborative_document(name)
|
|
43
|
+
attributes = { grant: document.grant(expires_in:), name: name }
|
|
41
44
|
attributes[:refresh] = refresh if refresh
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
tag.yrby_document(**, **attributes, &)
|
|
45
|
+
# A later key wins in a keyword splat. A template can pass its own
|
|
46
|
+
# channel, but it can't override the grant or the name.
|
|
47
|
+
tag.yrby_document(channel: document.channel, **, **attributes, &)
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
end
|
data/lib/y/collaborative.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/concern"
|
|
4
|
+
require "active_support/deprecation"
|
|
4
5
|
require "global_id"
|
|
6
|
+
require "y/action_cable"
|
|
5
7
|
|
|
6
8
|
module Y
|
|
7
9
|
# The signed token that connects a page to a channel for record-backed
|
|
@@ -12,7 +14,7 @@ module Y
|
|
|
12
14
|
# record up from it. The token is a signed GlobalID scoped to one attribute.
|
|
13
15
|
#
|
|
14
16
|
# # the view
|
|
15
|
-
# tag.div data: { grant: post.
|
|
17
|
+
# tag.div data: { grant: post.collaborative_document(:body).grant }
|
|
16
18
|
#
|
|
17
19
|
# # the channel
|
|
18
20
|
# def authorized?(_key)
|
|
@@ -28,6 +30,10 @@ module Y
|
|
|
28
30
|
#
|
|
29
31
|
# The engine includes this into ActiveRecord::Base so any channel's
|
|
30
32
|
# authorized? can use it. lexxy-realtime uses the same token flow.
|
|
33
|
+
# Warns about yrby-rails methods that are going away. The engine adds it to
|
|
34
|
+
# Rails.application.deprecators, so the app's deprecation settings apply.
|
|
35
|
+
def self.deprecator = @deprecator ||= ActiveSupport::Deprecation.new("0.9", "yrby-rails")
|
|
36
|
+
|
|
31
37
|
module Collaborative
|
|
32
38
|
extend ActiveSupport::Concern
|
|
33
39
|
|
|
@@ -39,7 +45,7 @@ module Y
|
|
|
39
45
|
# shipped one can both resolve the same tokens for an attribute.
|
|
40
46
|
def sgid_purpose(name) = "yrby/#{name}"
|
|
41
47
|
|
|
42
|
-
# Looks up the record for a
|
|
48
|
+
# Looks up the record for a grant from `collaborative_document(name).grant`.
|
|
43
49
|
# Returns nil for an invalid, tampered, expired, or wrong-attribute
|
|
44
50
|
# token, and for a record that no longer exists.
|
|
45
51
|
def locate(sgid, name)
|
|
@@ -76,20 +82,17 @@ module Y
|
|
|
76
82
|
end
|
|
77
83
|
|
|
78
84
|
# Returns the document for one attribute, stored the way the model
|
|
79
|
-
# declared. It responds to load_state, append, y_doc,
|
|
85
|
+
# declared. It responds to load_state, append, edit, y_doc, key, grant,
|
|
86
|
+
# and channel.
|
|
80
87
|
def collaborative_document(name)
|
|
81
88
|
Attribute.new(self, name)
|
|
82
89
|
end
|
|
83
90
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# long the grant lasts. Without it GlobalID's own default applies, which is
|
|
87
|
-
# one month under Rails. We pass the option through only when it's given,
|
|
88
|
-
# because an explicit nil would mean "never expire".
|
|
91
|
+
# Deprecated. Use collaborative_document(name).grant. This one always
|
|
92
|
+
# signs yrby's own grant, as it did before.
|
|
89
93
|
def collaborative_sgid(name, expires_in: nil)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
to_sgid(**options).to_s
|
|
94
|
+
Y.deprecator.warn("collaborative_sgid(name) is deprecated. Use collaborative_document(name).grant instead.")
|
|
95
|
+
Attribute.new(self, name).grant(expires_in:)
|
|
93
96
|
end
|
|
94
97
|
end
|
|
95
98
|
end
|
data/lib/y/doc.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# Ruby-side conveniences over the native document.
|
|
5
|
+
class Doc
|
|
6
|
+
# Runs the block, which edits this document through handles, and returns
|
|
7
|
+
# the update its writes produced, or nil when the document didn't change.
|
|
8
|
+
# The document has the edit either way. The update is what a process
|
|
9
|
+
# saves and broadcasts, one call per chunk as it streams into a document.
|
|
10
|
+
#
|
|
11
|
+
# The update holds everything the document gained while the block ran,
|
|
12
|
+
# so a write another thread or peer made meanwhile is in it too. Applying
|
|
13
|
+
# an update twice changes nothing, so that only adds bytes.
|
|
14
|
+
def edit
|
|
15
|
+
before = native_snapshot
|
|
16
|
+
yield self
|
|
17
|
+
native_update_since(before)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The ordinal of the top-level block a position falls in, or nil at the
|
|
21
|
+
# root or once the block is gone. Takes an `Anchor`, or a raw relative
|
|
22
|
+
# position hash (`{type, tname, item, assoc}`, the shape editors put in
|
|
23
|
+
# awareness as `anchorPos`/`focusPos`) plus the root's name.
|
|
24
|
+
def block_at(position, root = nil)
|
|
25
|
+
return native_block_at(position.position, position.root) if position.is_a?(Anchor)
|
|
26
|
+
raise ArgumentError, "block_at needs the root name with a raw position" unless root
|
|
27
|
+
|
|
28
|
+
native_block_at(position, root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The block an anchor names, as a live handle, or nil once it is gone.
|
|
32
|
+
def find(anchor)
|
|
33
|
+
native_find(anchor.position, anchor.root)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
class Lexical
|
|
5
|
+
# The subset of Markdown a model writes: headings, paragraphs, bulleted
|
|
6
|
+
# and numbered lists, quotes, fenced code, and inline bold, italic, code,
|
|
7
|
+
# and links. Appended as Lexical blocks, so model output arrives looking
|
|
8
|
+
# like what a person would have typed.
|
|
9
|
+
module Markdown
|
|
10
|
+
HEADING = /\A(\#{1,6})\s+(.*)\z/
|
|
11
|
+
BULLET = /\A[-*]\s+(.*)\z/
|
|
12
|
+
NUMBERED = /\A\d+[.)]\s+(.*)\z/
|
|
13
|
+
QUOTE = /\A>\s?(.*)\z/
|
|
14
|
+
FENCE = /\A```\s*([^\s`]*)[^`]*\z/ # the language, then any other info
|
|
15
|
+
INLINE = /
|
|
16
|
+
( \*\*(.+?)\*\* # bold
|
|
17
|
+
| `([^`]+)` # code
|
|
18
|
+
| \[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\) # link: its URL may hold balanced parentheses
|
|
19
|
+
| (?<![*\w])\*([^*]+)\*(?!\w) # italic
|
|
20
|
+
| (?<!\w)_([^_]+)_(?!\w) # italic
|
|
21
|
+
)
|
|
22
|
+
/x
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Append `markdown` to `doc` through `flavor` (Y::Lexical or Y::Lexxy).
|
|
27
|
+
def append(flavor, doc, markdown, root: "root")
|
|
28
|
+
blocks(markdown.to_s).each do |kind, payload, extra|
|
|
29
|
+
case kind
|
|
30
|
+
when :heading then flavor.append_heading(doc, runs(payload), tag: "h#{extra}", root: root)
|
|
31
|
+
when :paragraph then flavor.append_paragraph(doc, runs(payload), root: root)
|
|
32
|
+
when :quote then flavor.append_quote(doc, runs(payload), root: root)
|
|
33
|
+
when :code then flavor.append_code(doc, payload, language: extra, root: root)
|
|
34
|
+
when :list then flavor.append_list(doc, payload.map { |line| runs(line) }, ordered: extra, root: root)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Lines into [kind, payload, extra] blocks.
|
|
40
|
+
def blocks(text)
|
|
41
|
+
parser = Blocks.new
|
|
42
|
+
text.each_line(chomp: true) { |line| parser.feed(line) }
|
|
43
|
+
parser.finish
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A line-by-line parser. Prose lines gather into a paragraph, quote
|
|
47
|
+
# lines into a quote, and list lines into a list until something else
|
|
48
|
+
# ends them; a fence gathers lines verbatim until it closes.
|
|
49
|
+
class Blocks
|
|
50
|
+
def initialize
|
|
51
|
+
@out = []
|
|
52
|
+
@para = []
|
|
53
|
+
@quote = nil
|
|
54
|
+
@list = nil
|
|
55
|
+
@fence = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def feed(line)
|
|
59
|
+
return fenced(line) if @fence
|
|
60
|
+
|
|
61
|
+
if (m = FENCE.match(line)) then open_fence(m[1])
|
|
62
|
+
elsif (m = HEADING.match(line)) then block(:heading, m[2], m[1].length)
|
|
63
|
+
elsif (m = BULLET.match(line)) then item(m[1], ordered: false)
|
|
64
|
+
elsif (m = NUMBERED.match(line)) then item(m[1], ordered: true)
|
|
65
|
+
elsif (m = QUOTE.match(line)) then quote(m[1])
|
|
66
|
+
elsif line.strip.empty? then close_all
|
|
67
|
+
else
|
|
68
|
+
close_list
|
|
69
|
+
close_quote
|
|
70
|
+
@para << line.strip
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def finish
|
|
75
|
+
close_fence if @fence
|
|
76
|
+
close_all
|
|
77
|
+
@out
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def fenced(line)
|
|
83
|
+
return close_fence if line.start_with?("```")
|
|
84
|
+
|
|
85
|
+
@fence[:lines] << line
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def open_fence(lang)
|
|
89
|
+
close_all
|
|
90
|
+
@fence = { lang: lang.empty? ? "plain" : lang, lines: [] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def close_fence
|
|
94
|
+
@out << [:code, @fence[:lines].join("\n"), @fence[:lang]]
|
|
95
|
+
@fence = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def block(kind, payload, extra = nil)
|
|
99
|
+
close_all
|
|
100
|
+
@out << [kind, payload, extra]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def item(payload, ordered:)
|
|
104
|
+
close_para
|
|
105
|
+
close_quote
|
|
106
|
+
close_list if @list && @list[:ordered] != ordered
|
|
107
|
+
(@list ||= { ordered: ordered, items: [] })[:items] << payload
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def quote(line)
|
|
111
|
+
close_para
|
|
112
|
+
close_list
|
|
113
|
+
@quote ||= []
|
|
114
|
+
@quote << line.strip unless line.strip.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def close_quote
|
|
118
|
+
@out << [:quote, @quote.join(" ")] if @quote&.any?
|
|
119
|
+
@quote = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def close_para
|
|
123
|
+
@out << [:paragraph, @para.join(" ")] unless @para.empty?
|
|
124
|
+
@para = []
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def close_list
|
|
128
|
+
@out << [:list, @list[:items], @list[:ordered]] if @list
|
|
129
|
+
@list = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def close_all
|
|
133
|
+
close_para
|
|
134
|
+
close_quote
|
|
135
|
+
close_list
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Inline markdown into runs: strings and { text:, bold:/italic:/code:/link: }.
|
|
140
|
+
def runs(line)
|
|
141
|
+
out = []
|
|
142
|
+
rest = line
|
|
143
|
+
while (m = INLINE.match(rest))
|
|
144
|
+
out << m.pre_match unless m.pre_match.empty?
|
|
145
|
+
out << if m[2] then { text: m[2], bold: true }
|
|
146
|
+
elsif m[3] then { text: m[3], code: true }
|
|
147
|
+
elsif m[4] then safe_url?(m[5]) ? { text: m[4], link: m[5] } : m[4]
|
|
148
|
+
else { text: m[6] || m[7], italic: true }
|
|
149
|
+
end
|
|
150
|
+
rest = m.post_match
|
|
151
|
+
end
|
|
152
|
+
out << rest unless rest.empty?
|
|
153
|
+
out
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The URL schemes a Markdown link may use, as Lexical's own link node
|
|
157
|
+
# allows them. A URL without a scheme is a path and stays.
|
|
158
|
+
SAFE_SCHEMES = %w[http https mailto sms tel].freeze
|
|
159
|
+
|
|
160
|
+
# Whether a model's link is safe to write: a `javascript:` URL becomes
|
|
161
|
+
# plain text. Browsers ignore whitespace and control characters inside
|
|
162
|
+
# a scheme, so those don't hide one.
|
|
163
|
+
def safe_url?(url)
|
|
164
|
+
scheme = url.gsub(/[\x00-\x20\x7f]/, "")[/\A([a-z][a-z0-9+.-]*):/i, 1]
|
|
165
|
+
scheme.nil? || SAFE_SCHEMES.include?(scheme.downcase)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Append a subset of Markdown as Lexical blocks. Call it on the flavor that
|
|
170
|
+
# matches the editor (`Y::Lexxy.append_markdown` for a Lexxy editor).
|
|
171
|
+
def self.append_markdown(doc, markdown, root: "root")
|
|
172
|
+
Markdown.append(self, doc, markdown, root: root)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# Writing to a Lexical document from Ruby.
|
|
5
|
+
#
|
|
6
|
+
# Lexical keeps its document in a root `XmlText`. Each block is an `XmlText`
|
|
7
|
+
# embedded in it, carrying the node's properties as attributes. Inside a
|
|
8
|
+
# block, a text node is an embedded marker (a map of its properties)
|
|
9
|
+
# followed by the characters; a run of formatted text is its own marker and
|
|
10
|
+
# characters. These helpers build exactly that shape, so what Ruby writes
|
|
11
|
+
# renders the same as what a person typed, and an open editor applies it as
|
|
12
|
+
# an ordinary remote edit. Each helper writes its block in one transaction,
|
|
13
|
+
# so another thread or a peer never sees a block without its text, or text
|
|
14
|
+
# under the wrong marker.
|
|
15
|
+
class Lexical
|
|
16
|
+
ELEMENT_ATTRIBUTES = {
|
|
17
|
+
"__format" => 0, "__style" => "", "__indent" => 0, "__dir" => nil,
|
|
18
|
+
"__textFormat" => 0, "__textStyle" => ""
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
PARAGRAPH_ATTRIBUTES = { "__type" => "paragraph", **ELEMENT_ATTRIBUTES }.freeze
|
|
22
|
+
|
|
23
|
+
TEXT_ATTRIBUTES = {
|
|
24
|
+
"__type" => "text", "__format" => 0, "__style" => "", "__mode" => 0, "__detail" => 0
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Lexical's text format bits.
|
|
28
|
+
FORMAT = { bold: 1, italic: 2, strikethrough: 4, underline: 8, code: 16 }.freeze
|
|
29
|
+
|
|
30
|
+
# A heading block: `tag` is "h1".."h6".
|
|
31
|
+
def self.heading_attributes(tag)
|
|
32
|
+
{ "__type" => "heading", "__tag" => tag.to_s, **ELEMENT_ATTRIBUTES }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.quote_attributes = { "__type" => "quote", **ELEMENT_ATTRIBUTES }
|
|
36
|
+
|
|
37
|
+
# A code block. Its text nodes are `code-highlight` nodes.
|
|
38
|
+
def self.code_attributes(language: "plain")
|
|
39
|
+
{ "__type" => "code", "__language" => language.to_s, **ELEMENT_ATTRIBUTES }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
CODE_TEXT_ATTRIBUTES = { **TEXT_ATTRIBUTES, "__type" => "code-highlight" }.freeze
|
|
43
|
+
|
|
44
|
+
# A text node's marker with a format from FORMAT keys, e.g. bold: true.
|
|
45
|
+
def self.text_attributes(**format)
|
|
46
|
+
bits = format.sum { |name, on| on ? FORMAT.fetch(name) : 0 }
|
|
47
|
+
{ **TEXT_ATTRIBUTES, "__format" => bits }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A list block: `ordered:` false is a bulleted list ("ul"), true a numbered
|
|
51
|
+
# one ("ol").
|
|
52
|
+
def self.list_attributes(ordered: false)
|
|
53
|
+
{ "__type" => "list", "__tag" => ordered ? "ol" : "ul",
|
|
54
|
+
"__listType" => ordered ? "number" : "bullet", "__start" => 1, **ELEMENT_ATTRIBUTES }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A list item; `value` is its 1-based position. The item node's type is
|
|
58
|
+
# `list_item_type`, which an editor flavor can override (Lexxy uses its own).
|
|
59
|
+
def self.list_item_attributes(value)
|
|
60
|
+
{ "__type" => list_item_type, "__value" => value, **ELEMENT_ATTRIBUTES }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.list_item_type = "listitem"
|
|
64
|
+
|
|
65
|
+
# A link inside a block: an element holding its own text nodes.
|
|
66
|
+
def self.link_attributes(url, title: nil)
|
|
67
|
+
{ "__type" => "link", "__url" => url.to_s, "__target" => nil, "__rel" => nil,
|
|
68
|
+
"__title" => title, **ELEMENT_ATTRIBUTES }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- appending blocks ---
|
|
72
|
+
|
|
73
|
+
# Append a paragraph and return the live block. `runs` is a string, or an
|
|
74
|
+
# array of strings and hashes like `{ text: "bold", bold: true }` or
|
|
75
|
+
# `{ text: "docs", link: "https://…" }`.
|
|
76
|
+
def self.append_paragraph(doc, runs, root: "root")
|
|
77
|
+
append_block(doc, PARAGRAPH_ATTRIBUTES, runs, root: root)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Append a heading (`tag:` "h1".."h6") and return the live block.
|
|
81
|
+
def self.append_heading(doc, runs, tag: "h2", root: "root")
|
|
82
|
+
append_block(doc, heading_attributes(tag), runs, root: root)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.append_quote(doc, runs, root: "root")
|
|
86
|
+
append_block(doc, quote_attributes, runs, root: root)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Append a code block. Lines stay as they are; `language:` names the syntax.
|
|
90
|
+
def self.append_code(doc, code, language: "plain", root: "root")
|
|
91
|
+
doc.get_xml_text(root).push_xml_text(code_attributes(language: language),
|
|
92
|
+
[CODE_TEXT_ATTRIBUTES, code.to_s])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Append a list of `items` (each a string or runs) and return the live list.
|
|
96
|
+
def self.append_list(doc, items, ordered: false, root: "root")
|
|
97
|
+
entries = items.each_with_index.map { |runs, i| [list_item_attributes(i + 1), content(runs)] }
|
|
98
|
+
doc.get_xml_text(root).push_xml_text(list_attributes(ordered: ordered), entries)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Append a block with `attributes` holding `runs`.
|
|
102
|
+
def self.append_block(doc, attributes, runs, root: "root")
|
|
103
|
+
doc.get_xml_text(root).push_xml_text(attributes, content(runs))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- editing in place ---
|
|
107
|
+
|
|
108
|
+
# Insert a paragraph before the `at`-th block (at the end when past the
|
|
109
|
+
# last) and return it.
|
|
110
|
+
def self.insert_paragraph(doc, at, runs, root: "root")
|
|
111
|
+
doc.get_xml_text(root).insert_xml_text(at, PARAGRAPH_ATTRIBUTES, content(runs))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Replace everything in a block with `runs`.
|
|
115
|
+
def self.replace_runs(block, runs) = block.replace(content(runs))
|
|
116
|
+
|
|
117
|
+
# Remove the `at`-th block. Returns whether there was one.
|
|
118
|
+
def self.delete_block(doc, at, root: "root") = doc.get_xml_text(root).delete_xml_text(at)
|
|
119
|
+
|
|
120
|
+
# Append plain `text` to the end of `block` and return the block. This is
|
|
121
|
+
# the call for streaming into a block. Text must follow a text node's
|
|
122
|
+
# marker or Lexical drops it, so after a link, or in an empty block, it
|
|
123
|
+
# starts a text node of its own; otherwise it joins the last one.
|
|
124
|
+
def self.append_text(block, text) = block.append_text(text.to_s, TEXT_ATTRIBUTES)
|
|
125
|
+
|
|
126
|
+
# Write `runs` at the end of `block`.
|
|
127
|
+
def self.write_runs(block, runs) = block.append(content(runs))
|
|
128
|
+
|
|
129
|
+
# `runs` as block content (see `XmlText#insert_xml_text`): a text node,
|
|
130
|
+
# its marker then its text, per run, and a link element holding one for a
|
|
131
|
+
# run with `link:`. Runs may use string keys, as parsed JSON does.
|
|
132
|
+
def self.content(runs)
|
|
133
|
+
Array(runs).flat_map do |run|
|
|
134
|
+
run = run.is_a?(String) ? { text: run } : run.transform_keys(&:to_sym)
|
|
135
|
+
raise ArgumentError, "a run needs text: #{run.inspect}" unless run.key?(:text)
|
|
136
|
+
|
|
137
|
+
text = [text_attributes(**run.slice(*FORMAT.keys)), run[:text].to_s]
|
|
138
|
+
run[:link] ? [[link_attributes(run[:link], title: run[:title]), text]] : text
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/y/lexxy.rb
CHANGED
|
@@ -11,6 +11,27 @@ module Y
|
|
|
11
11
|
# nodes are declarative hashes, nodes with logic are plain methods mapped
|
|
12
12
|
# in NODES.
|
|
13
13
|
class Lexxy < Lexical
|
|
14
|
+
# Lexxy's list items are its own node type (it leaves a list when you press
|
|
15
|
+
# Enter on an empty item), so lists written for a Lexxy editor use it.
|
|
16
|
+
def self.list_item_type
|
|
17
|
+
"early_escape_listitem"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Lexxy's code block is its own node type, with a syntax-highlight flag.
|
|
21
|
+
def self.code_attributes(language: "plain")
|
|
22
|
+
{ **super, "__type" => "early_escape_code", "__isSyntaxHighlightSupported" => true }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Lexxy links carry rel="noreferrer".
|
|
26
|
+
def self.link_attributes(url, title: nil)
|
|
27
|
+
{ **super, "__rel" => "noreferrer" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Lexxy puts a quote's text inside a paragraph within the quote block.
|
|
31
|
+
def self.append_quote(doc, runs, root: "root")
|
|
32
|
+
doc.get_xml_text(root).push_xml_text(quote_attributes, [[PARAGRAPH_ATTRIBUTES, content(runs)]])
|
|
33
|
+
end
|
|
34
|
+
|
|
14
35
|
# A cursor-placement placeholder; empty ones export to nothing.
|
|
15
36
|
def self.provisional_paragraph(node)
|
|
16
37
|
node.content.empty? ? "" : "<p>#{node.content}</p>"
|
|
@@ -101,6 +122,8 @@ module Y
|
|
|
101
122
|
|
|
102
123
|
NODES = {
|
|
103
124
|
# Lexxy's replacement for Lexical's CodeNode.
|
|
125
|
+
# Lexxy wraps a quote's content in a paragraph.
|
|
126
|
+
"quote" => { tag: "blockquote", contains: :blocks },
|
|
104
127
|
"early_escape_code" => { tag: "pre", attrs: { "data-language" => :language } },
|
|
105
128
|
"horizontal_divider" => { tag: "hr", void: true },
|
|
106
129
|
"provisonal_paragraph" => method(:provisional_paragraph), # (sic: Lexxy's spelling)
|
|
@@ -109,6 +132,8 @@ module Y
|
|
|
109
132
|
"wrapped_table_node" => { contains: :blocks, render: method(:table) },
|
|
110
133
|
"tablecell" => { contains: :blocks, render: method(:table_cell) },
|
|
111
134
|
"listitem" => { contains: :blocks, render: method(:list_item) },
|
|
135
|
+
# Lexxy's own list item (it leaves the list on Enter in an empty item).
|
|
136
|
+
"early_escape_listitem" => { contains: :blocks, render: method(:list_item) },
|
|
112
137
|
"action_text_attachment" => method(:upload),
|
|
113
138
|
"action_text_attachment_upload" => method(:pending_upload),
|
|
114
139
|
"custom_action_text_attachment" => method(:mention)
|
data/lib/y/rendering.rb
CHANGED
|
@@ -125,7 +125,7 @@ module Y
|
|
|
125
125
|
# A template: String literal, Symbol attribute reference, or an Array of
|
|
126
126
|
# both.
|
|
127
127
|
def compile_parts(template)
|
|
128
|
-
Array(template).map do |part|
|
|
128
|
+
Kernel.Array(template).map do |part|
|
|
129
129
|
case part
|
|
130
130
|
when Symbol then { "ref" => part.to_s }
|
|
131
131
|
else { "lit" => part.to_s }
|
|
@@ -227,7 +227,7 @@ module Y
|
|
|
227
227
|
|
|
228
228
|
def to_html(root = nil)
|
|
229
229
|
result = root.nil? ? @native.to_html : @native.to_html(root)
|
|
230
|
-
return result unless result.is_a?(Array)
|
|
230
|
+
return result unless result.is_a?(::Array)
|
|
231
231
|
|
|
232
232
|
RenderRules.splice(result, @render_callbacks)
|
|
233
233
|
end
|
|
@@ -274,7 +274,7 @@ module Y
|
|
|
274
274
|
|
|
275
275
|
def to_html(root = nil)
|
|
276
276
|
result = root.nil? ? @native.to_html : @native.to_html(root)
|
|
277
|
-
return result unless result.is_a?(Array)
|
|
277
|
+
return result unless result.is_a?(::Array)
|
|
278
278
|
|
|
279
279
|
RenderRules.splice(result, @render_callbacks)
|
|
280
280
|
end
|
data/lib/y/text.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
class Text
|
|
5
|
+
# The Yjs relative position of character `index`, the shape editors put
|
|
6
|
+
# in awareness as a caret. It keeps pointing at the same character as
|
|
7
|
+
# people edit around it; `index_at` says where that is now. `assoc:` is
|
|
8
|
+
# :after (default) or :before.
|
|
9
|
+
def relative_position(index, assoc: :after)
|
|
10
|
+
native_relative_position(index, assoc.to_s)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/y/version.rb
CHANGED
data/lib/y/xml_text.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# Ruby-side conveniences over the native live handle.
|
|
5
|
+
class XmlText
|
|
6
|
+
# Insert a block with `attributes` before the `ordinal`-th embedded
|
|
7
|
+
# block and return a live handle to it. A negative `ordinal` counts from
|
|
8
|
+
# the end, so -1 adds it after the last. `content` is written into the
|
|
9
|
+
# block in the same transaction, so no one sees the block before what it
|
|
10
|
+
# holds. It is an Array of Strings (text), Hashes (embeds, such as the
|
|
11
|
+
# marker Lexical puts before a text node's characters), and
|
|
12
|
+
# `[attributes, content]` pairs (blocks of their own).
|
|
13
|
+
def insert_xml_text(ordinal, attributes, content = [])
|
|
14
|
+
native_insert_xml_text(ordinal, attributes, content)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Append a block with `attributes` holding `content` (see
|
|
18
|
+
# `insert_xml_text`) and return a live handle to it.
|
|
19
|
+
def push_xml_text(attributes, content = [])
|
|
20
|
+
insert_xml_text(-1, attributes, content)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The Yjs relative position of character `index`: the `{type, tname,
|
|
24
|
+
# item, assoc}` hash editors carry in awareness as `anchorPos`/`focusPos`.
|
|
25
|
+
# `assoc:` is `:after` (the default) or `:before`.
|
|
26
|
+
def relative_position(index, assoc: :after)
|
|
27
|
+
native_relative_position(index, assoc.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# This block's `Anchor`: a value that names the block itself, with the
|
|
31
|
+
# root's name, so the document can say where the block is later.
|
|
32
|
+
def anchor
|
|
33
|
+
Anchor.new(position: native_block_position, root: root_name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|