lexxy-realtime · a separate gem, built on yrby
Collaborative editing for Lexxy
Lexxy is Basecamp's rich text editor for Action Text, built on Lexical.
lexxy-realtime lets several people type in the same field
and see each other's text and cursors as they go. You add one line to the
model and one to the form, then run a generator.
lexxy-realtime installs yrby-rails and yrby-client for you and does the editor setup. If you use a different editor, or no editor, use yrby-rails directly.
class Post < ApplicationRecord has_collaborative_rich_text :bodyend<%= form.collaborative_rich_textarea :body %>bin/rails generate lexxy_realtime:install && bin/rails db:migrate
Import lexxy-realtime wherever you import Lexxy. The channel
ships in the gem, so there's no channel to write. Render the form only for
users who may edit the record, then open it in two browsers and type.
post.body is still Action Text.
While people are editing, lexxy-realtime keeps the body's Yjs state in
yrby's Y::Document tables. After each change, it renders
that state to HTML in Ruby with Y::Lexxy and saves the result
to post.body. That HTML matches what Lexxy would have
submitted itself, so search, mailers, and show pages work without
changes.
The channel ships in the gem.
The form helper wraps the Lexxy editor in yrby-client's
<yrby-document> element and puts a
<lexxy-collaboration> element inside the editor:
<yrby-document grant="..." name="body" channel="LexxyRealtime::DocumentChannel">
<lexxy-editor>
<lexxy-collaboration doc-id="post-42-body" name="Ada" color="#3b82f6">
</lexxy-collaboration>
</lexxy-editor>
</yrby-document>
<yrby-document> subscribes to the channel with the
signed grant and holds the post's Yjs document, the provider, and any
edits the server hasn't confirmed yet. When the document first syncs,
<lexxy-collaboration> binds the editor to it. The editor
stays inert until then, so nobody types into a document that can't sync.
The grant is a signed GlobalID for one record and one field.
LexxyRealtime::DocumentChannel extends yrby-rails'
Y::DocumentChannel and looks up the record from the grant. It
rejects a grant that's missing, tampered with, expired, or made for another
field, a record that's been deleted, and a field that isn't declared with
has_collaborative_rich_text. The grant has its own purpose, so
Y::DocumentChannel rejects it, and a client can't skip the
Action Text rendering by subscribing to the parent channel.
A valid grant means your app rendered the form for this user. To also check
the user's current permissions when they subscribe, give the channel an
authorize_document block. It runs inside the channel, so
current_user and your other connection identifiers work.
If it returns false or nil, the channel rejects the subscription before it
sends anything.
# config/initializers/lexxy_realtime.rb
Rails.application.config.to_prepare do
LexxyRealtime::DocumentChannel.authorize_document do |record, name|
record.editable_by?(current_user, attribute: name)
end
end
Grants expire, and the page can renew them.
A grant lasts a month by default, which is GlobalID's default under Rails.
Pass expires_in: to shorten it. The grant is part of the
rendered page, and Action Cable resubscribes with it after every network
drop, so an editor whose grant expired stops syncing at its next reconnect.
Pair expires_in: with refresh:, the URL of an
action that returns a new grant:
<%= form.collaborative_rich_textarea :body, expires_in: 10.minutes,
refresh: grant_post_path(@post) %>
# config/routes.rb: resources :posts do get :grant, on: :member end
def grant
@post = current_user.posts.find(params[:id]) # your own authorization, again
render json: { grant: @post.collaborative_rich_text_grant(:body, expires_in: 10.minutes) }
end
When the server rejects the subscription, <yrby-document>
fetches that URL with the session cookie and resubscribes with the new
grant. It keeps the Yjs document and any edits the server hasn't
confirmed. The action hands out write access, so its check has to be at
least as strict as the page that renders the form. If the refresh fails,
the editor stops syncing until the page reloads.
Import maps or a bundler.
With import maps, the generator pins the JavaScript files the gem ships, one for each module the page must load only once. It keeps pins your app already has, so it's safe to run again after an upgrade.
# config/importmap.rb, added by the generator
pin "@37signals/lexxy", to: "lexxy.js"
pin "lexxy-realtime", to: "lexxy_realtime/lexxy-realtime.js"
pin "yrby-client", to: "lexxy_realtime/yrby-client.js"
pin "yrby-client/element", to: "lexxy_realtime/yrby-client.js"
pin "yjs", to: "lexxy_realtime/yjs.js"
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "@rails/activestorage", to: "activestorage.esm.js"
@37signals/lexxy points at Lexxy's own file, and
lexxy-realtime gets Lexical from it, so the page loads one copy of each.
yrby-client and Yjs are separate files, so any of your own code that
imports them gets the same copies lexxy-realtime uses. With a bundler,
run npm install lexxy-realtime. npm and bun install its peers
(lexical, yjs, and the rest). Either way, the
entry point imports the same two packages:
import "@37signals/lexxy"
import "lexxy-realtime" // registers <lexxy-collaboration> and <yrby-document>
<yrby-document> creates an @rails/actioncable
consumer from the page's action-cable-url meta tag. To use
@anycable/web, set the consumer once at boot, before any
editor mounts. The function runs the first time an editor needs a consumer:
import { createConsumer } from "@anycable/web"
import { setConsumer } from "lexxy-realtime"
setConsumer(() => createConsumer())
Custom nodes and encryption.
Any custom Lexical nodes you've registered sync like the rest of the
editor's content. When Y::Lexxy doesn't know a node, it
renders what it can: a container or inline wrapper keeps its text, and an
embed that keeps its content in attributes renders nothing. Pass
nodes: to has_collaborative_rich_text with a
render rule for each of your node types. When a document has node types
with no rule, the gem logs a warning that names them.
To encrypt a field, pass encrypted: true to
has_collaborative_rich_text. Both the Action Text body and
the Y::Document rows are then stored with Active Record
encryption.
Built on yrby.
lexxy-realtime is a small layer on top of yrby, which the rest of this
site documents. yrby does the syncing, the saving, and the HTML
rendering, over Action Cable or AnyCable. The gem's channel is yrby-rails'
Y::DocumentChannel plus the Action Text rendering, and the
<yrby-document> element comes from yrby-client.
bundle add lexxy-realtime · bin/rails generate lexxy_realtime:install