mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-09-18 06:22:08 +09:00
Hello world
First open source release of Campfire 🎉
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
|
||||
import Selection from "lib/autocomplete/selection"
|
||||
|
||||
export default class extends BaseAutocompleteHandler {
|
||||
#selection
|
||||
|
||||
constructor(element, select, url) {
|
||||
super(element, url)
|
||||
this.#selection = new Selection(select)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#selection.disconnect()
|
||||
}
|
||||
|
||||
|
||||
insertAutocompletable(autocompletable) {
|
||||
this.#selection.add(autocompletable.value, autocompletable.name, { avatarUrl: autocompletable.avatar_url })
|
||||
this.element.value = ""
|
||||
}
|
||||
|
||||
get pattern() {
|
||||
return new RegExp(`^(.*?)$`)
|
||||
}
|
||||
|
||||
remove(value) {
|
||||
this.#selection.remove(value)
|
||||
}
|
||||
|
||||
removeLastSelection() {
|
||||
this.#selection.removeLast()
|
||||
}
|
||||
|
||||
search(term) {
|
||||
super.updateWithContentAndPosition(term, 0)
|
||||
}
|
||||
|
||||
setAutocompletables(autocompletables) {
|
||||
super.setAutocompletables(this.#filterSelectedAutocompletables(autocompletables))
|
||||
}
|
||||
|
||||
#filterSelectedAutocompletables(autocompletables) {
|
||||
const selectedValues = this.#selection.values.concat(Current.user.id)
|
||||
return autocompletables.filter(autocompletable => !selectedValues.includes(autocompletable.value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Collection from "lib/autocomplete/collection"
|
||||
import SuggestionController from "lib/autocomplete/suggestion_controller"
|
||||
import { generateUUID } from "lib/autocomplete/helpers"
|
||||
import { Renderer } from "lib/autocomplete/renderer"
|
||||
|
||||
export default class BaseAutocompleteHandler {
|
||||
#autocompletables
|
||||
#url
|
||||
|
||||
constructor(element, url) {
|
||||
this.element = element
|
||||
this.#url = url
|
||||
if (!this.element.id) { this.element.id = `autocomplete_${generateUUID()}` }
|
||||
this.suggestionController = new SuggestionController(this)
|
||||
}
|
||||
|
||||
updateWithContentAndPosition(content, position) {
|
||||
if (this.suggestionController && this.shouldAutocompleteWithContentAndPosition(content, position)) {
|
||||
this.suggestionController.updateWithContentAndPosition(content, position)
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#closeSuggestionController()
|
||||
}
|
||||
|
||||
// Subclass methods
|
||||
|
||||
get pattern() {
|
||||
return null
|
||||
}
|
||||
|
||||
shouldAutocompleteWithContentAndPosition(content, position) {
|
||||
return true
|
||||
}
|
||||
|
||||
getAutocompletable(value) {
|
||||
return this.#autocompletables.get(value)
|
||||
}
|
||||
|
||||
autocompletablesMatchingQuery(query) {
|
||||
return this.#autocompletables.matchingQuery(query).toArray()
|
||||
}
|
||||
|
||||
loadAutocompletables(query, callback) {
|
||||
const url = query ? this.#autocompletablesUrl(query) : this.#url
|
||||
|
||||
this.#fetchAutocompletables(url).then((autocompletables) => {
|
||||
this.setAutocompletables(autocompletables)
|
||||
callback()
|
||||
})
|
||||
}
|
||||
|
||||
setAutocompletables(autocompletables) {
|
||||
this.#autocompletables = new Collection(autocompletables)
|
||||
}
|
||||
|
||||
// SuggestionController Delegate
|
||||
|
||||
getSuggestionsIdentifier() {
|
||||
return `${this.element.id}_suggestions`
|
||||
}
|
||||
|
||||
matchQueryAndTerminatorForWord(word) {
|
||||
if (!this.pattern) return
|
||||
|
||||
const match = word.match(this.pattern)
|
||||
if (match) {
|
||||
return {
|
||||
query: match[1],
|
||||
terminator: match?.[2] || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getOffsetsAtPosition(position) {
|
||||
return this.element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
getResultsPlacement() {
|
||||
return this.#suggestionResultsPlacement
|
||||
}
|
||||
|
||||
fetchResultsForQuery(query, callback) {
|
||||
this.loadAutocompletables(query, () => {
|
||||
const autocompletables = this.autocompletablesMatchingQuery(query)
|
||||
const html = new Renderer().renderAutocompletableSuggestions(autocompletables)
|
||||
callback(html)
|
||||
})
|
||||
}
|
||||
|
||||
willCommitValueAtRangeWithTerminator(value, range, terminator) {
|
||||
const autocompletable = this.getAutocompletable(value)
|
||||
this.insertAutocompletable(autocompletable, range, terminator)
|
||||
}
|
||||
|
||||
didShowResults(selectElement) {
|
||||
selectElement.classList.add("rich_text")
|
||||
}
|
||||
|
||||
#autocompletablesUrl(query) {
|
||||
const separator = this.#url.includes('?') ? '&' : '?'
|
||||
return `${this.#url}${separator}query=${query}`
|
||||
}
|
||||
|
||||
get #suggestionResultsPlacement() {
|
||||
return this.element.dataset.suggestionResultsPlacement
|
||||
}
|
||||
|
||||
#closeSuggestionController() {
|
||||
if (!this.suggestionController) return
|
||||
|
||||
if (this.suggestionController.active) {
|
||||
this.suggestionController.hideResults()
|
||||
} else {
|
||||
this.suggestionController.destroy()
|
||||
this.suggestionController = null
|
||||
}
|
||||
}
|
||||
|
||||
#fetchAutocompletables(url) {
|
||||
if (url) {
|
||||
return fetch(url, { as: "json" }).then(response => response.json())
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { camelize, normalize, regexpForQuery, uniqueValues } from "lib/autocomplete/utils"
|
||||
|
||||
export default class AutocompletableCollection {
|
||||
#autocompletables
|
||||
#index
|
||||
|
||||
constructor(autocompletables = [], options = {}) {
|
||||
this.#index = new Map()
|
||||
this.#autocompletables = new Array()
|
||||
|
||||
Array.from(autocompletables).forEach((autocompletable) => {
|
||||
this.#index.set(this.#uniqueAutocompleteableKey(autocompletable), autocompletable)
|
||||
})
|
||||
|
||||
this.#index.forEach(autocompletable => {
|
||||
this.#autocompletables.push(autocompletable)
|
||||
})
|
||||
|
||||
if (options.sort !== false) {
|
||||
this.#autocompletables.sort(this.#compareAutocompletables)
|
||||
}
|
||||
}
|
||||
|
||||
get(value) {
|
||||
return this.#index.get(value.toString())
|
||||
}
|
||||
|
||||
has(value) {
|
||||
return this.#index.has(value.toString())
|
||||
}
|
||||
|
||||
add(autocompletables = [], collectionOptions) {
|
||||
return new this.constructor(this.#autocompletables.concat(autocompletables), collectionOptions)
|
||||
}
|
||||
|
||||
getValues() {
|
||||
return this.#autocompletables.map(autocompletable => autocompletable.value)
|
||||
}
|
||||
|
||||
withValues(values = [], collectionOptions) {
|
||||
const autocompletables = values.map((value) => this.get(value)).filter(Boolean)
|
||||
return new this.constructor(autocompletables, collectionOptions)
|
||||
}
|
||||
|
||||
withoutValues(values = [], collectionOptions) {
|
||||
const autocompletables = []
|
||||
this.#index.forEach(function(autocompletable, value) {
|
||||
const allGroupMembersAreAdded = autocompletable.type == "group" && autocompletable.value.split(",").every(id => values.includes(id))
|
||||
|
||||
if (!values.includes(value) && !allGroupMembersAreAdded) {
|
||||
return autocompletables.push(autocompletable)
|
||||
}
|
||||
})
|
||||
return new this.constructor(autocompletables, collectionOptions)
|
||||
}
|
||||
|
||||
filter(callback, collectionOptions) {
|
||||
if (!callback) { return this }
|
||||
|
||||
const autocompletables = []
|
||||
this.#index.forEach(function(autocompletable, value) {
|
||||
if (callback(autocompletable)) {
|
||||
return autocompletables.push(autocompletable)
|
||||
}
|
||||
})
|
||||
|
||||
return new this.constructor(autocompletables, collectionOptions)
|
||||
}
|
||||
|
||||
matchingQuery(query) {
|
||||
if (!query) { return this }
|
||||
|
||||
return new this.constructor(
|
||||
this.#matchAutocompletablesByNameOrDescription(this.#autocompletables, query),
|
||||
{ sort: false }
|
||||
)
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return this.#autocompletables.slice(0)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.toArray()
|
||||
}
|
||||
|
||||
isEqualTo(collection) {
|
||||
if (!collection || (this.#autocompletables.length !== collection.length)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return JSON.stringify(this) === JSON.stringify(collection)
|
||||
}
|
||||
|
||||
#compareAutocompletables(autocompletable, otherAutocompletable) {
|
||||
return autocompletable.name.localeCompare(otherAutocompletable.name)
|
||||
}
|
||||
|
||||
#matchAutocompletablesByNameOrDescription(autocompletables, query) {
|
||||
return uniqueValues([].concat(
|
||||
this.#matchAutocompletablesByNameAtHead(autocompletables, query),
|
||||
this.#matchAutocompletablesByRestOfName(autocompletables, query),
|
||||
this.#matchAutocompletablesByRestOfDescription(autocompletables, query))
|
||||
)
|
||||
}
|
||||
|
||||
#matchAutocompletablesByNameAtHead(autocompletables, query) {
|
||||
return this.#matchAutocompletablesByRegExp(autocompletables, regexpForQuery(query, "^"))
|
||||
}
|
||||
|
||||
#matchAutocompletablesByRestOfName(autocompletables, query) {
|
||||
return this.#matchAutocompletablesByRegExp(autocompletables, regexpForQuery(query, "\\s"))
|
||||
}
|
||||
|
||||
#matchAutocompletablesByRestOfDescription(autocompletables, query) {
|
||||
return this.#matchAutocompletablesByRegExp(autocompletables, regexpForQuery("", query), "description")
|
||||
}
|
||||
|
||||
#matchAutocompletablesByRegExp(autocompletables, regexp, propertyName = "name") {
|
||||
return autocompletables.filter(autocompletable => {
|
||||
const normalizedPropertyName = `normalized${camelize(propertyName)}`
|
||||
const property = autocompletable[propertyName]
|
||||
|
||||
if (property) {
|
||||
if (!autocompletable[normalizedPropertyName]) autocompletable[normalizedPropertyName] = normalize(property)
|
||||
return regexp.test(autocompletable[normalizedPropertyName])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#uniqueAutocompleteableKey(autocompletable) {
|
||||
return autocompletable.value.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const PUNCTUATION_PATTERN = /[\u0021-\u0023\u0025-\u002A\u002C-\u002F\u003A\u003B\u003F\u0040\u005B-\u005D\u005F\u007B\u007D\u00A1\u00A7\u00AB\u00B6\u00B7\u00BB\u00BF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E3B\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]/
|
||||
@@ -0,0 +1,59 @@
|
||||
import { generateUUID, synchronize } from "lib/autocomplete/helpers"
|
||||
|
||||
export default class extends HTMLElement {
|
||||
constructor() {
|
||||
super(...arguments)
|
||||
this.flash = synchronize(this.flash)
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.id ||= `option-${generateUUID()}`
|
||||
}
|
||||
|
||||
get selectElement() {
|
||||
return this.closest("suggestion-select")
|
||||
}
|
||||
|
||||
get index() {
|
||||
if (this.selectElement) {
|
||||
return Array.from(this.selectElement.optionElements).indexOf(this)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return this.hasAttribute("selected")
|
||||
}
|
||||
|
||||
set selected(value) {
|
||||
if (value) {
|
||||
this.setAttribute("selected", "")
|
||||
} else {
|
||||
this.removeAttribute("selected")
|
||||
}
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.getAttribute("value")
|
||||
}
|
||||
|
||||
flash(callback) {
|
||||
const drawFrame = (frame = 0) => {
|
||||
requestAnimationFrame(() => {
|
||||
if (frame == 0) {
|
||||
this.classList.add("flashing-off")
|
||||
} else if (frame == 4) {
|
||||
this.classList.remove("flashing-off")
|
||||
}
|
||||
|
||||
if (frame == 7) {
|
||||
callback()
|
||||
} else {
|
||||
drawFrame(frame + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
drawFrame(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
if (!this.hasAttribute("role")) this.setAttribute("role", "listbox")
|
||||
}
|
||||
|
||||
get optionElements() {
|
||||
return this.querySelectorAll("suggestion-option")
|
||||
}
|
||||
|
||||
get selectedIndex() {
|
||||
const selected = this.querySelector("suggestion-option[selected]")
|
||||
return selected?.index
|
||||
}
|
||||
|
||||
set selectedIndex(value) {
|
||||
const optionElements = this.optionElements
|
||||
const optionCount = optionElements.length
|
||||
|
||||
if (!optionElements.length) return
|
||||
|
||||
Array.from(optionElements).forEach(option => {
|
||||
option.selected = false
|
||||
})
|
||||
|
||||
if (value === null || typeof value === "undefined") return
|
||||
|
||||
const index = Math.max(0, Math.min(optionCount - 1, parseInt(value, 10)))
|
||||
optionElements[index].selected = true
|
||||
}
|
||||
|
||||
get selectedOption() {
|
||||
return this.optionElements[this.selectedIndex]
|
||||
}
|
||||
|
||||
set selectedOption(option) {
|
||||
if (option.selectElement === this) {
|
||||
this.selectedIndex = option.index
|
||||
}
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.selectedOption?.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export function generateUUID() {
|
||||
const template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
|
||||
return template.replace(/[xy]/g, function(char) {
|
||||
const rand = (Math.random() * 16) | 0
|
||||
const value = char === "x" ? rand : ((rand & 0x3)|0x8)
|
||||
return value.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
export function synchronize(fn) {
|
||||
const monitorCallbacks = new WeakMap
|
||||
return function(callback) {
|
||||
let callbacks = monitorCallbacks.get(this)
|
||||
if (!callbacks) {
|
||||
monitorCallbacks.set(this, (callbacks = []))
|
||||
}
|
||||
callbacks.push(callback)
|
||||
|
||||
if (callbacks.length === 1) {
|
||||
return fn.call(this, () => {
|
||||
Array.from(callbacks).forEach((callback) => { callback?.() })
|
||||
return monitorCallbacks.delete(this)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function transitionElementWithClass(element, className, callback) {
|
||||
return applyClassAwaitingEvent(element, className, "transitionend", callback)
|
||||
}
|
||||
|
||||
const applyClassAwaitingEvent = function(element, className, eventName, callback) {
|
||||
let timeout
|
||||
let uninstalled = false
|
||||
|
||||
const uninstall = function() {
|
||||
if (!uninstalled) {
|
||||
uninstalled = true
|
||||
element.removeEventListener(eventName, uninstall)
|
||||
return requestAnimationFrame(function() {
|
||||
element.classList.remove(className)
|
||||
return callback?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener(eventName, uninstall)
|
||||
element.classList.add(className)
|
||||
|
||||
// Failsafe: If we don't receive a {transition,animation}end event
|
||||
// for some reason, ensure that uninstall is still called.
|
||||
const duration = getDuration(element, eventName)
|
||||
if (duration) {
|
||||
timeout = duration + 50
|
||||
} else {
|
||||
timeout = 50
|
||||
}
|
||||
|
||||
return setTimeout(uninstall, timeout)
|
||||
}
|
||||
|
||||
const getDuration = function(element, eventName) {
|
||||
const type = eventName === "animationend" ? "animation" : "transition"
|
||||
const duration = getComputedStyle(element)[`${type}Duration`]
|
||||
|
||||
if (duration) {
|
||||
if (/ms/.test(duration)) {
|
||||
return parseInt(duration, 10)
|
||||
} else {
|
||||
return parseFloat(duration) * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getElementMargin(element) {
|
||||
const result = {}
|
||||
const style = window.getComputedStyle(element);
|
||||
|
||||
["Top", "Right", "Bottom", "Left"].forEach((side) => {
|
||||
result[side.toLowerCase()] = parseInt(style[`margin${side}`], 10)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getAbsolutePositionForOffsets({ top, right, bottom, left }) {
|
||||
return {
|
||||
top: top + window.scrollY,
|
||||
right: right + window.scrollX,
|
||||
bottom: bottom + window.scrollY,
|
||||
left: left + window.scrollX
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function getViewportRect() {
|
||||
return {
|
||||
top: window.scrollY,
|
||||
right: window.scrollX + window.innerWidth,
|
||||
bottom: window.scrollY + window.innerHeight,
|
||||
left: window.scrollX
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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,40 @@
|
||||
export class Renderer {
|
||||
renderAutocompletableSuggestions(autocompletables, options = {}) {
|
||||
const { selectedAutocompletable } = options
|
||||
let html = ""
|
||||
|
||||
autocompletables.forEach((autocompletable) => {
|
||||
const isSelected = autocompletable === selectedAutocompletable
|
||||
const multipleAttr = autocompletable.type === "group" ? "multiple" : ""
|
||||
const selectedAriaSelectedAttrs = isSelected ? "selected aria-selected" : ""
|
||||
|
||||
html += `
|
||||
<suggestion-option class="autocomplete__item flex align-center gap unpad" role="option" value="${autocompletable.value}" ${multipleAttr} ${selectedAriaSelectedAttrs}>
|
||||
${
|
||||
autocompletable.pending
|
||||
? `Add <strong>${autocompletable.name}…</strong>`
|
||||
: autocompletable.noResultsLabel
|
||||
? `<span class="txt--disable-truncate">${autocompletable.noResultsLabel}</span>`
|
||||
: this.renderAutocompletable(autocompletable)
|
||||
}
|
||||
</suggestion-option>
|
||||
`
|
||||
})
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
renderAutocompletable(autocompletable) {
|
||||
const html = `
|
||||
<button class="autocomplete__btn btn btn--borderless btn--transparent min-width flex-item-grow justify-start" data-value="${autocompletable.value}">
|
||||
<span class="avatar">
|
||||
<img src="${autocompletable.avatar_url}" class="automcomplete__avatar" role="presentation" />
|
||||
</span>
|
||||
<span class="autocompletable__name">${autocompletable.name}</span>
|
||||
<a href="#" class="autocompletable__unselect" aria-label="Remove ${autocompletable.name}" data-behavior="unselect_autocompletable">×</a>
|
||||
</button>
|
||||
`
|
||||
|
||||
return html
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { memoize } from "lib/autocomplete/utils"
|
||||
|
||||
export default class Selection {
|
||||
#elements = []
|
||||
#element
|
||||
#observer
|
||||
|
||||
constructor(element) {
|
||||
this.#element = element
|
||||
this.#observeMutations()
|
||||
this.#render()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#observer.disconnect()
|
||||
this.#render()
|
||||
}
|
||||
|
||||
add(value, label, options = {}) {
|
||||
this.#element.append(this.#findOption(value) || this.#createOption(value, label, options))
|
||||
}
|
||||
|
||||
remove(value) {
|
||||
this.#findOption(value)?.remove()
|
||||
}
|
||||
|
||||
removeLast() {
|
||||
this.#lastOption?.remove()
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.#options.map(option => parseInt(option.value))
|
||||
}
|
||||
|
||||
#createOption(value, label, { avatarUrl }) {
|
||||
const option = new Option(label, value, true, true)
|
||||
option.dataset.avatarUrl = avatarUrl
|
||||
return option
|
||||
}
|
||||
|
||||
#findOption(value) {
|
||||
return this.#options.find(option => option.value == value)
|
||||
}
|
||||
|
||||
get #lastOption() {
|
||||
return this.#options.slice(-1)[0]
|
||||
}
|
||||
|
||||
#observeMutations() {
|
||||
this.#observer = new MutationObserver(this.#optionsChanged)
|
||||
this.#observer.observe(this.#element, { childList: true })
|
||||
}
|
||||
|
||||
#optionsChanged = () => {
|
||||
this.#render()
|
||||
}
|
||||
|
||||
get #options() {
|
||||
return Array.from(this.#element.options)
|
||||
}
|
||||
|
||||
#render() {
|
||||
for (const element of this.#elements) element.remove()
|
||||
this.#elements = this.#element.isConnected ? this.#options.map(this.#renderElementForOption) : []
|
||||
}
|
||||
|
||||
#renderElementForOption = (option) => {
|
||||
const { value, label } = option
|
||||
const content = this.#template.content.cloneNode(true)
|
||||
content.querySelectorAll("[data-value]").forEach(element => element.dataset.value = value)
|
||||
content.querySelector("[data-content=label]").textContent = label
|
||||
content.querySelector("[data-content=label]").title = value
|
||||
content.querySelector("[data-content=screenReaderLabel]").textContent = label
|
||||
content.querySelector("[data-content=avatar]").src = option.dataset.avatarUrl
|
||||
return this.#template.insertAdjacentElement("beforebegin", content.firstElementChild)
|
||||
}
|
||||
|
||||
get #template() {
|
||||
const id = this.#element.getAttribute("data-template-id")
|
||||
return memoize(this, "template", document.getElementById(id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export default class SuggestionContext {
|
||||
#content
|
||||
#position
|
||||
|
||||
constructor(delegate, content, position) {
|
||||
this.#content = content
|
||||
this.#position = position
|
||||
|
||||
const { matchQueryAndTerminatorForWord, characterMatchesWordBoundary } = delegate
|
||||
const bounds = this.#findWordBoundsFromStringAtPosition(characterMatchesWordBoundary)
|
||||
|
||||
if (bounds) {
|
||||
[this.startPosition, this.endPosition] = Array.from(bounds)
|
||||
this.word = this.#content.slice(...Array.from(bounds || []))
|
||||
|
||||
const match = matchQueryAndTerminatorForWord(this.word)
|
||||
if (match) {
|
||||
const {query, terminator} = match
|
||||
if (query.length) { this.query = query }
|
||||
if (terminator.length) { this.terminator = terminator }
|
||||
this.active = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return this.active
|
||||
}
|
||||
|
||||
isTerminated() {
|
||||
return this.terminator?.length && (this.#position === this.endPosition)
|
||||
}
|
||||
|
||||
isEqualTo(context) {
|
||||
return false
|
||||
}
|
||||
|
||||
#findWordBoundsFromStringAtPosition(characterMatchesWordBoundary) {
|
||||
let char, index
|
||||
let start = (index = this.#position)
|
||||
|
||||
while (--index >= 0) {
|
||||
char = this.#content.charAt(index)
|
||||
if (characterMatchesWordBoundary(char)) { break }
|
||||
start = index
|
||||
}
|
||||
|
||||
let end = (index = this.#position)
|
||||
while (index < this.#content.length) {
|
||||
char = this.#content.charAt(index)
|
||||
if (characterMatchesWordBoundary(char)) { break }
|
||||
end = ++index
|
||||
}
|
||||
|
||||
if (start !== end) {
|
||||
return [ start, end ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import SuggestionResultsController from "lib/autocomplete/suggestion_results_controller"
|
||||
import SuggestionContext from "lib/autocomplete/suggestion_context"
|
||||
|
||||
export default class SuggestionController {
|
||||
#active = false
|
||||
#canceled = false
|
||||
#committing = false
|
||||
#context
|
||||
#resultsController
|
||||
|
||||
constructor(delegate) {
|
||||
this.delegate = delegate
|
||||
this.commitSuggestion = this.commitSuggestion.bind(this)
|
||||
this.characterMatchesWordBoundary = this.characterMatchesWordBoundary.bind(this)
|
||||
this.matchQueryAndTerminatorForWord = this.matchQueryAndTerminatorForWord.bind(this)
|
||||
this.didPressKey = this.didPressKey.bind(this)
|
||||
this.didResizeWindow = this.didResizeWindow.bind(this)
|
||||
this.didScrollWindow = this.didScrollWindow.bind(this)
|
||||
this.#installKeyboardListener()
|
||||
this.#installResizeListeners()
|
||||
}
|
||||
|
||||
updateWithContentAndPosition(content, position) {
|
||||
if (this.#committing) { return }
|
||||
const previousContext = this.#context
|
||||
this.#context = new SuggestionContext(this, content, position)
|
||||
|
||||
if (!this.#context.isEqualTo(previousContext)) {
|
||||
if (this.#context.isTerminated()) {
|
||||
return this.commitSuggestion()
|
||||
} else if (this.#context.isActive()) {
|
||||
return this.#activateSuggestion()
|
||||
} else {
|
||||
return this.#deactivateSuggestion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hideResults() {
|
||||
return this.#resultsController.hide()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#uninstallResultsController()
|
||||
this.#uninstallResizeListeners()
|
||||
this.#uninstallKeyboardListener()
|
||||
}
|
||||
|
||||
commitSuggestion({withTerminator} = {}) {
|
||||
if (this.#committing || this.#canceled || !this.#active) { return false }
|
||||
|
||||
const values = this.selectedValues
|
||||
if (values.length == 0) { return false }
|
||||
|
||||
const range = [this.#context.startPosition, this.#context.endPosition]
|
||||
const terminator = (withTerminator != null ? withTerminator : this.#context.terminator) || " "
|
||||
|
||||
this.#committing = true
|
||||
this.#resultsController.flashSelection(() => {
|
||||
this.#committing = false
|
||||
this.#didCommitValuesAtRangeWithTerminator(values, range, terminator)
|
||||
this.#deactivateSuggestionWithAnimation()
|
||||
})
|
||||
|
||||
if (values.length > 1) {
|
||||
this.#willCommitValuesAtRangeWithTerminator(values, range, terminator, { editor: this.delegate.editor })
|
||||
} else {
|
||||
this.delegate.willCommitValueAtRangeWithTerminator?.(values[0], range, terminator)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
get selectedValues() {
|
||||
const value = this.#resultsController.getSelectedValue()
|
||||
if (!value) { return [] }
|
||||
|
||||
const valueOnlyHasCommaSeparatedNumbers = /^\d+(,\d+)*$/.test(value)
|
||||
return valueOnlyHasCommaSeparatedNumbers ? value.split(",") : [value]
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return this.#active
|
||||
}
|
||||
|
||||
isCanceled() {
|
||||
return this.#canceled
|
||||
}
|
||||
|
||||
#activateSuggestion() {
|
||||
if (!this.#canceled) {
|
||||
this.#active = true
|
||||
this.#installResultsController()
|
||||
return this.#updateResults(() => {
|
||||
if (this.#resultsController.hasResults()) {
|
||||
return this.#displayResults()
|
||||
} else {
|
||||
return this.hideResults()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#deactivateSuggestionWithAnimation() {
|
||||
this.#hideResultsWithAnimation(() => {
|
||||
this.#deactivateSuggestion()
|
||||
})
|
||||
}
|
||||
|
||||
#deactivateSuggestion() {
|
||||
this.#uninstallResultsController()
|
||||
this.#active = false
|
||||
this.#canceled = false
|
||||
}
|
||||
|
||||
#cancelSuggestion() {
|
||||
if (this.#active) {
|
||||
this.#deactivateSuggestion()
|
||||
this.#canceled = true
|
||||
}
|
||||
}
|
||||
|
||||
#resumeSuggestion() {
|
||||
if (this.#canceled) {
|
||||
this.#canceled = false
|
||||
return this.#activateSuggestion()
|
||||
}
|
||||
}
|
||||
|
||||
#willCommitValuesAtRangeWithTerminator(values, range, terminator, { editor }) {
|
||||
editor?.setSelectedRange(range) && editor?.deleteInDirection("forward") // Delete user autocomplete input
|
||||
|
||||
values.forEach((value) => {
|
||||
this.delegate.willCommitValueAtRangeWithTerminator?.(value, null, terminator)
|
||||
range = this.#advanceRangeForNextValue(range)
|
||||
})
|
||||
}
|
||||
|
||||
#didCommitValuesAtRangeWithTerminator(values, range, terminator) {
|
||||
values.forEach((value) => {
|
||||
this.delegate.didCommitValueAtRangeWithTerminator?.(value, range, terminator)
|
||||
range = this.#advanceRangeForNextValue(range)
|
||||
})
|
||||
}
|
||||
|
||||
#advanceRangeForNextValue(range) {
|
||||
const startPosition = range[1] + 1
|
||||
return Array(startPosition, startPosition + 1)
|
||||
}
|
||||
|
||||
#displayResults() {
|
||||
if (this.#active) {
|
||||
const offsets = this.delegate.getOffsetsAtPosition(this.#context.startPosition)
|
||||
const placement = this.delegate.getResultsPlacement?.()
|
||||
return this.#resultsController.displayAtOffsets(offsets, {placement})
|
||||
}
|
||||
}
|
||||
|
||||
#updateResults(callback) {
|
||||
const query = this.#context?.query
|
||||
return this.delegate.fetchResultsForQuery(query, results => {
|
||||
if ((this.#resultsController != null) && (query === this.#context?.query)) {
|
||||
this.#resultsController.updateResults(results)
|
||||
return callback?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#hideResultsWithAnimation(callback) {
|
||||
return this.#resultsController?.hideWithAnimation(callback)
|
||||
}
|
||||
|
||||
// Suggestion context delegate
|
||||
|
||||
characterMatchesWordBoundary(character) {
|
||||
if (this.delegate.characterMatchesWordBoundary != null) {
|
||||
return this.delegate.characterMatchesWordBoundary(character)
|
||||
} else {
|
||||
return /[\s\uFFFC]/.test(character)
|
||||
}
|
||||
}
|
||||
|
||||
matchQueryAndTerminatorForWord(word) {
|
||||
return this.delegate.matchQueryAndTerminatorForWord(word)
|
||||
}
|
||||
|
||||
// Results controller delegate
|
||||
|
||||
didClickOption(option) {
|
||||
return setTimeout(this.commitSuggestion, 100)
|
||||
}
|
||||
|
||||
didShowResults(element) {
|
||||
this.hidden = false
|
||||
return this.delegate.didShowResults?.(element)
|
||||
}
|
||||
|
||||
didHideResults(element) {
|
||||
this.hidden = true
|
||||
return this.delegate.didHideResults?.(element)
|
||||
}
|
||||
|
||||
// Keyboard events
|
||||
|
||||
didPressKey(event) {
|
||||
if (this.#committing) { return }
|
||||
|
||||
let result
|
||||
switch (event.keyCode) {
|
||||
case 9:
|
||||
result = this.#didPressTabKey()
|
||||
break
|
||||
case 10: case 13:
|
||||
result = this.#didPressReturnKey()
|
||||
break
|
||||
case 27:
|
||||
result = this.#didPressEscapeKey()
|
||||
break
|
||||
case 32:
|
||||
result = this.#didPressSpaceKey()
|
||||
break
|
||||
case 38:
|
||||
result = this.#didPressUpKey()
|
||||
break
|
||||
case 40:
|
||||
result = this.#didPressDownKey()
|
||||
break
|
||||
default:
|
||||
result = this.#didPressKeyWithValue(event.key)
|
||||
}
|
||||
|
||||
if (result === false) {
|
||||
event.preventDefault()
|
||||
return event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
#didPressTabKey() {
|
||||
if (this.#active) {
|
||||
if (this.hidden) {
|
||||
this.#displayResults()
|
||||
return false
|
||||
} else if (!this.#committing) {
|
||||
if (this.commitSuggestion()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else if (this.#canceled) {
|
||||
this.#resumeSuggestion()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#didPressReturnKey() {
|
||||
if (this.#active) {
|
||||
if (this.commitSuggestion()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#didPressEscapeKey() {
|
||||
if (this.#active) {
|
||||
this.#cancelSuggestion()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#didPressSpaceKey() {
|
||||
if (this.#active && this.#spaceMatchesWordBoundary()) {
|
||||
if (this.commitSuggestion()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#didPressUpKey() {
|
||||
if (this.#active) {
|
||||
this.#resultsController.selectUp()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#didPressDownKey() {
|
||||
if (this.#active) {
|
||||
this.#resultsController.selectDown()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#didPressKeyWithValue(value) {
|
||||
if (this.#active && (value != null) && !this.hidden) {
|
||||
const result = this.matchQueryAndTerminatorForWord(value)
|
||||
if (result?.query === "") {
|
||||
this.#cancelSuggestion()
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll and resize events
|
||||
|
||||
didResizeWindow() {
|
||||
if (this.#active) {
|
||||
return this.hideResults()
|
||||
}
|
||||
}
|
||||
|
||||
didScrollWindow(event) {
|
||||
if (this.#active && (event.target === document)) {
|
||||
return this.hideResults()
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
#installKeyboardListener() {
|
||||
window.addEventListener("keydown", this.didPressKey, true)
|
||||
}
|
||||
|
||||
#uninstallKeyboardListener() {
|
||||
window.removeEventListener("keydown", this.didPressKey, true)
|
||||
}
|
||||
|
||||
#installResizeListeners() {
|
||||
window.addEventListener("resize", this.didResizeWindow, true)
|
||||
window.addEventListener("scroll", this.didScrollWindow, true)
|
||||
}
|
||||
|
||||
#uninstallResizeListeners() {
|
||||
window.removeEventListener("resize", this.didResizeWindow, true)
|
||||
window.removeEventListener("scroll", this.didScrollWindow, true)
|
||||
}
|
||||
|
||||
#installResultsController() {
|
||||
if (!this.#resultsController) {
|
||||
this.#resultsController = new SuggestionResultsController({ id: this.delegate.getSuggestionsIdentifier() })
|
||||
}
|
||||
this.#resultsController.delegate = this
|
||||
}
|
||||
|
||||
#uninstallResultsController() {
|
||||
this.#resultsController?.destroy()
|
||||
this.#resultsController = null
|
||||
}
|
||||
|
||||
#spaceMatchesWordBoundary() {
|
||||
return this.characterMatchesWordBoundary(" ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { getAbsolutePositionForOffsets, getElementMargin, getViewportRect, synchronize, transitionElementWithClass } from "lib/autocomplete/helpers"
|
||||
|
||||
export default class SuggestionResultsController {
|
||||
constructor(options = {}) {
|
||||
this.revealOption = this.revealOption.bind(this)
|
||||
this.didMouseDown = this.didMouseDown.bind(this)
|
||||
this.flashSelection = synchronize(this.flashSelection)
|
||||
this.id = options.id || `suggestion_results_${generateUUID()}`
|
||||
|
||||
this.#createSelectElement()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.hide()
|
||||
this.#removeSelectElement()
|
||||
}
|
||||
|
||||
displayAtOffsets(offsets, {placement} = {}) {
|
||||
let availableMaxHeight, elementHeight, height, left, maxHeight, top
|
||||
this.show()
|
||||
|
||||
this.selectElement.style.height = ""
|
||||
const style = getComputedStyle(this.selectElement)
|
||||
const margin = getElementMargin(this.selectElement)
|
||||
const position = getAbsolutePositionForOffsets(offsets)
|
||||
|
||||
const elementRect = this.selectElement.getBoundingClientRect()
|
||||
const viewportRect = getViewportRect()
|
||||
|
||||
const availableHeightAbove = position.top - viewportRect.top - margin.top
|
||||
const availableHeightBelow = viewportRect.bottom - position.bottom - margin.bottom
|
||||
const thresholdHeight = this.getOptionHeight() * 3
|
||||
|
||||
if (availableHeightAbove > thresholdHeight && thresholdHeight > availableHeightBelow) {
|
||||
if (placement == null) { placement = "above" }
|
||||
} else {
|
||||
if (placement == null) { placement = "below" }
|
||||
}
|
||||
|
||||
if (placement === "above") {
|
||||
availableMaxHeight = availableHeightAbove
|
||||
} else {
|
||||
availableMaxHeight = availableHeightBelow
|
||||
}
|
||||
|
||||
if (style.maxHeight === "none") {
|
||||
maxHeight = availableMaxHeight
|
||||
} else {
|
||||
const requestedMaxHeight = parseInt(style.maxHeight, 10)
|
||||
maxHeight = Math.min(availableMaxHeight, requestedMaxHeight)
|
||||
}
|
||||
|
||||
if (elementRect.height > maxHeight) {
|
||||
elementHeight = (height = maxHeight)
|
||||
} else {
|
||||
elementHeight = elementRect.height
|
||||
}
|
||||
|
||||
if (placement === "above") {
|
||||
top = position.top - elementHeight - margin.top
|
||||
} else {
|
||||
top = position.bottom - margin.top
|
||||
}
|
||||
|
||||
const elementRight = position.left + elementRect.width + margin.right
|
||||
|
||||
if (elementRight > viewportRect.right) {
|
||||
left = position.left - (elementRight - viewportRect.right) - margin.right
|
||||
} else {
|
||||
left = position.left - margin.right
|
||||
}
|
||||
|
||||
this.selectElement.style.top = `${top}px`
|
||||
this.selectElement.style.left = `${left}px`
|
||||
this.selectElement.style.height = height ? `${height}px` : "auto"
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.visible) {
|
||||
this.visible = true
|
||||
this.selectElement.setAttribute("aria-hidden", "false")
|
||||
this.selectElement.style.visibility = ""
|
||||
return this.delegate.didShowResults(this.selectElement)
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.visible) {
|
||||
this.visible = false
|
||||
this.selectElement.style.visibility = "hidden"
|
||||
this.selectElement.setAttribute("aria-hidden", "true")
|
||||
return this.delegate.didHideResults(this.selectElement)
|
||||
}
|
||||
}
|
||||
|
||||
hideWithAnimation = synchronize((callback) => {
|
||||
if (this.visible) {
|
||||
return transitionElementWithClass(this.selectElement, "hiding", () => {
|
||||
this.hide()
|
||||
return callback()
|
||||
})
|
||||
} else {
|
||||
return callback()
|
||||
}
|
||||
})
|
||||
|
||||
selectUp() {
|
||||
this.selectElement.selectedIndex--
|
||||
return this.revealOption()
|
||||
}
|
||||
|
||||
selectDown() {
|
||||
this.selectElement.selectedIndex++
|
||||
return this.revealOption()
|
||||
}
|
||||
|
||||
revealOption() {
|
||||
const {
|
||||
selectedOption
|
||||
} = this.selectElement
|
||||
if (selectedOption) {
|
||||
const {scrollTop} = this.selectElement
|
||||
const selectHeight = this.selectElement.clientHeight
|
||||
const scrollBottom = scrollTop + selectHeight
|
||||
|
||||
const optionTop = selectedOption.offsetTop
|
||||
const optionHeight = selectedOption.offsetHeight
|
||||
const optionBottom = optionTop + optionHeight
|
||||
|
||||
if (optionTop < scrollTop) {
|
||||
this.selectElement.scrollTop = optionTop
|
||||
} else if (optionBottom > scrollBottom) {
|
||||
this.selectElement.scrollTop = scrollTop + (optionBottom - scrollBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flashSelection(callback) {
|
||||
const { selectedOption } = this.selectElement
|
||||
if (selectedOption) {
|
||||
this.selectElement.classList.add("flashing")
|
||||
return selectedOption.flash(() => {
|
||||
this.selectElement.classList.remove("flashing")
|
||||
return callback()
|
||||
})
|
||||
} else {
|
||||
return callback()
|
||||
}
|
||||
}
|
||||
|
||||
updateResults(results) {
|
||||
this.selectElement.innerHTML = results.toString().trim()
|
||||
if (this.selectElement.selectedIndex != null) {
|
||||
return requestAnimationFrame(this.revealOption)
|
||||
} else {
|
||||
this.selectElement.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
hasResults() {
|
||||
return this.selectElement.innerHTML.length > 0
|
||||
}
|
||||
|
||||
getSelectedValue() {
|
||||
return this.selectElement.value
|
||||
}
|
||||
|
||||
getOptionHeight() {
|
||||
return this.selectElement.optionElements[0]?.offsetHeight != null ? this.selectElement.optionElements[0]?.offsetHeight : 0
|
||||
}
|
||||
|
||||
didMouseDown(event) {
|
||||
const url = event.target.getAttribute("href")
|
||||
const option = event.target.closest("suggestion-option")
|
||||
|
||||
if (url) {
|
||||
Turbo.visit(url)
|
||||
} else if (option) {
|
||||
option.selectElement.selectedOption = option
|
||||
this.delegate.didClickOption(option)
|
||||
this.#cancelEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
#createSelectElement() {
|
||||
this.selectElement = document.createElement("suggestion-select")
|
||||
this.selectElement.setAttribute("class", "autocomplete__list shadow margin-none unpad")
|
||||
this.selectElement.addEventListener("mousedown", this.didMouseDown, true)
|
||||
this.selectElement.addEventListener("click", this.#cancelEvent)
|
||||
this.selectElement.setAttribute("id", this.id)
|
||||
this.selectElement.setAttribute("data-behavior", "scrollable_menu")
|
||||
this.selectElement.setAttribute("aria-live", "assertive")
|
||||
|
||||
document.body.appendChild(this.selectElement)
|
||||
}
|
||||
|
||||
#removeSelectElement() {
|
||||
this.selectElement.removeEventListener("mousedown", this.didMouseDown, true)
|
||||
this.selectElement.removeEventListener("click", this.#cancelEvent)
|
||||
|
||||
return this.selectElement.remove()
|
||||
}
|
||||
|
||||
#cancelEvent(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function camelize(dashString) {
|
||||
const element = document.createElement("span")
|
||||
element.setAttribute(`data-${dashString}`, "")
|
||||
return Object.keys(element.dataset)[0]
|
||||
}
|
||||
|
||||
export function memoize(object, name, value) {
|
||||
Object.defineProperty(object, name, { value })
|
||||
return value
|
||||
}
|
||||
|
||||
export function normalize(string) {
|
||||
return string.normalize("NFKD").replace(/\p{Diacritic}/gu, "")
|
||||
}
|
||||
|
||||
export function regexpForQuery(query, prefix = "") {
|
||||
return new RegExp(prefix + patternForQuery(query), "i")
|
||||
}
|
||||
|
||||
export function patternForQuery(query) {
|
||||
return normalize(query.toString()).split("").map(regexpEscape).join("(.*\\s)?").replace(/\(\.\*\\s\)\? /g, "[^ ]* ")
|
||||
}
|
||||
|
||||
|
||||
export function uniqueValues(array) {
|
||||
const set = new Set()
|
||||
Array.from(array).forEach(value => set.add(value))
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
export function regexpEscape(string) {
|
||||
return string.toString().replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function getCookie(name) {
|
||||
const cookies = document.cookie ? document.cookie.split("; ") : []
|
||||
const prefix = `${encodeURIComponent(name)}=`
|
||||
const cookie = cookies.find(cookie => cookie.startsWith(prefix))
|
||||
|
||||
if (cookie) {
|
||||
const value = cookie.split("=").slice(1).join("=")
|
||||
return value ? decodeURIComponent(value) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const twentyYears = 20 * 365 * 24 * 60 * 60 * 1000
|
||||
|
||||
export function setCookie(name, value) {
|
||||
const body = [ name, value ].map(encodeURIComponent).join("=")
|
||||
const expires = new Date(Date.now() + twentyYears).toUTCString()
|
||||
const cookie = `${body}; path=/; expires=${expires}`
|
||||
document.cookie = cookie
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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