Files
once-campfire/app/javascript/lib/autocomplete/suggestion_controller.js
Kevin McConnell df76a227dc Hello world
First open source release of Campfire 🎉
2025-08-21 09:31:59 +01:00

350 lines
8.8 KiB
JavaScript

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(" ")
}
}