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
-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) {