DEV: floatkit autocomplete for d-editor (#33513)

This PR introduces a modern floatkit-based autocomplete system for the
core composer (both rich text / markdown modes), intended to replace the
legacy jQuery-based implementation. This will be enabled via a site
setting for now. All tests that cover the legacy implementation are
duplicated with the site setting enabled to ensure they keep functional
parity.

### What's changed:

* The autocomplete menu remains open between searches while typing
within a search term, instead of closing and reopening (this looks like
the menu flickering, especially if the searches are quickly resolving).
* Flip behaviour now works (the autocomplete menu should never overlap
with the header, and will appear below the cursor if there's not enough
space to appear fully in the viewport)
* On any mouse-down event outside the menu, the menu will immediately
close (previously, it stayed open during the grippie drag up/down of the
composer drawer, and closes on mouse-up)
* Preserves exact CSS structure and selectors for existing
themes/plugins
* Better use of native browser APIs
*
[scrollIntoView](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView)
API is used for handling scroll within the hashtag autocomplete menu
instead of manual calculation
*
[requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)
API is used to better time the opening of the autocomplete menu with
repaint during pasting of autocompletable terms
* `position: absolute` CSS was removed - this didn't seem to affect the
old autocomplete during testing, and keeping it broke positioning for
the Floatkit-based autocomplete

### What's the same:

* All templates specific to the different types of autocomplete (user &
group / hashtag / emoji) remain exactly the same
* we update the selected class that's used to highlight the item in the
autocomplete menu while navigating it via keyboard the same way we do in
the old autocomplete - it's fairly imperative, but allows us to avoid a
deeper refactor (including an entirely new set of templates)
This commit is contained in:
Kelv
2025-07-30 08:00:39 +08:00
committed by GitHub
parent 9d7f283c94
commit 50f80d9809
21 changed files with 1325 additions and 25 deletions
@@ -0,0 +1,151 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { htmlSafe } from "@ember/template";
// CSS selectors for autocomplete result items
const RESULT_ITEM_SELECTOR = "li a";
const SELECTED_RESULT_SELECTOR = "li a.selected";
const SELECTED_CLASS = "selected";
/**
* Component for rendering autocomplete results in a d-menu
*
* @component DAutocompleteResults
* @param {Array} data.results - Array of autocomplete results
* @param {number} data.selectedIndex - Currently selected index
* @param {Function} data.onSelect - Callback for item selection
* @param {Function} data.template - Template function for rendering
*/
export default class DAutocompleteResults extends Component {
isInitialRender = true;
get results() {
return this.args.data.getResults?.() || [];
}
get selectedIndex() {
return this.args.data.getSelectedIndex?.() || 0;
}
_applySelectedClass(wrapperElement, selectedIndex) {
const links = wrapperElement.querySelectorAll(RESULT_ITEM_SELECTOR);
// Always remove existing selected classes first
const selectedElements = wrapperElement.querySelectorAll(
SELECTED_RESULT_SELECTOR
);
selectedElements.forEach((element) =>
element.classList.remove(SELECTED_CLASS)
);
if (selectedIndex >= 0 && links[selectedIndex]) {
links[selectedIndex].classList.add(SELECTED_CLASS);
}
return links;
}
scrollToSelected(wrapperElement) {
// This is a more imperative approach that's meant to be compatible with the pre-existing autocomplete templates,
// we should refactor in future to use component templates that are more declarative in setting the `selected` class.
if (!wrapperElement) {
return;
}
// Find all links in the autocomplete menu and update selection
const links = this._applySelectedClass(wrapperElement, this.selectedIndex);
if (!links || links.length === 0 || !links[this.selectedIndex]) {
return;
}
links[this.selectedIndex].scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
@action
handleInitialRender() {
this.args.data.onRender?.(this.results);
}
@action
handleClick(event) {
if (!this.args.data.template) {
return;
}
try {
event.preventDefault();
event.stopPropagation();
const clickedLink = event.target.closest(RESULT_ITEM_SELECTOR);
if (!clickedLink) {
return;
}
// Find the index of the clicked link
const links = event.currentTarget.querySelectorAll(RESULT_ITEM_SELECTOR);
const index = Array.from(links).indexOf(clickedLink);
if (index >= 0) {
// Call onSelect and handle any promise returned
const result = this.args.data.onSelect(
this.results[index],
index,
event
);
if (result && typeof result.then === "function") {
result.catch((e) => {
// eslint-disable-next-line no-console
console.error("[autocomplete] onSelect promise rejected: ", e);
});
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] Click handler error: ", e);
}
}
@action
handleUpdate(wrapperElement) {
this.isInitialRender = false;
this.scrollToSelected(wrapperElement);
// Call onRender callback after DOM is ready
this.args.data.onRender?.(this.results);
}
get templateHTML() {
if (!this.args.data.template) {
return "";
}
const template = this.args.data.template({ options: this.results });
if (!this.isInitialRender || this.selectedIndex < 0) {
return htmlSafe(template);
}
const tempDiv = document.createElement("div");
tempDiv.innerHTML = template;
this._applySelectedClass(tempDiv, this.selectedIndex);
return htmlSafe(tempDiv.innerHTML);
}
<template>
<div
{{didInsert this.handleInitialRender}}
{{didUpdate this.handleUpdate this.selectedIndex this.templateHTML}}
{{on "click" this.handleClick}}
tabindex="-1"
>
{{this.templateHTML}}
</div>
</template>
}
@@ -22,7 +22,6 @@ import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
import UpsertHyperlink from "discourse/components/modal/upsert-hyperlink";
import PluginOutlet from "discourse/components/plugin-outlet";
import PopupInputTip from "discourse/components/popup-input-tip";
import { SKIP } from "discourse/lib/autocomplete";
import renderEmojiAutocomplete from "discourse/lib/autocomplete/emoji";
import userAutocomplete from "discourse/lib/autocomplete/user";
import Toolbar from "discourse/lib/composer/toolbar";
@@ -44,6 +43,7 @@ import {
initUserStatusHtml,
renderUserStatusHtml,
} from "discourse/lib/user-status-on-autocomplete";
import { SKIP } from "discourse/modifiers/d-autocomplete";
import { i18n } from "discourse-i18n";
let _createCallbacks = [];
@@ -18,7 +18,6 @@ import concatClass from "discourse/helpers/concat-class";
import lazyHash from "discourse/helpers/lazy-hash";
import loadingSpinner from "discourse/helpers/loading-spinner";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
import discourseDebounce from "discourse/lib/debounce";
import { bind } from "discourse/lib/decorators";
@@ -30,6 +29,7 @@ import {
} from "discourse/lib/search";
import DiscourseURL from "discourse/lib/url";
import userSearch from "discourse/lib/user-search";
import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete";
const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi;
const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi;
@@ -1,13 +1,13 @@
import { cancel } from "@ember/runloop";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import { SEPARATOR } from "discourse/lib/category-hashtags";
import discourseDebounce from "discourse/lib/debounce";
import { isTesting } from "discourse/lib/environment";
import discourseLater from "discourse/lib/later";
import { TAG_HASHTAG_POSTFIX } from "discourse/lib/tag-hashtags";
import Category from "discourse/models/category";
import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete";
let cache = {};
let cacheTime;
@@ -1,13 +1,13 @@
import { cancel } from "@ember/runloop";
import { htmlSafe } from "@ember/template";
import { ajax } from "discourse/lib/ajax";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import discourseDebounce from "discourse/lib/debounce";
import { INPUT_DELAY, isTesting } from "discourse/lib/environment";
import { getHashtagTypeClasses as getHashtagTypeClassesNew } from "discourse/lib/hashtag-type-registry";
import discourseLater from "discourse/lib/later";
import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete";
/**
* Sets up a textarea using the jQuery autocomplete plugin, specifically
@@ -1,5 +1,5 @@
// @ts-check
import { setOwner } from "@ember/owner";
import { getOwner, setOwner } from "@ember/owner";
import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
@@ -18,6 +18,7 @@ import {
inCodeBlock,
setCaretPosition,
} from "discourse/lib/utilities";
import DAutocompleteModifier from "discourse/modifiers/d-autocomplete";
import { i18n } from "discourse-i18n";
/**
@@ -900,12 +901,21 @@ export default class TextareaTextManipulation {
}
autocomplete(options) {
// @ts-ignore
this.$textarea.autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
if (this.siteSettings.floatkit_autocomplete_composer) {
return DAutocompleteModifier.setupAutocomplete(
getOwner(this),
this.textarea,
this.autocompleteHandler,
options
);
} else {
// @ts-ignore
this.$textarea.autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
}
}
}
@@ -1,13 +1,13 @@
import { cancel } from "@ember/runloop";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import { camelCaseToSnakeCase } from "discourse/lib/case-converter";
import discourseDebounce from "discourse/lib/debounce";
import { isTesting } from "discourse/lib/environment";
import discourseLater from "discourse/lib/later";
import { userPath } from "discourse/lib/url";
import { emailValid } from "discourse/lib/utilities";
import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete";
let cache = {},
cacheKey,
@@ -0,0 +1,45 @@
class VirtualElementFromCaretCoords {
constructor(caretCoords, offset = [0, 0]) {
this.caretCoords = caretCoords;
this.offset = offset;
this.updateRect();
}
updateRect() {
const [xOffset, yOffset] = this.offset;
this.rect = {
top: this.caretCoords.y + yOffset,
right: this.caretCoords.x,
bottom: this.caretCoords.y + yOffset,
left: this.caretCoords.x + xOffset,
width: 0,
height: 0,
x: this.caretCoords.x,
y: this.caretCoords.y,
toJSON() {
return this;
},
};
return this.rect;
}
getBoundingClientRect() {
return this.rect;
}
getClientRects() {
return [this.rect];
}
get clientWidth() {
return this.rect.width;
}
get clientHeight() {
return this.rect.height;
}
}
export default function virtualElementFromCaretCoords(caretCoords, offset) {
return new VirtualElementFromCaretCoords(caretCoords, offset);
}
@@ -0,0 +1,622 @@
import { tracked } from "@glimmer/tracking";
import { registerDestructor } from "@ember/destroyable";
import { action } from "@ember/object";
import { cancel } from "@ember/runloop";
import { service } from "@ember/service";
import Modifier from "ember-modifier";
import DAutocompleteResults from "discourse/components/d-autocomplete-results";
import discourseDebounce from "discourse/lib/debounce";
import { INPUT_DELAY } from "discourse/lib/environment";
import { VISIBILITY_OPTIMIZERS } from "float-kit/lib/constants";
export const SKIP = "skip";
export const CANCELLED_STATUS = "__CANCELLED";
/**
* Class-based modifier for adding autocomplete functionality to input elements
* Preserves exact CSS structure for backward compatibility
*
* @class DAutocompleteModifier
* @param {string} key - Trigger character (e.g., "@", "#", ":")
* @param {Function} dataSource - Async function to fetch results: (term) => Promise<Array>
* @param {Function} template - Template function that receives {options: results} and returns HTML
* @param {Function} [transformComplete] - Transform completion before insertion
* @param {Function} [afterComplete] - Callback after completion
* @param {boolean} [debounced=false] - Enable debounced search
* @param {boolean} [preserveKey=true] - Include trigger key in completion
* @param {boolean} [autoSelectFirstSuggestion=true] - Auto-select first result
* @param {Function} [triggerRule] - Function to determine if autocomplete should trigger: (element, opts) => Promise<boolean>
* @param {Function} [onKeyUp] - Function to extract search patterns from text on keyup: (text, caretPosition) => Array<string>
*/
export default class DAutocompleteModifier extends Modifier {
/**
* Static helper function to set up autocomplete on any element
*
* @param {Object} owner - Ember owner
* @param {HTMLElement} element - The element to modify with autocomplete functionality
* @param {Object} autocompleteHandler - Handler for text operations
* @param {Object} options - Autocomplete options
*/
static setupAutocomplete(owner, element, autocompleteHandler, options) {
const modifier = new DAutocompleteModifier(owner, {
named: {},
positional: [],
});
const modifierOptions = {
...options,
textHandler: autocompleteHandler,
};
modifier.modify(element, [modifierOptions]);
return modifier;
}
@service menu;
@tracked expanded = false;
@tracked results = [];
@tracked selectedIndex = -1;
@tracked searchTerm = "";
@tracked completeStart = null;
@tracked completeEnd = null;
// Internal state
previousTerm = null;
debouncedSearch = null;
targetElement = null;
// Constants
ALLOWED_LETTERS_REGEXP = /[\s[{(/+]/;
TRIGGER_CHAR_RELATIVE_OFFSET = 9;
VERTICAL_RELATIVE_OFFSET = 10;
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup());
}
@action
handleKeyUp(event) {
// Skip if modifier keys are pressed or other keys handled in KeyDown
if (
this.hasModifierKey(event) ||
["Enter", "Escape", "Tab"].includes(event.key)
) {
return;
}
if (this.shouldDebounce) {
this.debouncedSearch = discourseDebounce(
this,
this.performAutocomplete,
event,
INPUT_DELAY
);
return;
}
// Handle potential async errors without blocking the UI
this.performAutocomplete(event).catch((e) => {
// eslint-disable-next-line no-console
console.error("[autocomplete] handleKeyup: ", e);
});
}
@action
async handleKeyDown(event) {
// Handle navigation when autocomplete is open
if (this.expanded) {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
await this.moveSelection(-1);
break;
case "ArrowDown":
event.preventDefault();
await this.moveSelection(1);
break;
case "Enter":
case "Tab":
event.preventDefault();
if (this.selectedIndex >= 0) {
await this.selectResult(this.results[this.selectedIndex], event);
}
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
await this.closeAutocomplete();
break;
case "ArrowRight":
// Allow right arrow to close autocomplete if at end of word
if (this.targetElement.value[this.getCaretPosition()] === " ") {
await this.closeAutocomplete();
}
break;
case "Backspace":
// Handle backspace to potentially reopen autocomplete
await this.handleBackspace(event);
break;
}
} else {
// Handle backspace when closed to potentially reopen,
// skip if modifier keys are pressed - this prevents autocomplete from opening on full deletion
if (event.key === "Backspace" && !this.hasModifierKey(event)) {
await this.handleBackspace(event);
}
}
}
@action
async handlePaste(event) {
// Trigger autocomplete check after paste with proper async handling
try {
// Use requestAnimationFrame for better performance than setTimeout - less flickering
await new Promise((resolve) => requestAnimationFrame(resolve));
await this.performAutocomplete(event);
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] handlePaste: ", e);
}
}
@action
async handleGlobalClick() {
try {
if (this.expanded) {
await this.closeAutocomplete();
}
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] handleGlobalClick: ", e);
}
}
hasModifierKey(event) {
return event.ctrlKey || event.altKey || event.metaKey;
}
async shouldTrigger(opts = {}) {
if (!this.options.triggerRule) {
return true;
}
try {
const triggerContext = {
...opts,
inCodeBlock: () => this.options.textHandler.inCodeBlock(),
};
const triggerRuleResult = await this.options.triggerRule(
this.targetElement,
triggerContext
);
return triggerRuleResult ?? true;
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] triggerRule error: ", e);
return true; // Default to allowing autocomplete on error
}
}
modify(element, [options]) {
this.targetElement = element;
this.options = options || {};
// Set up event listeners
element.addEventListener("keyup", this.handleKeyUp);
element.addEventListener("keydown", this.handleKeyDown);
element.addEventListener("paste", this.handlePaste);
// Global click handler to close autocomplete
document.addEventListener("click", this.handleGlobalClick);
}
@action
cleanup() {
cancel(this.debouncedSearch);
if (this.targetElement) {
this.targetElement.removeEventListener("keyup", this.handleKeyUp);
this.targetElement.removeEventListener("keydown", this.handleKeyDown);
this.targetElement.removeEventListener("paste", this.handlePaste);
}
document.removeEventListener("click", this.handleGlobalClick);
this.menu.close("d-autocomplete");
}
get shouldDebounce() {
return this.options.debounced ?? false;
}
// [introduced in https://github.com/discourse/discourse/commit/e02cc98092f5a889d0313cd741b29926be7430ab]
// By default, when the autocomplete popup is rendered it has the
// first suggestion 'selected', and pressing enter key inserts
// the first suggestion into the input box.
// If you want to stop that behavior, i.e. have the popup renders
// with no suggestions selected, set the `autoSelectFirstSuggestion`
// option to false.
// With this option set to false, users will have to select
// a suggestion via the up/down arrow keys and then press enter
// to insert it.
get autoSelectFirstSuggestion() {
return this.options.autoSelectFirstSuggestion ?? true;
}
async performAutocomplete() {
const caretPosition = this.getCaretPosition();
const value = this.getValue();
const key = value[caretPosition - 1];
// onKeyUp for additional custom trigger logic
if (this.options.key && this.options.onKeyUp && key !== this.options.key) {
const match = this.options.onKeyUp(value, caretPosition);
if (match && (await this.shouldTrigger())) {
this.completeStart = caretPosition - match[0].length;
this.completeEnd = caretPosition - 1;
const term = match[0].substring(1, match[0].length);
await this.performSearch(term);
return;
}
}
// Check if we should trigger autocomplete
if (this.completeStart === null && caretPosition > 0) {
// Try backwards scanning to find existing autocomplete context
const position = await this.guessCompletePosition();
if (position.completeStart !== null) {
this.completeStart = position.completeStart;
this.completeEnd = caretPosition - 1;
await this.performSearch(position.term || "");
} else if (key === this.options.key) {
// Fallback to original trigger logic for new autocomplete sessions
const prevChar = value.charAt(caretPosition - 2);
if (
(!prevChar || this.ALLOWED_LETTERS_REGEXP.test(prevChar)) &&
(await this.shouldTrigger())
) {
this.completeStart = caretPosition - 1;
this.completeEnd = caretPosition - 1;
await this.performSearch("");
}
}
} else if (this.completeStart !== null) {
// Extract search term
const term = value.substring(
this.completeStart + (this.options.key ? 1 : 0),
caretPosition
);
// Validate we're still in autocomplete context
if (!this.options.key || value[this.completeStart] === this.options.key) {
this.completeEnd = caretPosition - 1;
await this.performSearch(term);
} else {
await this.closeAutocomplete();
}
}
}
async handleBackspace() {
try {
if (this.completeStart === null && this.options.key) {
const position = await this.guessCompletePosition({ backSpace: true });
if (position.completeStart !== null) {
this.completeStart = position.completeStart;
this.completeEnd = this.getCaretPosition() - 1;
await this.performAutocomplete();
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] handleBackspace: ", e);
}
}
async performSearch(term) {
if (this.isDestroying || this.isDestroyed) {
return;
}
// Skip if same term (basic caching)
if (this.previousTerm === term && !this.options.forceRefresh) {
return;
}
this.previousTerm = term;
this.searchTerm = term;
// Close if only whitespace or invalid context
if (
(term.length !== 0 && term.trim().length === 0) ||
this.getValue()[this.getCaretPosition()]?.trim()
) {
await this.closeAutocomplete();
return;
}
const results = this.options.dataSource(term);
if (results && results.then && typeof results.then === "function") {
try {
const resolvedResults = await results;
this.updateResults(resolvedResults || []);
} catch (e) {
if (e.name !== "AbortError") {
// eslint-disable-next-line no-console
console.error("[autocomplete] performSearch: ", e);
}
await this.closeAutocomplete();
}
return;
}
// For handling non-async dataSources
this.updateResults(results || []);
}
updateResults(results) {
if (
this.completeStart === null ||
results === SKIP ||
results === CANCELLED_STATUS
) {
return;
}
const oldResults = this.results;
this.results = results;
if (!this.results || this.results.length === 0) {
this.closeAutocomplete();
return;
}
// If menu is already open, reactive getters update based on results
if (this.expanded) {
// This ensures we only reset the selected style if results changed between typing of search term
if (JSON.stringify(oldResults) !== JSON.stringify(results)) {
this.selectedIndex = this.autoSelectFirstSuggestion ? 0 : -1;
}
return;
}
this.openAutocomplete();
}
async openAutocomplete() {
this.selectedIndex = this.autoSelectFirstSuggestion ? 0 : -1;
try {
// Create virtual element positioned at the caret location
const virtualElement = this.createVirtualElementAtCaret();
const menuOptions = {
identifier: "d-autocomplete",
component: DAutocompleteResults,
visibilityOptimizer: VISIBILITY_OPTIMIZERS.AUTO_PLACEMENT,
placement: "top-start",
allowedPlacements: [
"top-start",
"top-end",
"bottom-start",
"bottom-end",
],
data: {
getResults: () => this.results,
getSelectedIndex: () => this.selectedIndex,
onSelect: (result, index, event) => this.selectResult(result, event),
template: this.options.template,
onRender: this.options.onRender,
},
modalForMobile: false,
onClose: () => {
this.expanded = false;
this.options.onClose?.();
},
};
await this.menu.show(virtualElement, menuOptions);
this.expanded = true;
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] renderAutocomplete: ", e);
}
}
@action
async closeAutocomplete() {
await this.menu.close("d-autocomplete");
this.expanded = false;
this.completeStart = null;
this.completeEnd = null;
this.searchTerm = "";
this.results = [];
this.selectedIndex = -1;
this.previousTerm = null;
cancel(this.debouncedSearch);
// Note: onClose callback is handled by the menu's onClose option
}
@action
async moveSelection(direction) {
try {
if (this.results.length === 0) {
return;
}
// Calculate new selectedIndex
const newSelectedIndex = Math.max(
0,
Math.min(this.results.length - 1, this.selectedIndex + direction)
);
// Only update if the index actually changed
if (newSelectedIndex !== this.selectedIndex) {
this.selectedIndex = newSelectedIndex;
}
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] moveSelection: ", e);
}
}
@action
async selectResult(result, event) {
try {
await this.completeTextareaTerm(result, event);
await this.closeAutocomplete();
// Clear any cached search state to prevent showing stale results
this.previousTerm = null;
this.searchTerm = "";
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] selectResult error: ", e);
}
}
@action
async completeTextareaTerm(term, event) {
if (!term) {
return;
}
// Transform if needed
if (this.options.transformComplete) {
term = await this.options.transformComplete(term, event);
}
if (!term) {
return;
}
const preserveKey = this.options.preserveKey ?? true;
const replacement = (preserveKey ? this.options.key || "" : "") + term;
// [introduced in https://github.com/discourse/discourse/commit/5fb6dd9bfaf6191393b7809fa0ac11b952a70a23]
// After completion is done our position for completeStart may have
// drifted. This can happen if the TEXTAREA changed out-of-band between
// the time autocomplete was first displayed and the time of completion
// Specifically this may happen due to uploads which inject a placeholder
// which is later replaced with a different length string.
const pos = await this.guessCompletePosition({ completeTerm: true });
let completeEnd;
let completeStart;
if (pos.completeStart !== undefined && pos.completeEnd !== undefined) {
completeStart = pos.completeStart;
completeEnd = pos.completeEnd;
} else {
completeStart = completeEnd = this.getCaretPosition();
}
// Use textHandler's replaceTerm method for consistent behavior
this.options.textHandler.replaceTerm(
completeStart,
completeEnd,
replacement
);
this.options.afterComplete?.(this.getValue(), event);
}
async guessCompletePosition(opts = {}) {
let prev, stopFound, term;
let prevIsGood = true;
let backSpace = opts?.backSpace;
let completeTermOption = opts?.completeTerm;
let caretPos = this.getCaretPosition();
if (backSpace) {
caretPos -= 1;
}
let start = null;
let end = null;
const initialCaretPos = caretPos;
while (prevIsGood && caretPos >= 0) {
caretPos -= 1;
prev = this.getValue()[caretPos];
stopFound = prev === this.options.key;
if (stopFound) {
prev = this.getValue()[caretPos - 1];
const shouldTrigger = await this.shouldTrigger({ backSpace });
if (
shouldTrigger &&
(prev === undefined || this.ALLOWED_LETTERS_REGEXP.test(prev))
) {
start = caretPos;
term = this.getValue().substring(caretPos + 1, initialCaretPos);
end = caretPos + term.length;
break;
}
}
prevIsGood = !/\s/.test(prev);
if (completeTermOption) {
prevIsGood ||= prev === " ";
}
}
return { completeStart: start, completeEnd: end, term };
}
getValue() {
return this.options.textHandler.getValue();
}
getCaretPosition() {
return this.options.textHandler.getCaretPosition();
}
getAbsoluteCaretCoords() {
// Use textHandler for accurate relative coordinate calculation
if (this.options.textHandler && this.options.textHandler.getCaretCoords) {
try {
// Use completeStart position (where @ is) like legacy autocomplete does
const position =
this.completeStart !== null
? this.completeStart
: this.getCaretPosition();
const relativeCoords =
this.options.textHandler.getCaretCoords(position);
// Convert to absolute viewport coordinates
const textareaRect = this.targetElement.getBoundingClientRect();
return {
x: textareaRect.left + relativeCoords.left,
y: textareaRect.top + relativeCoords.top,
};
} catch (e) {
// eslint-disable-next-line no-console
console.error("[autocomplete] getAbsoluteCaretCoords: ", e);
}
}
// Fallback: return textarea position (will be inaccurate but won't crash)
const textareaRect = this.targetElement.getBoundingClientRect();
return {
x: textareaRect.left,
y: textareaRect.top,
};
}
createVirtualElementAtCaret() {
const caretCoords = this.getAbsoluteCaretCoords();
return {
getBoundingClientRect: () => ({
left: caretCoords.x + this.TRIGGER_CHAR_RELATIVE_OFFSET,
top: caretCoords.y + this.VERTICAL_RELATIVE_OFFSET,
width: 1,
height: 10,
}),
};
}
}
@@ -1,6 +1,7 @@
// @ts-check
import { setOwner } from "@ember/owner";
import { getOwner, setOwner } from "@ember/owner";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import $ from "jquery";
import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
@@ -9,6 +10,7 @@ import { liftListItem, sinkListItem } from "prosemirror-schema-list";
import { TextSelection } from "prosemirror-state";
import { bind } from "discourse/lib/decorators";
import escapeRegExp from "discourse/lib/escape-regexp";
import DAutocompleteModifier from "discourse/modifiers/d-autocomplete";
import { i18n } from "discourse-i18n";
import { hasMark, inNode, isNodeActive } from "./plugin-utils";
@@ -21,6 +23,8 @@ import { hasMark, inNode, isNodeActive } from "./plugin-utils";
/** @implements {TextManipulation} */
export default class ProsemirrorTextManipulation {
@service siteSettings;
allowPreview = false;
/** @type {import("prosemirror-model").Schema} */
@@ -82,12 +86,21 @@ export default class ProsemirrorTextManipulation {
}
autocomplete(options) {
// @ts-ignore
$(this.view.dom).autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
if (this.siteSettings.floatkit_autocomplete_composer) {
return DAutocompleteModifier.setupAutocomplete(
getOwner(this),
this.view.dom,
this.autocompleteHandler,
options
);
} else {
// @ts-ignore
$(this.view.dom).autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
}
}
applySurroundSelection(head, tail, exampleKey) {
@@ -506,6 +519,7 @@ class ProsemirrorPlaceholderHandler {
}
progress() {}
progressComplete() {}
cancelAll() {
@@ -21,7 +21,159 @@ acceptance("Composer - editor mentions", function (needs) {
};
needs.user();
needs.settings({ enable_mentions: true, allow_uncategorized_topics: true });
needs.settings({
enable_mentions: true,
allow_uncategorized_topics: true,
});
needs.hooks.afterEach(() => clock?.restore());
needs.pretender((server, helper) => {
server.get("/t/11557.json", () => {
const topicFixture = cloneJSON(topicFixtures["/t/130.json"]);
topicFixture.id = 11557;
return helper.response(topicFixture);
});
server.get("/u/search/users", () => {
return helper.response({
users: [
{
username: "user",
name: "Some User",
avatar_template:
"https://avatars.discourse.org/v3/letter/t/41988e/{size}.png",
status,
},
{
username: "user2",
name: "Some User",
avatar_template:
"https://avatars.discourse.org/v3/letter/t/41988e/{size}.png",
},
{
username: "foo",
avatar_template:
"https://avatars.discourse.org/v3/letter/t/41988e/{size}.png",
},
],
groups: [
{
name: "user_group",
full_name: "Group",
},
],
});
});
});
test("selecting user mentions", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/");
await click("#create-topic");
await simulateKeys(".d-editor-input", "abc @u\r");
assert
.dom(".d-editor-input")
.hasValue("abc @user ", "replaces mention correctly");
});
test("selecting user mentions after deleting characters", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/");
await click("#create-topic");
await simulateKeys(".d-editor-input", "abc @user a\b\b\r");
assert
.dom(".d-editor-input")
.hasValue("abc @user ", "replaces mention correctly");
});
test("selecting user mentions after deleting characters mid sentence", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/");
await click("#create-topic");
await simulateKeys(".d-editor-input", "abc @user 123");
await setCaretPosition(".d-editor-input", 9);
await simulateKeys(".d-editor-input", "\b\b\r");
assert
.dom(".d-editor-input")
.hasValue("abc @user 123", "replaces mention correctly");
});
test("shows status on search results when mentioning a user", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
const timezone = loggedInUser().user_option.timezone;
const now = moment(status.ends_at).add(-1, "hour").format();
clock = fakeTime(now, timezone, true);
await visit("/");
await click("#create-topic");
await simulateKeys(".d-editor-input", "@u");
assert
.dom(`.autocomplete .emoji[alt='${status.emoji}']`)
.exists("status emoji is shown");
assert
.dom(".autocomplete .user-status-message-description")
.hasText(status.description, "status description is shown");
});
test("metadata matches are moved to the end", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/");
await click("#create-topic");
await simulateKeys(".d-editor-input", "abc @u");
assert.deepEqual(
[...queryAll(".ac-user .username")].map((e) => e.innerText),
["user", "user2", "user_group", "foo"]
);
await simulateKeys(".d-editor-input", "\bf");
assert.deepEqual(
[...queryAll(".ac-user .username")].map((e) => e.innerText),
["foo", "user", "user2"]
);
});
test("shows users immediately when @ is typed in a reply", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/");
await click(".topic-list-item .title");
await click(".btn-primary.create");
await simulateKeys(".d-editor-input", "abc @");
assert.deepEqual(
[...document.querySelectorAll(".ac-user .username")].map(
(e) => e.innerText
),
["user_group", "user", "user2", "foo"]
);
});
});
acceptance("Composer - editor mentions with floatkit", function (needs) {
let clock = null;
const status = {
emoji: "tooth",
description: "off to dentist",
ends_at: "2100-02-01T09:00:00.000Z",
};
needs.user();
needs.settings({
enable_mentions: true,
allow_uncategorized_topics: true,
});
needs.hooks.afterEach(() => clock?.restore());
needs.pretender((server, helper) => {
@@ -20,6 +20,78 @@ acceptance("Emoji", function (needs) {
});
});
test("emoji is cooked properly", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create");
await simulateKeys(".d-editor-input", "a :blonde_woman\t");
assert
.dom(".d-editor-preview")
.hasHtml(
`<p>a <img src="/images/emoji/twitter/blonde_woman.png?v=${v}" title=":blonde_woman:" class="emoji" alt=":blonde_woman:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
);
});
test("emoji can be picked from the emoji-picker using the mouse", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create");
await simulateKeys(".d-editor-input", "a :man_b");
// the 6th item in the list is the "more..."
await click(".autocomplete.ac-emoji ul li:nth-of-type(6) a");
await emojiPicker().select("man_biking");
assert
.dom(".d-editor-preview")
.hasHtml(
`<p>a <img src="/images/emoji/twitter/man_biking.png?v=${v}" title=":man_biking:" class="emoji" alt=":man_biking:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
);
});
test("skin toned emoji is cooked properly", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create");
await fillIn(".d-editor-input", "a :blonde_woman:t5:");
assert
.dom(".d-editor-preview")
.hasHtml(
`<p>a <img src="/images/emoji/twitter/blonde_woman/5.png?v=${v}" title=":blonde_woman:t5:" class="emoji" alt=":blonde_woman:t5:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
);
});
needs.settings({ emoji_autocomplete_min_chars: 2 });
test("siteSetting:emoji_autocomplete_min_chars", async function (assert) {
this.siteSettings.floatkit_autocomplete_composer = false;
await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create");
await simulateKeys(".d-editor-input", ":s");
assert.dom(".autocomplete.ac-emoji").doesNotExist();
await simulateKey(".d-editor-input", "w");
assert.dom(".autocomplete.ac-emoji").exists();
});
});
acceptance("Emoji with floatkit", function (needs) {
needs.user();
needs.pretender((server, helper) => {
server.get("/emojis/search-aliases.json", () => {
return helper.response([]);
});
server.get("/drafts/topic_280.json", function () {
return helper.response(200, { draft: null });
});
});
test("emoji is cooked properly", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create");
@@ -39,8 +111,8 @@ acceptance("Emoji", function (needs) {
await simulateKeys(".d-editor-input", "a :man_b");
// the 5th item in the list is the "more..."
await click(".autocomplete.ac-emoji ul li:nth-of-type(6)");
// the 6th item in the list is the "more..."
await click(".autocomplete.ac-emoji ul li:nth-of-type(6) a");
await emojiPicker().select("man_biking");
assert
@@ -1,7 +1,7 @@
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import userSearch from "discourse/lib/user-search";
import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
module("Unit | Utility | user-search", function (hooks) {
@@ -17,6 +17,7 @@
@import "calendar-date-time-input";
@import "composer-toggle-switch";
@import "convert-to-public-topic-modal";
@import "d-autocomplete";
@import "d-toggle-switch";
@import "date-input";
@import "date-picker";
@@ -2,7 +2,6 @@
.autocomplete {
z-index: z("composer", "dropdown") + 1;
position: absolute;
max-width: 370px;
min-width: 300px;
background-color: var(--secondary);
@@ -0,0 +1,30 @@
@use "lib/viewport";
.fk-d-menu[data-identifier="d-autocomplete"] {
z-index: z("modal", "dialog") + 1;
animation: fade-in ease 0.25s 1 forwards !important;
--d-border-radius: var(--space-2);
// Override compose.scss autocomplete styles to remove double styling
// Let d-menu handle the container border/shadow/radius
.autocomplete {
border: none;
box-shadow: none;
border-radius: 0;
ul {
li {
&:first-of-type a {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
&:last-of-type a {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
}
}
}
+1
View File
@@ -2725,6 +2725,7 @@ en:
glimmer_post_stream_mode: "Control whether the new 'glimmer' post stream implementation is used. 'auto' will enable automatically once all your themes and plugins are ready. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
glimmer_post_stream_mode_auto_groups: "Enable the new 'glimmer' post stream implementation in 'auto' mode for the specified user groups. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
deactivate_widgets_rendering: "Disable the legacy widgets rendering engine. This will disable all widgets that have not been updated to Glimmer components, it will also force the Glimmer Post Stream to be enabled unconditionally regardless of the value of the `glimmer_post_stream_mode` setting. This setting is not recommended for production sites, as it may break existing themes and plugins."
floatkit_autocomplete_composer: "Enable the Floatkit-based autocomplete menu in composer. This is an UI enhancement that will replace the current JQuery-based autocomplete menu."
experimental_form_templates: "Enable the form templates feature. Manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
show_preview_for_form_templates: "Enable the preview for form templates feature"
lazy_load_categories_groups: "Lazy load category information only for users of these groups. This improves performance on sites with many categories."
+4
View File
@@ -569,6 +569,10 @@ basic:
enum: "InterfaceColorSelectorSetting"
default: "disabled"
area: "interface"
floatkit_autocomplete_composer:
client: true
default: true
area: "interface"
login:
invite_only:
@@ -25,7 +25,6 @@ import UpsertHyperlink from "discourse/components/modal/upsert-hyperlink";
import PluginOutlet from "discourse/components/plugin-outlet";
import concatClass from "discourse/helpers/concat-class";
import lazyHash from "discourse/helpers/lazy-hash";
import { SKIP } from "discourse/lib/autocomplete";
import renderEmojiAutocomplete from "discourse/lib/autocomplete/emoji";
import userAutocomplete from "discourse/lib/autocomplete/user";
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
@@ -41,6 +40,7 @@ import {
} from "discourse/lib/user-status-on-autocomplete";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
import { waitForClosedKeyboard } from "discourse/lib/wait-for-keyboard";
import { SKIP } from "discourse/modifiers/d-autocomplete";
import { i18n } from "discourse-i18n";
import Button from "discourse/plugins/chat/discourse/components/chat/composer/button";
import ChatComposerDropdown from "discourse/plugins/chat/discourse/components/chat-composer-dropdown";
+73
View File
@@ -5,6 +5,7 @@ describe "Composer", type: :system do
let(:composer) { PageObjects::Components::Composer.new }
before { sign_in(user) }
before { SiteSetting.floatkit_autocomplete_composer = false }
it "displays user cards in preview" do
page.visit "/new-topic"
@@ -73,4 +74,76 @@ describe "Composer", type: :system do
)
end
end
context "with floatkit autocomplete enabled" do
before { SiteSetting.floatkit_autocomplete_composer = true }
it "displays user cards in preview" do
page.visit "/new-topic"
expect(composer).to be_opened
composer.fill_content("@#{user.username}")
composer.preview.find("a.mention").click
page.has_css?("#user-card")
end
context "in a topic, the autocomplete prioritizes" do
fab!(:topic_user, :user)
fab!(:second_reply_user, :user)
fab!(:topic) { Fabricate(:topic, user: topic_user) }
fab!(:op) { Fabricate(:post, topic: topic, user: topic_user) }
let!(:op_post) { PageObjects::Components::Post.new(op.post_number) }
fab!(:second_reply) { Fabricate(:post, topic: topic, user: second_reply_user) }
let!(:second_reply_post) { PageObjects::Components::Post.new(second_reply.post_number) }
before { SiteSetting.enable_names = false }
it "the topic owner if replying to topic" do
page.visit "/t/#{topic.id}"
op_post.reply
expect(composer).to be_opened
composer.type_content("@")
expect(composer.mention_menu_autocomplete_username_list).to eq(
[op.username, second_reply_user.username], # must be first the topic owner
)
end
it "the recipient of the reply when replying" do
page.visit "/t/#{topic.id}"
second_reply_post.reply
expect(composer).to be_opened
composer.type_content("@")
expect(composer.mention_menu_autocomplete_username_list).to eq(
[second_reply_user.username, topic_user.username], # must be first the reply user
)
end
it "the recipient of the reply when editing a reply" do
admin = Fabricate(:admin, refresh_auto_groups: true)
reply_to_second_post =
Fabricate(:post, topic: topic, user: user, reply_to_post_number: second_reply.post_number)
reply_post = PageObjects::Components::Post.new(reply_to_second_post.post_number)
sign_in(admin)
page.visit "/t/#{topic.id}"
reply_post.edit
expect(composer).to be_opened
composer.type_content(" @")
expect(composer.mention_menu_autocomplete_username_list).to eq(
[second_reply_user.username, user.username, topic_user.username],
)
end
end
end
end
+126
View File
@@ -17,6 +17,7 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
let(:topic_page) { PageObjects::Pages::Topic.new }
before { sign_in(current_user) }
before { SiteSetting.floatkit_autocomplete_composer = false }
def visit_topic_and_initiate_autocomplete(initiation_text: "something #co", expected_count: 2)
topic_page.visit_topic_and_open_composer(topic)
@@ -261,4 +262,129 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
expect(generated_css).not_to include(".hashtag-color--category--#{private_category.id}")
end
end
context "with floatkit autocomplete enabled" do
before { SiteSetting.floatkit_autocomplete_composer = true }
it "searches for categories and tags with # and prioritises categories in the results" do
visit_topic_and_initiate_autocomplete
hashtag_results = page.all(".hashtag-autocomplete__link", count: 2)
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(
["Cool Category", "cooltag (x325)"],
)
end
it "begins showing results as soon as # is pressed based on categories and tags topic_count" do
visit_topic_and_initiate_autocomplete(initiation_text: "#", expected_count: 5)
hashtag_results = page.all(".hashtag-autocomplete__link")
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(
[
"Cool Category",
"Other Category",
uncategorized_category.name,
"cooltag (x325)",
"othertag (x66)",
],
)
end
it "cooks the selected hashtag clientside in the composer preview with the correct url and icon" do
visit_topic_and_initiate_autocomplete
hashtag_results = page.all(".hashtag-autocomplete__link", count: 2)
hashtag_results[0].click
expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked")
expect(cooked_hashtag["outerHTML"]).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: category.url,
"data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
},
) do
with_tag(
"span",
with: {
class: "hashtag-category-square hashtag-color--category-#{category.id}",
},
)
end
visit_topic_and_initiate_autocomplete
hashtag_results = page.all(".hashtag-autocomplete__link", count: 2)
hashtag_results[1].click
expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked")
expect(cooked_hashtag["outerHTML"]).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": tag.name,
"data-id": tag.id,
},
) do
with_tag(
"svg",
with: {
class: "fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string",
},
) { with_tag("use", with: { href: "#tag" }) }
end
end
it "cooks the hashtags for tag and category correctly serverside when the post is saved to the database" do
topic_page.visit_topic_and_open_composer(topic)
expect(topic_page).to have_expanded_composer
topic_page.send_reply("this is a #cool-cat category and a #cooltag tag")
expect(topic_page).to have_post_number(2)
cooked_hashtags = page.all(".hashtag-cooked", count: 2)
expect(cooked_hashtags[0]["outerHTML"]).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: category.url,
"data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
"aria-label": category.name,
},
) do
with_tag(
"span",
with: {
class: "hashtag-category-square hashtag-color--category-#{category.id}",
},
)
end
expect(cooked_hashtags[1]["outerHTML"]).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": tag.name,
"data-id": tag.id,
"aria-label": tag.name,
},
) do
with_tag(
"svg",
with: {
class: "fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string",
},
) { with_tag("use", with: { href: "#tag" }) }
end
end
end
end