mirror of
https://github.com/discourse/discourse.git
synced 2026-08-07 03:35:32 -05:00
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:
@@ -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() {
|
||||
|
||||
+153
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user