diff --git a/Gemfile b/Gemfile index 1077c90..4587f3b 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index 1cd6c74..a442223 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/assets/stylesheets/actiontext.css b/app/assets/stylesheets/actiontext.css index d89140b..7130777 100644 --- a/app/assets/stylesheets/actiontext.css +++ b/app/assets/stylesheets/actiontext.css @@ -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; diff --git a/app/assets/stylesheets/autocomplete.css b/app/assets/stylesheets/autocomplete.css index 6a4d390..225adc9 100644 --- a/app/assets/stylesheets/autocomplete.css +++ b/app/assets/stylesheets/autocomplete.css @@ -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; diff --git a/app/assets/stylesheets/composer.css b/app/assets/stylesheets/composer.css index c41eea9..dc01aed 100644 --- a/app/assets/stylesheets/composer.css +++ b/app/assets/stylesheets/composer.css @@ -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); diff --git a/app/assets/stylesheets/embeds.css b/app/assets/stylesheets/embeds.css index 42b8df9..bd53ba6 100644 --- a/app/assets/stylesheets/embeds.css +++ b/app/assets/stylesheets/embeds.css @@ -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; diff --git a/app/assets/stylesheets/messages.css b/app/assets/stylesheets/messages.css index 88bd151..73cc43b 100644 --- a/app/assets/stylesheets/messages.css +++ b/app/assets/stylesheets/messages.css @@ -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; } diff --git a/app/controllers/autocompletable/users_controller.rb b/app/controllers/autocompletable/users_controller.rb index 0141cfb..32aa8ee 100644 --- a/app/controllers/autocompletable/users_controller.rb +++ b/app/controllers/autocompletable/users_controller.rb @@ -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 } # 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 diff --git a/app/helpers/content_filters.rb b/app/helpers/content_filters.rb index 926a726..ef3b06c 100644 --- a/app/helpers/content_filters.rb +++ b/app/helpers/content_filters.rb @@ -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 diff --git a/app/helpers/content_filters/remove_solo_unfurled_link_text.rb b/app/helpers/content_filters/remove_solo_unfurled_link_text.rb index ad96284..2e2d247 100644 --- a/app/helpers/content_filters/remove_solo_unfurled_link_text.rb +++ b/app/helpers/content_filters/remove_solo_unfurled_link_text.rb @@ -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
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

, 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 diff --git a/app/helpers/content_filters/sanitize_tags.rb b/app/helpers/content_filters/sanitize_tags.rb index 4ea25bb..7801f12 100644 --- a/app/helpers/content_filters/sanitize_tags.rb +++ b/app/helpers/content_filters/sanitize_tags.rb @@ -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("") diff --git a/app/helpers/content_filters/style_unfurled_twitter_avatars.rb b/app/helpers/content_filters/style_unfurled_twitter_avatars.rb index cc4bb4e..0ca02cf 100644 --- a/app/helpers/content_filters/style_unfurled_twitter_avatars.rb +++ b/app/helpers/content_filters/style_unfurled_twitter_avatars.rb @@ -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 diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index 0d5a35c..73c7a03 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -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 }) diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb index c548ffe..3b66eba 100644 --- a/app/helpers/rich_text_helper.rb +++ b/app/helpers/rich_text_helper.rb @@ -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 diff --git a/app/helpers/rooms_helper.rb b/app/helpers/rooms_helper.rb index 3316b79..fc8b6f9 100644 --- a/app/helpers/rooms_helper.rb +++ b/app/helpers/rooms_helper.rb @@ -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 diff --git a/app/javascript/application.js b/app/javascript/application.js index 4b5785f..e917b49 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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" diff --git a/app/javascript/controllers/composer_controller.js b/app/javascript/controllers/composer_controller.js index 8346def..bb8c0eb 100644 --- a/app/javascript/controllers/composer_controller.js +++ b/app/javascript/controllers/composer_controller.js @@ -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() { diff --git a/app/javascript/controllers/messages_controller.js b/app/javascript/controllers/messages_controller.js index 6407e3d..5033789 100644 --- a/app/javascript/controllers/messages_controller.js +++ b/app/javascript/controllers/messages_controller.js @@ -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() diff --git a/app/javascript/controllers/reply_controller.js b/app/javascript/controllers/reply_controller.js index 5401adc..a90f73c 100644 --- a/app/javascript/controllers/reply_controller.js +++ b/app/javascript/controllers/reply_controller.js @@ -11,7 +11,7 @@ export default class extends Controller { } reply() { - const content = `

${this.#bodyContent}
${this.authorTarget.innerHTML} ${this.#linkToOriginal}
` + const content = `
${this.#bodyContent}
${this.authorTarget.innerHTML} ${this.#linkToOriginal}


` 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 } diff --git a/app/javascript/controllers/rich_autocomplete_controller.js b/app/javascript/controllers/rich_autocomplete_controller.js deleted file mode 100644 index 5b7eac0..0000000 --- a/app/javascript/controllers/rich_autocomplete_controller.js +++ /dev/null @@ -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 - } -} diff --git a/app/javascript/controllers/unfurl_controller.js b/app/javascript/controllers/unfurl_controller.js new file mode 100644 index 0000000..4fb675d --- /dev/null +++ b/app/javascript/controllers/unfurl_controller.js @@ -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 ` +
+
+ +
${escapeHTML(truncateString(description, 560))}
+
+ ${image ? `
` : ""} +
+
` + } + + #isTwitterAvatar(image) { + return !!image?.startsWith(TWITTER_AVATAR_URL_PREFIX) + } +} diff --git a/app/javascript/initializers/rich_text.js b/app/javascript/initializers/rich_text.js index f204cd3..2d819ce 100644 --- a/app/javascript/initializers/rich_text.js +++ b/app/javascript/initializers/rich_text.js @@ -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" ] + } +}) diff --git a/app/javascript/lib/autocomplete/mentions_autocomplete_handler.js b/app/javascript/lib/autocomplete/mentions_autocomplete_handler.js deleted file mode 100644 index 241a440..0000000 --- a/app/javascript/lib/autocomplete/mentions_autocomplete_handler.js +++ /dev/null @@ -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 = ` - - ${mentionable.name} - ${mentionable.name} - - ` - - 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 : {} - } -} diff --git a/app/javascript/lib/rich_text/campfire_extension.js b/app/javascript/lib/rich_text/campfire_extension.js new file mode 100644 index 0000000..3ba60e3 --- /dev/null +++ b/app/javascript/lib/rich_text/campfire_extension.js @@ -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 ] + }) + } +} diff --git a/app/javascript/lib/rich_text/cite_node.js b/app/javascript/lib/rich_text/cite_node.js new file mode 100644 index 0000000..dca6b20 --- /dev/null +++ b/app/javascript/lib/rich_text/cite_node.js @@ -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") } + } +} diff --git a/app/javascript/lib/rich_text/unfurl/lib/opengraph_embed_operation.js b/app/javascript/lib/rich_text/unfurl/lib/opengraph_embed_operation.js deleted file mode 100644 index e261fdb..0000000 --- a/app/javascript/lib/rich_text/unfurl/lib/opengraph_embed_operation.js +++ /dev/null @@ -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 ` -
-
-
${truncateString(embed.title, 560)}
-
${truncateString(embed.description, 560)}
-
-
- -
-
-
` - } - - #isTwitterAvatar(embed) { - return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX) - } -} diff --git a/app/javascript/lib/rich_text/unfurl/lib/paste.js b/app/javascript/lib/rich_text/unfurl/lib/paste.js deleted file mode 100644 index 5b3c44e..0000000 --- a/app/javascript/lib/rich_text/unfurl/lib/paste.js +++ /dev/null @@ -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] - } -} diff --git a/app/javascript/lib/rich_text/unfurl/unfurler.js b/app/javascript/lib/rich_text/unfurl/unfurler.js deleted file mode 100644 index d60804a..0000000 --- a/app/javascript/lib/rich_text/unfurl/unfurler.js +++ /dev/null @@ -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") - } -} diff --git a/app/javascript/models/client_message.js b/app/javascript/models/client_message.js index e075b20..68ce212 100644 --- a/app/javascript/models/client_message.js +++ b/app/javascript/models/client_message.js @@ -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(/
(?:)*([\s\S]*?)<\/div>/i, '$1') + #plainTextFromNode(node) { + return this.#isRichText(node) ? node.toString()?.trim() : node } - #isRichText(node) { return typeof(node) != "string" } #richTextContent(node) { - return `
${node.innerHTML}
` + return `
${node.value}
` } diff --git a/app/javascript/models/message_formatter.js b/app/javascript/models/message_formatter.js index d8dbc23..b5f9b27 100644 --- a/app/javascript/models/message_formatter.js +++ b/app/javascript/models/message_formatter.js @@ -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
, Trix-era blocks used newlines + #normalizeLineBreaks(block) { + block.querySelectorAll("br").forEach(br => br.replaceWith("\n")) } #isPlainText(element) { diff --git a/app/models/user/mentionable.rb b/app/models/user/mentionable.rb index 85c8fc7..bb763f2 100644 --- a/app/models/user/mentionable.rb +++ b/app/models/user/mentionable.rb @@ -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 diff --git a/app/views/action_text/attachables/_opengraph_embed.html.erb b/app/views/action_text/attachables/_opengraph_embed.html.erb index f82208a..6685176 100644 --- a/app/views/action_text/attachables/_opengraph_embed.html.erb +++ b/app/views/action_text/attachables/_opengraph_embed.html.erb @@ -5,7 +5,7 @@
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
-
<%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %>
+
<%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…").html_safe %>
<% if opengraph_embed.url %>
diff --git a/app/views/autocompletable/users/_prompt_item.html.erb b/app/views/autocompletable/users/_prompt_item.html.erb new file mode 100644 index 0000000..b858ec4 --- /dev/null +++ b/app/views/autocompletable/users/_prompt_item.html.erb @@ -0,0 +1,11 @@ + + + + diff --git a/app/views/autocompletable/users/index.html.erb b/app/views/autocompletable/users/index.html.erb new file mode 100644 index 0000000..06be9b3 --- /dev/null +++ b/app/views/autocompletable/users/index.html.erb @@ -0,0 +1 @@ +<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %> diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb index 9e3c0d0..f8bfda9 100644 --- a/app/views/layouts/action_text/contents/_content.html.erb +++ b/app/views/layouts/action_text/contents/_content.html.erb @@ -1,3 +1,3 @@ -
+
<%= yield -%>
diff --git a/app/views/messages/edit.html.erb b/app/views/messages/edit.html.erb index d9e5be3..4f356e9 100644 --- a/app/views/messages/edit.html.erb +++ b/app/views/messages/edit.html.erb @@ -14,17 +14,17 @@ <% else %>
<%= 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| %>
<%= 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 %>
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %> diff --git a/app/views/rooms/show/_composer.html.erb b/app/views/rooms/show/_composer.html.erb index b6b0dcb..8d9e3c5 100644 --- a/app/views/rooms/show/_composer.html.erb +++ b/app/views/rooms/show/_composer.html.erb @@ -19,15 +19,16 @@
<%= 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 %>