Replace Trix with Lexxy

This commit is contained in:
Stanko K.R.
2026-07-20 15:10:54 +02:00
parent df5ed25e14
commit 797efa54fd
46 changed files with 678 additions and 495 deletions
+3
View File
@@ -26,6 +26,9 @@ gem "importmap-rails", github: "rails/importmap-rails"
gem "turbo-rails", github: "hotwired/turbo-rails"
gem "stimulus-rails"
# Rich text
gem "lexxy", "~> 0.9.24"
# Media handling
gem "image_processing", ">= 1.2"
+3
View File
@@ -199,6 +199,8 @@ GEM
activesupport (>= 6.0.0)
redis (>= 4.2, < 6)
language_server-protocol (3.17.0.5)
lexxy (0.9.24)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
@@ -424,6 +426,7 @@ DEPENDENCIES
importmap-rails!
jbuilder
kredis
lexxy (~> 0.9.24)
mocha
net-http-persistent
ostruct
+108 -130
View File
@@ -1,165 +1,141 @@
trix-toolbar {
display: none;
inset: auto 0 0 0;
min-width: 0;
position: sticky;
z-index: 3;
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
so everything follows Campfire's light/dark color scheme */
.lexxy-content {
--lexxy-color-canvas: transparent;
--lexxy-color-text: var(--color-text);
--lexxy-color-text-subtle: var(--color-text);
--lexxy-color-link: var(--color-link);
--lexxy-color-ink: var(--color-text);
--lexxy-color-ink-inverted: var(--color-text-reversed);
--lexxy-color-ink-medium: var(--color-text);
--lexxy-color-ink-lighter: var(--color-border-darker);
--lexxy-color-ink-lightest: var(--color-bg);
--lexxy-color-selected: var(--color-selected, var(--color-bg));
--lexxy-color-code-bg: var(--color-bg);
.trix-dialog {
bottom: 3rem !important;
top: auto !important;
}
.trix-button--icon {
height: auto !important;
}
.trix-dialog {
background-color: var(--color-bg) !important;
border-radius: 1rem !important;
border: 2px solid var(--color-text) !important;
box-shadow: none !important;
color: var(--color-text) !important;
gap: var(--inline-space);
padding: 0 !important;
overflow: clip;
.trix-input {
background: transparent !important;
border: 0 !important;
color: var(--color-text) !important;
font-size: max(16px, 1em) !important;
padding: var(--input-padding, 0.5em 0.8em) !important;
}
.trix-button {
color: var(--color-text) !important;
}
}
.trix-dialog--link {
.trix-dialog__link-fields {
align-items: center;
padding-right: 0.3em;
}
[value="Link"],
[value="Unlink"] {
width: 2.6em;
aspect-ratio: 1 / 1;
background-position: center center;
background-repeat: no-repeat;
text-indent: -9999%;
padding: 0;
}
[value="Link"] { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.948 5.258a4.337 4.337 0 0 0-6.108 0L11.217 6.87a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l1.623-1.613a2.323 2.323 0 0 1 3.271 0 2.29 2.29 0 0 1 0 3.251l-2.393 2.38a3.021 3.021 0 0 1-4.255 0l-.05-.049a1.007 1.007 0 0 0-1.418 0 .993.993 0 0 0 0 1.41l.05.049a5.036 5.036 0 0 0 7.091 0l2.394-2.38a4.275 4.275 0 0 0 0-6.072Zm-13.683 13.6a4.337 4.337 0 0 0 6.108 0l1.262-1.255a.993.993 0 0 0 0-1.41 1.007 1.007 0 0 0-1.418 0L9.954 17.45a2.323 2.323 0 0 1-3.27 0 2.29 2.29 0 0 1 0-3.251l2.344-2.331a2.579 2.579 0 0 1 3.631 0c.392.39 1.027.39 1.419 0a.993.993 0 0 0 0-1.41 4.593 4.593 0 0 0-6.468 0l-2.345 2.33a4.275 4.275 0 0 0 0 6.072Z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
[value="Unlink"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.954 5.264a3.58 3.58 0 0 0-5.04 0l-2.157 2.144a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l2.157-2.144a1.565 1.565 0 0 1 2.204 0c.607.603.609 1.58.005 2.186l-2.348 2.355a.993.993 0 0 0 .006 1.41 1.007 1.007 0 0 0 1.419-.007l2.347-2.354a3.528 3.528 0 0 0-.01-5ZM5.257 18.852a3.59 3.59 0 0 0 5.041.015l2.6-2.554a.993.993 0 0 0 .01-1.41 1.007 1.007 0 0 0-1.419-.009l-2.6 2.554c-.615.603-1.603.6-2.214-.006a1.554 1.554 0 0 1-.012-2.195l2.539-2.58a.993.993 0 0 0-.016-1.41 1.007 1.007 0 0 0-1.418.015l-2.539 2.58a3.54 3.54 0 0 0 .028 5Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-width='2' d='M10.233 5.78V3.769M5.968 9.802H4.21M7.343 7.107 5.968 5.781M14.348 17.523v2.013M18.613 13.502h1.758M17.238 16.196l1.375 1.327'/%3E%3C/svg%3E%0A");
border-top-right-radius: 1em !important;
border-bottom-right-radius: 1em !important;
}
}
/* Campfire's message lines stack without extra block margins */
--lexxy-content-margin: 0;
}
trix-editor {
lexxy-editor {
&.input {
--lexxy-editor-padding: 0;
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
whose resolution differs between Chrome versions — pin it instead of
risking the 8-line fallback */
--lexxy-editor-rows: 1lh;
background: transparent;
border: 0;
border-radius: 0;
min-height: 24px;
}
.attachment--content {
margin: 0;
display: inline-flex;
.attachment__toolbar {
top: 0;
z-index: 2;
}
}
}
.trix-button-group--file-tools,
.trix-button-group--history-tools {
display: none !important;
/* The mentions menu needs a solid surface — the editor's transparent canvas
would let messages show through it */
.lexxy-prompt-menu {
--lexxy-color-canvas: var(--color-bg);
border: 1px solid var(--color-border-dark);
}
.trix-button-group,
.trix-button {
border: 0 !important;
margin: 0 !important;
border-radius: 0.3em !important;
box-shadow: none;
/* Same for the code block language picker: with a transparent canvas the
native dropdown renders white-on-white in dark mode */
lexxy-editor lexxy-code-language-picker select {
background-color: var(--color-bg);
color: var(--color-text);
&::before {
opacity: 1 !important;
}
@media (prefers-color-scheme: dark) {
&::before {
filter: invert(100);
}
}
&.trix-active {
background-color: var(--color-text) !important;
&::before {
filter: invert(100);
}
@media (prefers-color-scheme: dark) {
&::before {
filter: invert(0);
}
}
}
&.trix-button--remove {
background-color: var(--color-negative);
border-radius: 1.8em !important;
&::before {
filter: invert(100);
background-size: 70%;
background-image: url("minus.svg");
}
option {
background-color: var(--color-bg);
color: var(--color-text);
}
}
[data-trix-content-type~='application/vnd.campfire.mention'] {
.trix-button-group {
display: inline;
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
lexxy-toolbar {
/* Wide, airy button spacing like the Trix toolbar */
--lexxy-toolbar-gap: 1.3em;
/* Match the compact size of the Trix toolbar buttons */
--lexxy-toolbar-button-size: 1.7em;
--lexxy-toolbar-icon-size: 1.125em;
display: none;
border-block-end: 0;
min-width: 0;
z-index: 3;
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
color highlights, tables, dividers and undo/redo (history tools were
hidden under Trix as well) */
button[name="underline"],
button[name="table"],
button[name="divider"],
button[name="undo"],
button[name="redo"],
lexxy-highlight-dropdown {
display: none !important;
}
/* No group separator pipes, like the Trix toolbar */
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
display: none;
}
}
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
* {
text-decoration: none;
}
/* Attachment markup contains formatting whitespace that must not turn into
line breaks under the editor's white-space: pre-wrap */
lexxy-editor action-text-attachment {
white-space: normal;
}
trix-editor & {
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
display: inline-flex;
margin: 0;
padding: 0;
position: relative;
}
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
lexxy-editor & {
display: block;
inline-size: fit-content;
max-inline-size: 100%;
}
}
.trix-button:not(.trix-button--remove) {
@media (any-hover: hover) {
&:where(:not(:active):hover) {
filter: brightness(0.9);
background-color: var(--color-bg);
/* No selection background — it peeks through the card's rounded corners.
The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
lexxy-editor &.node--selected {
--lexxy-attachment-bg-color: transparent;
}
/* A prominent remove button on the card's top edge, horizontally
centered, like the Trix one. The lexxy-editor prefix out-specifies
Lexxy's own placement rules. */
lexxy-editor & lexxy-node-delete-button {
inset-block-start: 0;
inset-inline-start: 50%;
inset-inline-end: auto;
transform: translate(-50%, -50%);
.lexxy-floating-controls__group {
--button-size: 2.2em;
background-color: var(--color-negative);
border-radius: 50%;
color: white;
}
}
&:disabled {
opacity: 0.33;
}
}
trix-editor,
.trix-content {
.lexxy-content {
pre {
background-color: var(--color-bg) !important;
border-radius: 0.5em;
@@ -179,11 +155,13 @@ trix-editor,
blockquote {
border: 0 solid var(--color-border-darker) !important;
border-inline-start-width: var(--blockquote-border) !important;
font-style: normal;
margin: 0 var(--blockquote-margin) !important;
padding-inline-start: var(--blockquote-padding) !important;
}
cite {
display: block;
margin-inline-start: calc(var(--blockquote-padding));
font-style: normal;
font-size: 0.8rem;
+1 -1
View File
@@ -33,7 +33,7 @@
}
}
[data-trix-content-type~='application/vnd.campfire.mention'] {
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
display: inline-flex;
margin: 0;
padding: 0;
+40 -4
View File
@@ -100,16 +100,42 @@
}
.composer--rich-text {
trix-toolbar {
display: block;
lexxy-toolbar {
display: flex;
}
trix-editor.input {
lexxy-editor.input {
margin-block-start: var(--block-space-half);
}
}
.composer--edit trix-toolbar {
/* In the message composer the toolbar goes below the text, on the same
row as the surrounding action buttons, like the Trix toolbar did.
The edit composer keeps it on top. */
#composer.composer--rich-text lexxy-editor.input {
display: flex;
flex-direction: column;
/* Sit the toolbar row on the same baseline as the composer's round
buttons, which pull themselves down with negative margins */
margin-block-end: -0.25em;
.lexxy-editor__content {
order: -1;
}
lexxy-toolbar {
margin-block-start: 0.4em;
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
[data-dropdown-panel] {
inset-block-start: auto;
inset-block-end: 100%;
}
}
}
.composer--edit lexxy-toolbar {
border-block-end: 1px solid var(--color-border);
margin-inline: -0.8em;
padding-inline: 0.3em;
@@ -117,6 +143,16 @@
}
}
/* The editor's container-type prevents it from sizing the message bubble by
its content, so give the bubble the full available width while editing */
.message__body:has(.composer--edit) {
inline-size: 100%;
.message__body-content--editing {
flex-grow: 1;
}
}
/* Typing indicator */
.typing-indicator {
inset-block-start: var(--indicator-position, 0.15rem);
+8 -3
View File
@@ -1,5 +1,5 @@
trix-editor,
.trix-content {
.lexxy-content {
.og-embed {
background: var(--color-bg);
border: 1px solid var(--color-border-dark);
@@ -35,10 +35,11 @@ trix-editor,
.og-embed__title {
font-weight: 600;
text-decoration: underline;
/* Underline the link itself so the line is link-colored */
a {
color: var(--color-link);
text-decoration: underline;
}
}
@@ -53,7 +54,11 @@ trix-editor,
inline-size: 15dvw;
}
/* block-size/border-radius guard against Lexxy's 1em round avatar
styling for custom attachment images */
> * {
block-size: auto;
border-radius: 0;
object-position: top right;
inline-size: 100%;
object-fit: contain;
+1 -1
View File
@@ -537,7 +537,7 @@ img.message__attachment {
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
text-shadow: 0 0 3px var(--color-text-reversed);
.trix-content {
.lexxy-content {
font-size: 2.4em;
line-height: 1.4;
}
@@ -1,11 +1,22 @@
class Autocompletable::UsersController < ApplicationController
def index
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
respond_to do |format|
format.html { render layout: false } # <lexxy-prompt-item> elements for the mentions prompt
format.json
end
end
private
def find_autocompletable_users
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
query.present? ? users_scope.active.filtered_by(query) : users_scope.active
end
# The rich text editor's mentions prompt filters with `filter`, the
# autocomplete inputs with `query`
def query
params[:filter].presence || params[:query].presence
end
def users_scope
+10
View File
@@ -1,3 +1,13 @@
module ContentFilters
# Formatting the rich text editor produces that Rails' sanitizers don't
# allow by default. A message passes through three sanitization layers on
# its way to the screen, and each builds on this list:
#
# * SanitizeTags strips disallowed markup from the message body
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
EDITOR_FORMATTING_TAGS = %w[ s u mark ]
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
end
@@ -4,7 +4,13 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
end
def apply
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
if fragment.find_all("div").any?
# Trix-era bodies: one <div> wrapping the link text and the embed
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
else
# Lexxy bodies: the link sits in its own <p>, the embed follows it
fragment.replace("p") { |node| node.at_css("action-text-attachment") ? node : nil }
end
end
private
@@ -12,7 +18,7 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
def solo_unfurled_url
unfurled_links.first["href"] if unfurled_links.size == 1
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
end
def unfurled_links
+2 -1
View File
@@ -9,7 +9,8 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
private
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
p pre samp small span strong sub sup time tt ul var ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS +
[ ActionText::Attachment.tag_name, "figure", "figcaption" ]
def not_allowed_tags_css_selector
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
@@ -15,7 +15,9 @@ class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
def unfurled_twitter_avatars
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
fragment.find_all(opengraph_css_selector).select do |node|
ActionText::Attachment::OpengraphEmbed.from_node(node)&.url.to_s.start_with?(TWITTER_AVATAR_URL_PREFIX)
end
end
def opengraph_css_selector
+7 -1
View File
@@ -1,4 +1,9 @@
module MessagesHelper
# auto_link re-sanitizes with Rails' default safe list, which lacks the
# formatting the rich text editor produces
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
def message_area_tag(room, &)
tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target",
@@ -57,7 +62,8 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
+7 -6
View File
@@ -1,11 +1,12 @@
module RichTextHelper
def rich_text_data_actions
default_actions =
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
# submitByKeyboard runs in the capture phase so it can submit on Enter
# before the editor turns the keystroke into a newline
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
end
autocomplete_actions =
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
[ default_actions, autocomplete_actions ].join(" ")
def mention_prompt_tag(room)
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
"remote-filtering": true, "empty-results": "No matches"
end
end
+3 -3
View File
@@ -73,12 +73,12 @@ module RoomsHelper
def composer_data_actions
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
trix_attachment_actions =
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
attachment_actions =
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
remaining_actions =
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
end
end
-2
View File
@@ -1,6 +1,4 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "trix"
import "@rails/actiontext"
import "initializers"
import "controllers"
@@ -44,21 +44,20 @@ export default class extends Controller {
}
replaceMessageContent(content) {
const editor = this.textTarget.editor
editor.recordUndoEntry("Format reply")
editor.setSelectedRange([0, editor.getDocument().toString().length])
editor.deleteInDirection("forward")
editor.insertHTML(content)
editor.setSelectedRange([editor.getDocument().toString().length - 1])
this.textTarget.value = content
this.textTarget.focus()
this.textTarget.selection.placeCursorAtTheEnd()
}
submitByKeyboard(event) {
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
const metaEnter = event.metaKey || event.ctrlKey
const plainEnter = !event.shiftKey && !event.isComposing
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
event.stopPropagation()
this.submit(event)
}
}
@@ -126,7 +125,7 @@ export default class extends Controller {
}
#validInput() {
return this.textTarget.textContent.trim().length > 0
return !this.textTarget.isBlank
}
async #submitFiles() {
@@ -89,7 +89,7 @@ export default class extends Controller {
}
async editMyLastMessage() {
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
if (editorEmpty && this.#paginator.upToDate) {
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
@@ -11,7 +11,7 @@ export default class extends Controller {
}
reply() {
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>`
this.composerOutlet.replaceMessageContent(content)
}
@@ -23,7 +23,7 @@ export default class extends Controller {
}
get #bodyContent() {
const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true)
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
}
@@ -1,46 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import MentionsAutocompleteHandler from "lib/autocomplete/mentions_autocomplete_handler"
import { debounce } from "helpers/timing_helpers"
export default class extends Controller {
static values = { url: String }
initialize() {
this.handlers = []
this.search = debounce(this.search.bind(this), 300)
}
connect() {
if (this.element == document.activeElement) {
this.#installHandlers()
}
}
focus(event) {
this.#installHandlers()
}
search(event) {
const content = this.editor.getDocument().toString()
const position = this.editor.getPosition()
this.handlers.forEach(handler => handler.updateWithContentAndPosition(content, position))
}
blur(event) {
this.#uninstallHandlers()
}
#installHandlers() {
this.#uninstallHandlers()
this.handlers = [ new MentionsAutocompleteHandler(this.element, this.urlValue) ]
}
#uninstallHandlers() {
this.handlers.forEach(handler => handler.destroy())
this.handlers = []
}
get editor() {
return this.element.editor
}
}
@@ -0,0 +1,70 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
import { truncateString } from "helpers/string_helpers"
import { escapeHTML } from "helpers/dom_helpers"
const OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
// Unfurls URLs pasted into the rich text editor into OpenGraph preview attachments
export default class extends Controller {
#abortController
disconnect() {
this.#abortController?.abort()
}
async unfurl(event) {
if (!this.element.permitsAttachmentContentType(OPENGRAPH_EMBED_CONTENT_TYPE)) return
const { url, insertBelowLink } = event.detail
const metadata = await this.#fetchOpengraphMetadata(url)
if (metadata) {
insertBelowLink(this.#opengraphEmbedHTML(metadata), { attachment: { contentType: OPENGRAPH_EMBED_CONTENT_TYPE } })
}
}
async #fetchOpengraphMetadata(url) {
this.#abortController?.abort()
this.#abortController = new AbortController()
try {
const response = await post("/unfurl_link", {
body: { url },
contentType: "application/json",
signal: this.#abortController.signal
})
if (response.ok && response.statusCode !== 204) {
const { title, url: href, image, description } = await response.json
if (title && href) return { title, href, image, description }
}
} catch {
// Ignore aborted or failed requests, like the previous implementation did
}
return null
}
#opengraphEmbedHTML({ title, href, image, description }) {
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(image) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ""}">
<div class="og-embed gap">
<div class="og-embed__content">
<div class="og-embed__title">
<a href="${escapeHTML(href)}" rel="noreferrer" target="_blank">${escapeHTML(truncateString(title, 280))}</a>
</div>
<div class="og-embed__description">${escapeHTML(truncateString(description, 560))}</div>
</div>
${image ? `<div class="og-embed__image"><img src="${escapeHTML(image)}" class="image center" alt="" /></div>` : ""}
</div>
</actiontext-opengraph-embed>`
}
#isTwitterAvatar(image) {
return !!image?.startsWith(TWITTER_AVATAR_URL_PREFIX)
}
}
+17 -8
View File
@@ -1,10 +1,19 @@
import Unfurler from "lib/rich_text/unfurl/unfurler"
import * as Lexxy from "lexxy"
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
// Support a `cite` block for attribution links
Trix.config.blockAttributes.cite = {
tagName: "cite",
inheritable: false,
}
Lexxy.configure({
global: {
// Keep the content type of mention attachments (application/vnd.campfire.mention)
// that Campfire has used since its Trix days
attachmentContentTypeNamespace: "campfire",
extensions: [ CampfireRichTextExtension ]
},
default: {
// Campfire sends files as separate messages via the composer's own
// attach button, never through the editor
toolbar: { attachments: false },
const unfurler = new Unfurler()
unfurler.install()
// Trix offered a single heading level, rendered as h1
headings: [ "h1" ]
}
})
@@ -1,48 +0,0 @@
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
export default class extends BaseAutocompleteHandler {
get pattern() {
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
}
insertAutocompletable(autocompletable, range, terminator, options = {}) {
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
}
// Override to set selector's position relative to the cursor in the editor
getOffsetsAtPosition(position) {
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
}
#createAttachmentForAutocompletable(mentionable) {
const mention = `
<span class="mention" sgid=${mentionable.sgid}>
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
${mentionable.name}
</span>
`
return new Trix.Attachment({
content: mention,
contentType: "application/vnd.campfire.mention",
sgid: mentionable.sgid
})
}
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
if (range) { this.#editor.setSelectedRange(range) }
this.#editor.insertAttachment(attachment)
this.#editor.insertString(terminator)
}
get #editor() {
return this.element.editor
}
#getOffsetsFromEditorAtPosition(editor, position) {
const rect = this.#editor.getClientRectAtPosition(position)
return rect ? rect : {}
}
}
@@ -0,0 +1,23 @@
import * as Lexxy from "lexxy"
import CiteNode from "lib/rich_text/cite_node"
export default class CampfireRichTextExtension extends Lexxy.Extension {
get allowedElements() {
return [
"cite",
"figure",
"figcaption",
"actiontext-opengraph-embed",
{ tag: "div", attributes: [ "sgid" ] },
{ tag: "img", attributes: [ "alt" ] },
{ tag: "a", attributes: [ "rel", "target" ] }
]
}
get lexicalExtension() {
return this.defineExtension({
name: "campfire/rich-text",
nodes: [ CiteNode ]
})
}
}
+39
View File
@@ -0,0 +1,39 @@
import * as Lexxy from "lexxy"
const { ElementNode } = Lexxy.Lexical
export default class CiteNode extends ElementNode {
static getType() {
return "cite"
}
static clone(node) {
return new CiteNode(node.__key)
}
static importJSON(serializedNode) {
return new CiteNode().updateFromJSON(serializedNode)
}
static importDOM() {
return {
cite: () => ({ conversion: () => ({ node: new CiteNode() }), priority: 1 })
}
}
exportJSON() {
return { ...super.exportJSON(), type: "cite" }
}
createDOM() {
return document.createElement("cite")
}
updateDOM() {
return false
}
exportDOM() {
return { element: document.createElement("cite") }
}
}
@@ -1,80 +0,0 @@
import { post } from "@rails/request.js"
import { truncateString } from "helpers/string_helpers"
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
export default class OpengraphEmbedOperation {
constructor(paste) {
this.paste = paste
this.editor = this.paste.editor
this.url = this.paste.string
this.abortController = new AbortController()
}
perform() {
return this.#createOpenGraphMetadataRequest()
.then(response => response.json)
.then(this.#insertOpengraphAttachment.bind(this))
.catch(() => null)
}
abort() {
this.abortController.abort()
}
#createOpenGraphMetadataRequest() {
return post("/unfurl_link", {
body: { url: this.url },
contentType: "application/json",
signal: this.abortController.signal
})
}
#insertOpengraphAttachment(response) {
if (this.#shouldInsertOpengraphPreview) {
const currentRange = this.editor.getSelectedRange()
this.editor.setSelectedRange(this.editor.getSelectedRange())
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
this.editor.setSelectedRange(currentRange)
}
}
get #shouldInsertOpengraphPreview() {
return this.editor.getDocument().toString().includes(this.url)
}
#createOpengraphAttachment(response) {
const { title, url, image, description } = response
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
return new Trix.Attachment({
contentType: "application/vnd.actiontext.opengraph-embed",
content: html,
filename: title,
href: url,
url: image,
caption: description
})
}
#generateOpengraphEmbedHTML(embed) {
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
<div class="og-embed">
<div class="og-embed__content">
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
</div>
<div class="og-embed__image">
<img src="${embed.image}" class="image" alt="" />
</div>
</div>
</actiontext-opengraph-embed>`
}
#isTwitterAvatar(embed) {
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
}
}
@@ -1,39 +0,0 @@
export default class Paste {
constructor(range, editor, document) {
this.range = range
this.editor = editor
this.document = document
if (this.document == null) { this.document = this.editor.getDocument() }
this.string = this.document.getStringAtRange(this.range)
}
isURL() {
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
}
getPathname() {
const a = document.createElement("a")
a.href = this.string
return a.pathname
}
isLinked() {
const {href} = this.getCommonAttributes()
return (href != null) && (href !== this.string)
}
getCommonAttributes() {
return this.document.getCommonAttributesAtRange(this.range)
}
getSignificantPaste() {
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
}
getSignificantRange() {
const significantString = this.string.trim()
const startOffset = this.range[0] + this.string.indexOf(significantString)
const endOffset = startOffset + significantString.length
return [startOffset, endOffset]
}
}
@@ -1,59 +0,0 @@
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
import Paste from "lib/rich_text/unfurl/lib/paste"
const performOperation = (function() {
let operation = null
let requestId = null
return function(operationToPerform) {
operation?.abort()
cancelAnimationFrame(requestId)
requestId = requestAnimationFrame(function() {
operation = operationToPerform
operation.perform().then(() => operation = null)
})
}
})()
export default class Unfurler {
install() {
this.#addEventListeners()
}
#addEventListeners() {
addEventListener("trix-initialize", function(event) {
if (this.#editorElementPermitsAttribute(event.target, "href")) {
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
}
}.bind(this))
}
#didPaste(event) {
const {range} = event.paste
const {editor} = event.target
if (range != null) {
const paste = new Paste(range, editor).getSignificantPaste()
if (paste.isURL()) {
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
performOperation(new OpengraphEmbedOperation(paste))
}
}
}
}
#editorElementPermitsAttribute(element, attributeName) {
if (element.hasAttribute("data-permitted-attributes")) {
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
} else {
return true
}
}
#editorElementPermitsOpengraphAttachment(element) {
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
}
}
+5 -6
View File
@@ -18,7 +18,7 @@ export default class ClientMessage {
body,
messageTimestamp: Math.floor(now.getTime()),
messageDatetime: now.toISOString(),
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
messageClasses: this.#containsOnlyEmoji(this.#plainTextFromNode(node)) ? "message--emoji" : "",
})
}
@@ -58,20 +58,19 @@ export default class ClientMessage {
}
#matchPlayCommand(node) {
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
}
#stripWrapperElement(node) {
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
#plainTextFromNode(node) {
return this.#isRichText(node) ? node.toString()?.trim() : node
}
#isRichText(node) {
return typeof(node) != "string"
}
#richTextContent(node) {
return `<div class="trix-content">${node.innerHTML}</div>`
return `<div class="lexxy-content">${node.value}</div>`
}
+14 -1
View File
@@ -76,7 +76,20 @@ export default class MessageFormatter {
}
#highlightCodeBlock(block) {
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
this.#normalizeLineBreaks(block)
if (!this.#isPlainText(block)) return
const language = block.dataset.language
if (language && window.hljs.getLanguage(language)) {
block.classList.add(`language-${language}`)
}
window.hljs.highlightElement(block)
}
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
#normalizeLineBreaks(block) {
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
}
#isPlainText(element) {
-4
View File
@@ -5,10 +5,6 @@ module User::Mentionable
"users/mention"
end
def to_trix_content_attachment_partial_path
"users/mention"
end
def attachable_plain_text_representation(caption)
"@#{name}"
end
@@ -5,7 +5,7 @@
<div class="og-embed__title">
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
</div>
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…").html_safe %></div>
</div>
<% if opengraph_embed.url %>
<div class="og-embed__image">
@@ -0,0 +1,11 @@
<lexxy-prompt-item search="<%= user.name %>" sgid="<%= user.attachable_sgid %>">
<template type="menu">
<span class="autocomplete__item flex align-center gap unpad">
<%= avatar_tag user %>
<span class="autocompletable__name"><%= user.name %></span>
</span>
</template>
<template type="editor">
<%= render "users/mention", user: user %>
</template>
</lexxy-prompt-item>
@@ -0,0 +1 @@
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
@@ -1,3 +1,3 @@
<div class="trix-content">
<div class="lexxy-content">
<%= yield -%>
</div>
+6 -6
View File
@@ -14,17 +14,17 @@
<% else %>
<div class="composer--edit composer--rich-text">
<%= form_with model: @message, url: room_message_path(@room, @message), id: dom_id(@message, :form), data: {
controller: "form", action: "trix-file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
controller: "form", action: "lexxy:file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
<div class="full-width input input--actor min-width fill-white">
<%= form.rich_text_area :body,
rows: 1,
class: "input",
class: "input lexxy-content",
aria: { multiline: "true", label: "Edit message" },
autofocus: true,
data: {
controller: "rich-autocomplete",
action: rich_text_data_actions,
rich_autocomplete_url_value: autocompletable_users_path(room_id: @room.id) } %>
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
data: { action: rich_text_data_actions } do %>
<%= mention_prompt_tag @room %>
<% end %>
</div>
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %>
+7 -6
View File
@@ -19,15 +19,16 @@
<div class="flex flex-column flex-item-grow min-width gap">
<%= form.rich_text_area :body,
rows: 1,
class: "input",
class: "input lexxy-content",
style: "order: -1",
aria: { multiline: "true", label: "Write a message" },
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
data: {
controller: "rich-autocomplete",
action: rich_text_data_actions,
rich_autocomplete_url_value: autocompletable_users_path(room_id: room.id),
permitted_attachment_types: "application/vnd.actiontext.opengraph-embed",
composer_target: "text" } %>
controller: "unfurl",
action: "#{rich_text_data_actions} lexxy:insert-link->unfurl#unfurl",
composer_target: "text" } do %>
<%= mention_prompt_tag room %>
<% end %>
</div>
<label class="btn btn--borderless txt-small flex-item-no-shrink composer__attachment-btn input--file">
+4 -4
View File
@@ -1,4 +1,4 @@
<div class="mention" sgid="<%= user.attachable_sgid %>">
<%= avatar_tag user %>
<%= user.name %>
</div>
<%# A span rather than a div: mentions render inline inside the <p> paragraphs
the editor produces, and block elements would make the browser split the
paragraph when parsing %>
<span class="mention" sgid="<%= user.attachable_sgid %>"><%= avatar_tag user %> <%= user.name %></span>
+1 -2
View File
@@ -5,8 +5,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/turbo-rails", to: "turbo.js"
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
pin "trix", to: "trix.esm.min.js" # @2.1.19
pin "@rails/actiontext", to: "actiontext.js"
pin "lexxy", to: "lexxy.js"
pin "highlight.js", to: "highlight.js/core.js"
pin_all_from "app/javascript/initializers", under: "initializers"
+11
View File
@@ -0,0 +1,11 @@
# The rich text editor produces formatting that Rails' default Action Text
# sanitizer strips on render. The additions are declared once in
# ContentFilters, shared by every sanitization layer.
Rails.application.config.to_prepare do
defaults = Class.new.include(ActionText::ContentHelper).new
ActionText::ContentHelper.allowed_tags =
(ActionText::ContentHelper.allowed_tags || defaults.sanitizer_allowed_tags) | ContentFilters::EDITOR_FORMATTING_TAGS
ActionText::ContentHelper.allowed_attributes =
(ActionText::ContentHelper.allowed_attributes || defaults.sanitizer_allowed_attributes) | ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
end
+24 -8
View File
@@ -14,12 +14,32 @@ class ActionText::Attachment::OpengraphEmbed
end
private
# Trix serialized the embed's details as attributes of the
# <action-text-attachment> node. Lexxy only serializes the sgid, content
# and content-type, so newer attachments carry the details in their
# content markup instead.
def attributes_from_node(node)
if node["href"].present?
{
href: node["href"],
url: node["url"],
filename: node["filename"],
description: node["caption"]
}
else
attributes_from_content(node["content"].to_s)
end
end
def attributes_from_content(content)
fragment = Nokogiri::HTML.fragment(content)
link = fragment.at_css(".og-embed__title a")
{
href: node["href"],
url: node["url"],
filename: node["filename"],
description: node["caption"]
href: link&.[]("href"),
url: fragment.at_css(".og-embed__image img")&.[]("src"),
filename: link&.text&.strip,
description: fragment.at_css(".og-embed__description")&.text&.strip
}
end
end
@@ -37,8 +57,4 @@ class ActionText::Attachment::OpengraphEmbed
def to_partial_path
"action_text/attachables/opengraph_embed"
end
def to_trix_content_attachment_partial_path
"action_text/attachables/opengraph_embed"
end
end
+1
View File
@@ -6,4 +6,5 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
include SystemTestHelper
include RichTextEditorHelper
end
+22
View File
@@ -1,6 +1,7 @@
require "test_helper"
class ContentFiltersTest < ActionView::TestCase
include MessagesHelper, ERB::Util
test "entire message contains an unfurled URL" do
text = "https://basecamp.com/"
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
@@ -10,6 +11,16 @@ class ContentFiltersTest < ActionView::TestCase
assert_match /<div><action-text-attachment/, filtered.to_html
end
test "entire message contains an unfurled URL in a lexxy body" do
text = "https://basecamp.com/"
body = "<p><a href=\"#{text}\">#{text}</a></p>#{unfurled_link_trix_attachment_for_basecamp}"
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match %r{>\s*https://basecamp\.com/\s*</a>}, filtered.to_html
assert_match /<action-text-attachment/, filtered.to_html
end
test "message includes additional text besides an unfurled URL" do
text = "Hello https://basecamp.com/"
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
@@ -53,6 +64,17 @@ class ContentFiltersTest < ActionView::TestCase
assert_match /<div><action-text-attachment/, filtered.to_html
end
test "message keeps strikethrough, underline and code block formatting" do
body = %(<p>Hello <s>struck</s> <u>under</u> <mark>marked</mark></p><pre data-language="ruby">def x<br>end</pre>)
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0016", creator: users(:jason)
html = message_presentation(message)
assert_match %r{<s>struck</s>}, html
assert_match %r{<u>under</u>}, html
assert_match %r{<mark>marked</mark>}, html
assert_match %r{<pre data-language="ruby">}, html
end
test "message contains a forbidden tag" do
exploit_image_tag = 'Hello <img src="https://ssecurityrise.com/tests/billionlaughs-cache.svg">World'
message = Message.create! room: rooms(:pets), body: exploit_image_tag, client_message_id: "0015", creator: users(:jason)
+136
View File
@@ -0,0 +1,136 @@
require "application_system_test_case"
class ComposerTest < ApplicationSystemTestCase
setup do
sign_in "jz@37signals.com"
join_room rooms(:designers)
end
test "enter sends the message when the toolbar is collapsed" do
type_in_composer "A quick reply"
press_in_composer :enter
assert_message_text "A quick reply"
assert_composer_empty
end
test "enter adds a newline in rich text mode and meta+enter sends" do
toggle_rich_text_toolbar
type_in_composer "line one"
press_in_composer :enter
type_in_composer "line two"
assert_no_message_text "line one"
press_in_composer [ :control, :enter ]
assert_message_text /line one\s*line two/
assert_composer_empty
end
test "markdown strikethrough survives sanitization" do
type_in_composer "Hello ~~Claude~~ World"
press_in_composer :enter
assert_selector last_message_selector("s"), text: "Claude"
assert_message_text "Hello Claude World"
end
test "mentioning a user with @ inserts a mention attachment" do
type_in_composer "Hey @Jas"
pick_mention "Jason"
click_send_button
assert_selector last_message_selector(".mention"), text: "Jason"
message = wait_for_persisted_message
assert_includes message.body.body.to_html, "application/vnd.campfire.mention"
assert_equal [ users(:jason) ], message.mentionees
end
test "editing a message with a mention keeps the mention" do
type_in_composer "Hey @Jas"
pick_mention "Jason"
click_send_button
assert_selector last_message_selector(".mention"), text: "Jason"
message = wait_for_persisted_message
within_message message do
reveal_message_actions
find(".message__edit-btn").click
assert_edit_editor_text "Jason"
click_on "Save changes"
end
assert_selector last_message_selector(".mention"), text: "Jason"
assert_equal [ users(:jason) ], message.reload.mentionees
end
test "replying quotes the original message with attribution" do
within_message messages(:third) do
reveal_message_actions
find("[aria-label='Reply']").click
end
assert_composer_text "Third time's a charm."
click_send_button
assert_selector last_message_selector("blockquote"), text: "Third time's a charm."
assert_selector last_message_selector("cite"), text: "JZ"
end
test "arrow up edits my last message when the composer is empty" do
composer_editor.click
press_in_composer :up
assert_selector ".message__body-content--editing"
assert_edit_editor_text "Third time's a charm."
end
test "pasting a URL unfurls an opengraph preview" do
metadata = Opengraph::Metadata.new(
title: "Example Site",
url: "https://example.com/article",
description: "An example article",
image: ""
)
Opengraph::Metadata.stubs(:from_url).returns(metadata)
paste_in_composer "https://example.com/article"
within "#composer" do
assert_selector ".og-embed__title", text: "Example Site", wait: 10
end
click_send_button
assert_selector last_message_selector(".og-embed__title"), text: "Example Site"
end
private
def click_send_button
find("#composer [data-action='composer#submit']").click
end
def last_message_selector(inner)
".message:last-of-type .message__body #{inner}"
end
def assert_no_message_text(text)
assert_no_selector ".message__body", text: text
end
# The message insert races with the form POST, so give the server a moment
def wait_for_persisted_message(room: rooms(:designers), since: 1.minute.ago)
message = nil
20.times do
message = room.messages.ordered.last
break if message.created_at > since
sleep 0.25
end
message
end
end
@@ -0,0 +1,56 @@
# Drives the rich text editor in the composer. Encapsulates all editor-specific
# selectors and interactions so tests describe behavior, not the editor's DOM.
module RichTextEditorHelper
# Rails' built-in helper drives Trix; this drives Lexxy
def fill_in_rich_text_area(id, with:)
find("lexxy-editor##{id}").execute_script("this.value = arguments[0]", with)
end
def composer_editor
find("#composer lexxy-editor .lexxy-editor__content")
end
def type_in_composer(text)
composer_editor.click
composer_editor.send_keys(text)
end
def press_in_composer(*keys)
composer_editor.send_keys(*keys)
end
# The rich text button is only displayed for fine-pointer devices, a media
# query headless Chrome doesn't satisfy, so click it directly.
def toggle_rich_text_toolbar
page.execute_script("document.querySelector('#composer .composer__rich-text-btn').click()")
end
# Waits for the suggestion to appear, then commits the selected one with Tab.
def pick_mention(name)
assert_selector ".lexxy-prompt-menu__item", text: name
composer_editor.send_keys :tab
end
def paste_in_composer(text)
composer_editor.click
page.execute_script(<<~JS, text)
const content = document.querySelector("#composer lexxy-editor .lexxy-editor__content")
const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: new DataTransfer() })
event.clipboardData.setData("text/plain", arguments[0])
content.dispatchEvent(event)
JS
end
def assert_composer_text(text)
assert_selector "#composer lexxy-editor .lexxy-editor__content", text: text
end
def assert_composer_empty
assert_no_selector "#composer lexxy-editor .lexxy-editor__content", text: /./
end
def assert_edit_editor_text(text)
assert_selector ".message__body-content--editing lexxy-editor", text: text
end
end
File diff suppressed because one or more lines are too long