lexxy-realtime 0.4.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.
@@ -0,0 +1,15 @@
1
+ Description:
2
+ Installs the channel and database tables required for collaborative
3
+ Lexxy editing. The channel locates records by signed GlobalID and
4
+ saves rendered updates to Action Text.
5
+
6
+ Example:
7
+ bin/rails generate lexxy_realtime:install
8
+
9
+ This will create:
10
+ app/channels/document_channel.rb
11
+ app/channels/application_cable/{connection,channel}.rb (if missing)
12
+ db/migrate/XXXXXXXX_create_y_tables.rb
13
+ The models ship in the gems (Y::Document, Y::DocumentUpdate).
14
+ The generator will remind you to add `import "lexxy-realtime"` to
15
+ your JavaScript entrypoint.
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "generators/yrby/tables/tables_generator"
5
+
6
+ module LexxyRealtime
7
+ module Generators
8
+ # Installs the document channel, the storage migration (via yrby's
9
+ # generator), and the Action Cable boilerplate when missing.
10
+ class InstallGenerator < Rails::Generators::Base
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ # rails new --skip-action-cable leaves nothing for the channel to
14
+ # inherit from.
15
+ def check_action_cable
16
+ return if defined?(ActionCable)
17
+
18
+ say "Action Cable is not loaded (rails new --skip-action-cable?). " \
19
+ 'Add `require "action_cable/engine"` to config/application.rb, ' \
20
+ "create config/cable.yml, and re-run this generator.", :red
21
+ raise Thor::Error, "lexxy_realtime:install requires Action Cable"
22
+ end
23
+
24
+ def create_application_cable
25
+ %w[connection channel].each do |file|
26
+ destination = "app/channels/application_cable/#{file}.rb"
27
+ next if File.exist?(File.join(destination_root, destination))
28
+
29
+ template "application_cable_#{file}.rb", destination
30
+ end
31
+ end
32
+
33
+ def create_channel
34
+ template "document_channel.rb", "app/channels/document_channel.rb"
35
+ end
36
+
37
+ # yrby owns the models and their migration.
38
+ def create_tables
39
+ invoke "yrby:tables"
40
+ end
41
+
42
+ # Import-map apps get pins to the assets this gem ships. The Lexxy
43
+ # pin must point at this gem's build (Lexxy's own asset bundles a
44
+ # second copy of lexical, which breaks collaboration), so an
45
+ # existing @37signals/lexxy pin is left for the app to resolve.
46
+ def add_importmap_pins
47
+ return unless File.exist?(File.join(destination_root, "config/importmap.rb"))
48
+ return if File.read(File.join(destination_root, "config/importmap.rb")).include?("lexxy_realtime/")
49
+
50
+ append_to_file "config/importmap.rb", <<~RUBY
51
+
52
+ # lexxy-realtime. lexical is shared between the Lexxy and
53
+ # lexxy-realtime bundles; @37signals/lexxy must point at the
54
+ # lexxy_realtime build, not Lexxy's own asset.
55
+ pin "lexical", to: "lexxy_realtime/lexical.js"
56
+ pin "@37signals/lexxy", to: "lexxy_realtime/lexxy.js"
57
+ pin "lexxy-realtime", to: "lexxy_realtime/lexxy-realtime.js"
58
+ pin "@rails/activestorage", to: "activestorage.esm.js"
59
+ RUBY
60
+ end
61
+
62
+ def show_next_steps
63
+ say <<~NEXT
64
+
65
+ lexxy-realtime is installed. Lexxy itself (the gem and its editor
66
+ JS) must already be installed and working. Next steps:
67
+
68
+ 1. bin/rails db:migrate
69
+ 2. Wire up the JavaScript. With import maps, the generator
70
+ added pins; import "@37signals/lexxy" and "lexxy-realtime"
71
+ from your entrypoint, and remove any pin for Lexxy's own
72
+ asset. With a bundler, install the lexxy-realtime npm
73
+ package and import it.
74
+ 3. Declare `has_collaborative_rich_text :body` on a model and
75
+ render it with `<%= form.collaborative_rich_textarea :body %>`.
76
+ 4. Update `authorized?` in app/channels/document_channel.rb
77
+ to check the current user.
78
+
79
+ Optional: set cursor names with `LexxyRealtime.identity`.
80
+ NEXT
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Channel < ActionCable::Channel::Base
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Connection < ActionCable::Connection::Base
5
+ # Expose the current user to DocumentChannel#authorized?:
6
+ #
7
+ # identified_by :current_user
8
+ #
9
+ # def connect
10
+ # self.current_user = User.find_by(id: cookies.signed[:user_id]) || reject_unauthorized_connection
11
+ # end
12
+ end
13
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Syncs clients with the record's collaborative document. After storing
4
+ # each update, the Action Text attribute is refreshed from the full
5
+ # document.
6
+ class DocumentChannel < ApplicationCable::Channel
7
+ include Y::ActionCable
8
+
9
+ # Storage routes through the record's association, so an encrypted
10
+ # attribute reads and writes through Y::EncryptedDocument.
11
+ on_load { |_key| record.find_or_create_collaborative_document(field).load_state }
12
+ on_change do |key, update|
13
+ record.find_or_create_collaborative_document(field).append(update)
14
+ # Log render failures. The stored document renders again after the
15
+ # next update. Raising would make the client resend an update the
16
+ # server already has.
17
+ begin
18
+ record.refresh_collaborative_rich_text(field)
19
+ rescue StandardError => e
20
+ Rails.logger.error("lexxy-realtime render failed for #{key}: #{e.class}: #{e.message}")
21
+ end
22
+ end
23
+
24
+ def subscribed
25
+ reject and return unless record&.collaborative_rich_text?(field)
26
+ reject and return unless authorized?
27
+
28
+ sync_subscribed(record.find_or_create_collaborative_document(field).key)
29
+ end
30
+
31
+ def receive(data)
32
+ return unless record
33
+
34
+ sync_receive(data, record.find_or_create_collaborative_document(field).key)
35
+ end
36
+
37
+ private
38
+
39
+ # Check whether the current user may edit this record, e.g.
40
+ # record.editable_by?(current_user) with identified_by :current_user
41
+ # on the connection. Nothing connects until this returns true.
42
+ def authorized?
43
+ false
44
+ end
45
+
46
+ # Invalid, stale, or field-mismatched tokens return nil and are
47
+ # rejected by subscribed.
48
+ def record
49
+ @record ||= GlobalID::Locator.locate_signed(params[:sgid], for: LexxyRealtime.sgid_purpose(field))
50
+ rescue ActiveRecord::RecordNotFound
51
+ nil
52
+ end
53
+
54
+ def field
55
+ params[:field].to_s
56
+ end
57
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires by gem name; the code lives under the underscored path.
4
+ require "lexxy_realtime"
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module LexxyRealtime
6
+ # Adds Y::Document-backed collaboration to an attribute. When Action
7
+ # Text is available, it declares the corresponding +has_rich_text+
8
+ # association.
9
+ module Collaborative
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ def has_collaborative_rich_text(name, **options) # rubocop:disable Naming/PredicatePrefix
14
+ include Model unless include?(Model)
15
+ has_rich_text(name, **options) if respond_to?(:has_rich_text)
16
+ self.collaborative_rich_text_names = (collaborative_rich_text_names + [name.to_sym]).freeze
17
+
18
+ # encrypted: true encrypts both halves: has_rich_text gets the
19
+ # option (an encrypted body), and the document stores CRDT state
20
+ # through Y::EncryptedDocument. Without Action Text, declare
21
+ # +encrypts+ on the plain attribute yourself.
22
+ document_class = options[:encrypted] ? "Y::EncryptedDocument" : "Y::Document"
23
+ has_one :"collaborative_document_#{name}", -> { where(name: name) },
24
+ class_name: document_class, as: :record, inverse_of: :record, dependent: :destroy
25
+ end
26
+ end
27
+
28
+ # The instance API, present only on models that declared an attribute.
29
+ module Model
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ class_attribute :collaborative_rich_text_names, instance_writer: false, default: [].freeze
34
+ end
35
+
36
+ def collaborative_rich_text?(name) = collaborative_rich_text_names.include?(name.to_sym)
37
+
38
+ # The document, if collaboration has started (nil until the first join).
39
+ def collaborative_document(name) = public_send("collaborative_document_#{name}")
40
+
41
+ # Creates the document on first use. The association supplies the
42
+ # class, so an encrypted attribute gets a Y::EncryptedDocument.
43
+ def find_or_create_collaborative_document(name)
44
+ collaborative_document(name) || begin
45
+ association(:"collaborative_document_#{name}").klass.for(self, name)
46
+ public_send("reload_collaborative_document_#{name}")
47
+ end
48
+ end
49
+
50
+ # Reloads and renders the document while holding the record lock,
51
+ # then saves the HTML through the attribute writer. Returns false
52
+ # when the document has no state.
53
+ def refresh_collaborative_rich_text(name)
54
+ ensure_collaborative!(name)
55
+
56
+ document = collaborative_document(name)
57
+ return false unless document
58
+
59
+ with_lock do
60
+ strict_loading!(false) if strict_loading? # the writer lazily loads the rich-text row
61
+ state = document.reload.load_state
62
+ break false if state.nil?
63
+
64
+ doc = Y::Doc.new
65
+ doc.apply_update(state)
66
+ html = Y::Lexxy.new(doc).to_html
67
+ break false if html.nil?
68
+
69
+ public_send("#{name}=", html)
70
+ save!(validate: false) # collaboration updates should not run unrelated model validations
71
+ true
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def ensure_collaborative!(name)
78
+ return if collaborative_rich_text?(name)
79
+
80
+ raise ArgumentError, "#{name.inspect} is not collaborative on #{self.class.name}"
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "action_dispatch" # Engine::Configuration references it at subclass definition
5
+
6
+ # Require the engine dependencies explicitly so their initializers run
7
+ # during boot.
8
+ require "lexxy"
9
+ require "y"
10
+ require "yrby-rails" # the sync concern, Y::Document storage, and yrby's engine
11
+
12
+ module LexxyRealtime
13
+ class Engine < ::Rails::Engine
14
+ initializer "lexxy_realtime.active_record" do
15
+ ActiveSupport.on_load(:active_record) { include LexxyRealtime::Collaborative }
16
+ end
17
+
18
+ initializer "lexxy_realtime.form_builder" do |app|
19
+ app.config.to_prepare { ActionView::Helpers::FormBuilder.prepend(LexxyRealtime::FormBuilder) }
20
+ end
21
+
22
+ # The import-map assets under app/assets/javascript need no
23
+ # initializer: Rails adds every app/assets subdirectory of an engine
24
+ # to the asset paths itself.
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexxyRealtime
4
+ # Renders a Lexxy editor with collaboration configured for the record
5
+ # and field. LexxyRealtime.identity supplies the cursor name and color.
6
+ module FormBuilder
7
+ def collaborative_rich_textarea(method, name: nil, color: nil, **options)
8
+ record = object
9
+ unless record.respond_to?(:collaborative_rich_text?) && record.collaborative_rich_text?(method)
10
+ raise ArgumentError,
11
+ "#{record.class.name}##{method} is not collaborative (declare has_collaborative_rich_text :#{method})"
12
+ end
13
+ raise ArgumentError, "#{record.class.name} must be persisted to collaborate on it" unless record.persisted?
14
+
15
+ identity = LexxyRealtime.identity.call(@template)
16
+ collaborator = name || identity[:name]
17
+ public_send(lexxy_editor_method, method, options) do
18
+ # The client-side Yjs binding key, shared by peers of this
19
+ # attribute. The server never sees it.
20
+ @template.content_tag("lexxy-collaboration", "",
21
+ "doc-id" => "#{record.model_name.param_key}-#{record.id}-#{method}",
22
+ "name" => collaborator,
23
+ "color" => color || identity[:color] || LexxyRealtime.collaborator_color(collaborator),
24
+ "channel-name" => LexxyRealtime::CHANNEL_NAME,
25
+ "channel-params" => { sgid: record.to_sgid(for: LexxyRealtime.sgid_purpose(method)).to_s,
26
+ field: method }.to_json)
27
+ end
28
+ end
29
+
30
+ alias collaborative_rich_text_area collaborative_rich_textarea
31
+
32
+ private
33
+
34
+ # Lexxy's explicit helper exists on Rails 8.0/8.1. On the
35
+ # ActionText::Editor adapter path in newer Rails, the standard
36
+ # rich_text_area renders Lexxy and accepts the block.
37
+ def lexxy_editor_method
38
+ respond_to?(:lexxy_rich_textarea) ? :lexxy_rich_textarea : :rich_text_area
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexxyRealtime
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lexxy_realtime/version"
4
+ require "lexxy_realtime/collaborative"
5
+ require "lexxy_realtime/form_builder"
6
+ require "lexxy_realtime/engine"
7
+
8
+ # Rails integration for collaborative Lexxy editing with yrby.
9
+ module LexxyRealtime
10
+ # Signed ids from the form helper carry this purpose scoped per field
11
+ # (sgid_purpose), so a token minted elsewhere can't join a document.
12
+ SGID_PURPOSE = :lexxy_realtime
13
+
14
+ # The channel the installer generates and the form helper points elements at.
15
+ CHANNEL_NAME = "DocumentChannel"
16
+
17
+ class << self
18
+ def sgid_purpose(field) = "#{SGID_PURPOSE}/#{field}"
19
+
20
+ # Cursor identity, called with the view context; returns { name:, color: }
21
+ # (a nil color gets a stable one derived from the name).
22
+ attr_writer :identity
23
+
24
+ def identity
25
+ @identity ||= lambda do |view|
26
+ user = view.respond_to?(:current_user) ? view.current_user : nil
27
+ # Use Anonymous when no display name is available.
28
+ name = user && %i[name username handle].lazy.filter_map { |a| user.try(a).presence }.first
29
+ { name: name || "Anonymous", color: nil }
30
+ end
31
+ end
32
+
33
+ # A stable, readable cursor color per collaborator name.
34
+ def collaborator_color(name)
35
+ "hsl(#{name.to_s.each_byte.reduce(0) { |acc, b| ((acc * 31) + b) % 360 }}, 70%, 45%)"
36
+ end
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lexxy-realtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - JP Camara
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: lexxy
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 8.0.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 8.0.2
40
+ - !ruby/object:Gem::Dependency
41
+ name: yrby
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.6.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.6.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: yrby-rails
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0.5'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0.5'
68
+ description: Adds collaborative Lexxy editing to Rails applications using yrby. Includes
69
+ the model, form, channel, generator, and Action Text integration.
70
+ email:
71
+ - jp@jpcamara.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - app/assets/javascript/lexxy_realtime/lexical.js
79
+ - app/assets/javascript/lexxy_realtime/lexical.js.map
80
+ - app/assets/javascript/lexxy_realtime/lexical.min.js
81
+ - app/assets/javascript/lexxy_realtime/lexxy-realtime.js
82
+ - app/assets/javascript/lexxy_realtime/lexxy-realtime.js.map
83
+ - app/assets/javascript/lexxy_realtime/lexxy-realtime.min.js
84
+ - app/assets/javascript/lexxy_realtime/lexxy.js
85
+ - app/assets/javascript/lexxy_realtime/lexxy.js.map
86
+ - app/assets/javascript/lexxy_realtime/lexxy.min.js
87
+ - lib/generators/lexxy_realtime/install/USAGE
88
+ - lib/generators/lexxy_realtime/install/install_generator.rb
89
+ - lib/generators/lexxy_realtime/install/templates/application_cable_channel.rb
90
+ - lib/generators/lexxy_realtime/install/templates/application_cable_connection.rb
91
+ - lib/generators/lexxy_realtime/install/templates/document_channel.rb
92
+ - lib/lexxy-realtime.rb
93
+ - lib/lexxy_realtime.rb
94
+ - lib/lexxy_realtime/collaborative.rb
95
+ - lib/lexxy_realtime/engine.rb
96
+ - lib/lexxy_realtime/form_builder.rb
97
+ - lib/lexxy_realtime/version.rb
98
+ homepage: https://github.com/jpcamara/lexxy-realtime
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ homepage_uri: https://github.com/jpcamara/lexxy-realtime
103
+ source_code_uri: https://github.com/jpcamara/lexxy-realtime
104
+ changelog_uri: https://github.com/jpcamara/lexxy-realtime/releases
105
+ rubygems_mfa_required: 'true'
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.4'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.6.9
121
+ specification_version: 4
122
+ summary: Collaborative editing for Lexxy in Rails
123
+ test_files: []