mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-09-09 18:21:34 +09:00
Replace Trix with Lexxy
This commit is contained in:
@@ -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 ]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user