Hello world

First open source release of Campfire 🎉
This commit is contained in:
Kevin McConnell
2025-08-15 11:02:42 +01:00
commit df76a227dc
664 changed files with 36235 additions and 0 deletions
@@ -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
}
}
+103
View File
@@ -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()
}
}
+33
View File
@@ -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, "\\$&")
}