DEV: refactor textarea from autocomplete (#29988)

Extracts the dependency we had on specifics of a textarea in our Autocomplete, this approach uses a TextareaTextManipulation, particularly the value getter, getCaretPosition, getCaretCoords, replaceText, and inCodeBlock.
This commit is contained in:
Renato Atilio 2024-12-05 16:09:06 -03:00 committed by GitHub
parent e37952c9db
commit 4a5a499d94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 127 additions and 74 deletions

View File

@ -306,8 +306,7 @@ export default class DEditor extends Component {
this.site.hashtag_configurations["topic-composer"], this.site.hashtag_configurations["topic-composer"],
this.siteSettings, this.siteSettings,
{ {
afterComplete: (value) => { afterComplete: () => {
this.set("value", value);
schedule( schedule(
"afterRender", "afterRender",
this.textManipulation, this.textManipulation,
@ -327,8 +326,7 @@ export default class DEditor extends Component {
this.textManipulation.autocomplete({ this.textManipulation.autocomplete({
template: findRawTemplate("emoji-selector-autocomplete"), template: findRawTemplate("emoji-selector-autocomplete"),
key: ":", key: ":",
afterComplete: (text) => { afterComplete: () => {
this.set("value", text);
schedule( schedule(
"afterRender", "afterRender",
this.textManipulation, this.textManipulation,
@ -466,9 +464,7 @@ export default class DEditor extends Component {
onRender: (options) => renderUserStatusHtml(options), onRender: (options) => renderUserStatusHtml(options),
key: "@", key: "@",
transformComplete: (v) => v.username || v.name, transformComplete: (v) => v.username || v.name,
afterComplete: (value) => { afterComplete: () => {
this.set("value", value);
schedule( schedule(
"afterRender", "afterRender",
this.textManipulation, this.textManipulation,

View File

@ -2,7 +2,7 @@ import { cancel } from "@ember/runloop";
import { createPopper } from "@popperjs/core"; import { createPopper } from "@popperjs/core";
import $ from "jquery"; import $ from "jquery";
import { isDocumentRTL } from "discourse/lib/text-direction"; import { isDocumentRTL } from "discourse/lib/text-direction";
import { caretPosition, setCaretPosition } from "discourse/lib/utilities"; import { TextareaAutocompleteHandler } from "discourse/lib/textarea-text-manipulation";
import Site from "discourse/models/site"; import Site from "discourse/models/site";
import { INPUT_DELAY } from "discourse-common/config/environment"; import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
@ -114,6 +114,8 @@ export default function (options) {
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea; const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
let inputSelectedItems = []; let inputSelectedItems = [];
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
function handlePaste() { function handlePaste() {
discourseLater(() => me.trigger("keydown"), 50); discourseLater(() => me.trigger("keydown"), 50);
} }
@ -216,8 +218,6 @@ export default function (options) {
} }
let completeTerm = async function (term, event) { let completeTerm = async function (term, event) {
let completeEnd = null;
if (term) { if (term) {
if (isInput) { if (isInput) {
me.val(""); me.val("");
@ -231,15 +231,13 @@ export default function (options) {
} }
if (term) { if (term) {
let text = me.val();
// After completion is done our position for completeStart may have // After completion is done our position for completeStart may have
// drifted. This can happen if the TEXTAREA changed out-of-band between // drifted. This can happen if the TEXTAREA changed out-of-band between
// the time autocomplete was first displayed and the time of completion // the time autocomplete was first displayed and the time of completion
// Specifically this may happen due to uploads which inject a placeholder // Specifically this may happen due to uploads which inject a placeholder
// which is later replaced with a different length string. // which is later replaced with a different length string.
let pos = await guessCompletePosition({ completeTerm: true }); let pos = await guessCompletePosition({ completeTerm: true });
let completeEnd = null;
if ( if (
pos.completeStart !== undefined && pos.completeStart !== undefined &&
pos.completeEnd !== undefined pos.completeEnd !== undefined
@ -247,31 +245,18 @@ export default function (options) {
completeStart = pos.completeStart; completeStart = pos.completeStart;
completeEnd = pos.completeEnd; completeEnd = pos.completeEnd;
} else { } else {
completeStart = completeEnd = caretPosition(me[0]); completeStart = completeEnd =
options.textHandler.getCaretPosition();
} }
let space = options.textHandler.replaceTerm({
text.substring(completeEnd + 1, completeEnd + 2) === " " ? "" : " "; start: completeStart,
end: completeEnd,
text = term: (options.preserveKey ? options.key || "" : "") + term,
text.substring(0, completeStart) + });
(options.preserveKey ? options.key || "" : "") +
term +
space +
text.substring(completeEnd + 1, text.length);
me.val(text);
let newCaretPos = completeStart + 1 + term.length;
if (options.key) {
newCaretPos++;
}
setCaretPosition(me[0], newCaretPos);
if (options && options.afterComplete) { if (options && options.afterComplete) {
options.afterComplete(text, event); options.afterComplete(options.textHandler.value, event);
} }
} }
} }
@ -429,9 +414,7 @@ export default function (options) {
} }
let vOffset = 0; let vOffset = 0;
let pos = me.caretPosition({ let pos = options.textHandler.getCaretCoords(completeStart);
pos: completeStart + 1,
});
if (options.treatAsTextarea) { if (options.treatAsTextarea) {
vOffset = -32; vOffset = -32;
@ -539,7 +522,11 @@ export default function (options) {
closeAutocomplete(); closeAutocomplete();
}); });
async function checkTriggerRule(opts) { async function checkTriggerRule(_opts) {
const opts = {
..._opts,
inCodeBlock: () => options.textHandler.inCodeBlock(),
};
const shouldTrigger = await options.triggerRule?.(me[0], opts); const shouldTrigger = await options.triggerRule?.(me[0], opts);
return shouldTrigger ?? true; return shouldTrigger ?? true;
} }
@ -557,12 +544,12 @@ export default function (options) {
return true; return true;
} }
let cp = caretPosition(me[0]); let cp = options.textHandler.getCaretPosition();
const key = me[0].value[cp - 1]; const key = options.textHandler.value[cp - 1];
if (options.key) { if (options.key) {
if (options.onKeyUp && key !== options.key) { if (options.onKeyUp && key !== options.key) {
let match = options.onKeyUp(me.val(), cp); let match = options.onKeyUp(options.textHandler.value, cp);
if (match) { if (match) {
completeStart = cp - match[0].length; completeStart = cp - match[0].length;
@ -574,10 +561,9 @@ export default function (options) {
if (completeStart === null && cp > 0) { if (completeStart === null && cp > 0) {
if (key === options.key) { if (key === options.key) {
let prevChar = me.val().charAt(cp - 2); let prevChar = options.textHandler.value.charAt(cp - 2);
const shouldTrigger = await checkTriggerRule();
if ( if (
shouldTrigger && (await checkTriggerRule()) &&
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar)) (!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar))
) { ) {
completeStart = cp - 1; completeStart = cp - 1;
@ -585,7 +571,10 @@ export default function (options) {
} }
} }
} else if (completeStart !== null) { } else if (completeStart !== null) {
let term = me.val().substring(completeStart + (options.key ? 1 : 0), cp); let term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
updateAutoComplete(dataSource(term, options)); updateAutoComplete(dataSource(term, options));
} }
} }
@ -593,10 +582,9 @@ export default function (options) {
async function guessCompletePosition(opts) { async function guessCompletePosition(opts) {
let prev, stopFound, term; let prev, stopFound, term;
let prevIsGood = true; let prevIsGood = true;
let element = me[0];
let backSpace = opts?.backSpace; let backSpace = opts?.backSpace;
let completeTermOption = opts?.completeTerm; let completeTermOption = opts?.completeTerm;
let caretPos = caretPosition(element); let caretPos = options.textHandler.getCaretPosition();
if (backSpace) { if (backSpace) {
caretPos -= 1; caretPos -= 1;
@ -609,12 +597,12 @@ export default function (options) {
while (prevIsGood && caretPos >= 0) { while (prevIsGood && caretPos >= 0) {
caretPos -= 1; caretPos -= 1;
prev = element.value[caretPos]; prev = options.textHandler.value[caretPos];
stopFound = prev === options.key; stopFound = prev === options.key;
if (stopFound) { if (stopFound) {
prev = element.value[caretPos - 1]; prev = options.textHandler.value[caretPos - 1];
const shouldTrigger = await checkTriggerRule({ backSpace }); const shouldTrigger = await checkTriggerRule({ backSpace });
if ( if (
@ -622,7 +610,10 @@ export default function (options) {
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev)) (prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
) { ) {
start = caretPos; start = caretPos;
term = element.value.substring(caretPos + 1, initialCaretPos); term = options.textHandler.value.substring(
caretPos + 1,
initialCaretPos
);
end = caretPos + term.length; end = caretPos + term.length;
break; break;
} }
@ -653,9 +644,10 @@ export default function (options) {
inputSelectedItems.push(""); inputSelectedItems.push("");
} }
if (typeof inputSelectedItems[0] === "string" && me.val().length > 0) { const value = options.textHandler.value;
if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
inputSelectedItems.pop(); inputSelectedItems.pop();
inputSelectedItems.push(me.val()); inputSelectedItems.push(value);
if (options.onChangeItems) { if (options.onChangeItems) {
options.onChangeItems(inputSelectedItems); options.onChangeItems(inputSelectedItems);
} }
@ -693,10 +685,13 @@ export default function (options) {
} }
if (completeStart !== null) { if (completeStart !== null) {
cp = caretPosition(me[0]); cp = options.textHandler.getCaretPosition();
// allow people to right arrow out of completion // allow people to right arrow out of completion
if (e.which === keys.rightArrow && me[0].value[cp] === " ") { if (
e.which === keys.rightArrow &&
options.textHandler.value[cp] === " "
) {
closeAutocomplete(); closeAutocomplete();
return true; return true;
} }
@ -771,7 +766,10 @@ export default function (options) {
return true; return true;
} }
term = me.val().substring(completeStart + (options.key ? 1 : 0), cp); term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
if (completeStart === cp && term === options.key) { if (completeStart === cp && term === options.key) {
closeAutocomplete(); closeAutocomplete();

View File

@ -4,11 +4,7 @@ import { ajax } from "discourse/lib/ajax";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete"; import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import { getHashtagTypeClasses as getHashtagTypeClassesNew } from "discourse/lib/hashtag-type-registry"; import { getHashtagTypeClasses as getHashtagTypeClassesNew } from "discourse/lib/hashtag-type-registry";
import { emojiUnescape } from "discourse/lib/text"; import { emojiUnescape } from "discourse/lib/text";
import { import { escapeExpression } from "discourse/lib/utilities";
caretPosition,
escapeExpression,
inCodeBlock,
} from "discourse/lib/utilities";
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment"; import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
@ -50,8 +46,8 @@ export function setupHashtagAutocomplete(
); );
} }
export async function hashtagTriggerRule(textarea) { export async function hashtagTriggerRule(textarea, { inCodeBlock }) {
return !(await inCodeBlock(textarea.value, caretPosition(textarea))); return !(await inCodeBlock());
} }
export function hashtagAutocompleteOptions( export function hashtagAutocompleteOptions(
@ -62,8 +58,6 @@ export function hashtagAutocompleteOptions(
return { return {
template: findRawTemplate("hashtag-autocomplete"), template: findRawTemplate("hashtag-autocomplete"),
key: "#", key: "#",
afterComplete: autocompleteOptions.afterComplete,
treatAsTextarea: autocompleteOptions.treatAsTextarea,
scrollElementSelector: ".hashtag-autocomplete__fadeout", scrollElementSelector: ".hashtag-autocomplete__fadeout",
autoSelectFirstSuggestion: true, autoSelectFirstSuggestion: true,
transformComplete: (obj) => obj.ref, transformComplete: (obj) => obj.ref,
@ -75,6 +69,7 @@ export function hashtagAutocompleteOptions(
}, },
triggerRule: async (textarea, opts) => triggerRule: async (textarea, opts) =>
await hashtagTriggerRule(textarea, opts), await hashtagTriggerRule(textarea, opts),
...autocompleteOptions,
}; };
} }

View File

@ -12,6 +12,7 @@ import {
clipboardHelpers, clipboardHelpers,
determinePostReplaceSelection, determinePostReplaceSelection,
inCodeBlock, inCodeBlock,
setCaretPosition,
} from "discourse/lib/utilities"; } from "discourse/lib/utilities";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
@ -52,6 +53,7 @@ export default class TextareaTextManipulation {
textarea; textarea;
$textarea; $textarea;
autocompleteHandler;
placeholder; placeholder;
constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) { constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) {
@ -62,6 +64,8 @@ export default class TextareaTextManipulation {
this.textarea = textarea; this.textarea = textarea;
this.$textarea = $(textarea); this.$textarea = $(textarea);
this.autocompleteHandler = new TextareaAutocompleteHandler(textarea);
generateLinkifyFunction(markdownOptions || {}).then((linkify) => { generateLinkifyFunction(markdownOptions || {}).then((linkify) => {
// When pasting links, we should use the same rules to match links as we do when creating links for a cooked post. // When pasting links, we should use the same rules to match links as we do when creating links for a cooked post.
this._cachedLinkify = linkify; this._cachedLinkify = linkify;
@ -349,13 +353,7 @@ export default class TextareaTextManipulation {
} }
_insertAt(start, end, text) { _insertAt(start, end, text) {
this.textarea.setSelectionRange(start, end); insertAtTextarea(this.textarea, start, end, text);
this.textarea.focus();
if (start !== end && text === "") {
document.execCommand("delete", false);
} else {
document.execCommand("insertText", false, text);
}
} }
extractTable(text) { extractTable(text) {
@ -742,7 +740,6 @@ export default class TextareaTextManipulation {
} }
} }
@bind
async inCodeBlock() { async inCodeBlock() {
return inCodeBlock( return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(), this.$textarea.value ?? this.$textarea.val(),
@ -829,8 +826,57 @@ export default class TextareaTextManipulation {
putCursorAtEnd(this.textarea); putCursorAtEnd(this.textarea);
} }
autocomplete() { autocomplete(options) {
return this.$textarea.autocomplete(...arguments); return this.$textarea.autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
}
}
function insertAtTextarea(textarea, start, end, text) {
textarea.setSelectionRange(start, end);
textarea.focus();
if (start !== end && text === "") {
document.execCommand("delete", false);
} else {
document.execCommand("insertText", false, text);
}
}
export class TextareaAutocompleteHandler {
textarea;
$textarea;
constructor(textarea) {
this.textarea = textarea;
this.$textarea = $(textarea);
}
get value() {
return this.textarea.value;
}
replaceTerm({ start, end, term }) {
const space = this.value.substring(end + 1, end + 2) === " " ? "" : " ";
insertAtTextarea(this.textarea, start, end + 1, term + space);
setCaretPosition(this.textarea, start + 1 + term.trim().length);
}
getCaretPosition() {
return caretPosition(this.textarea);
}
getCaretCoords(start) {
return this.$textarea.caretPosition({ pos: start + 1 });
}
async inCodeBlock() {
return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(),
caretPosition(this.$textarea)
);
} }
} }

View File

@ -151,4 +151,22 @@ module("Unit | Utility | autocomplete", function (hooks) {
assert.dom("#ac-testing li a.selected").exists({ count: 1 }); assert.dom("#ac-testing li a.selected").exists({ count: 1 });
assert.dom("#ac-testing li a.selected").hasText("test1"); assert.dom("#ac-testing li a.selected").hasText("test1");
}); });
test("Autocomplete doesn't reset undo history", async function (assert) {
const element = textArea();
$(element).autocomplete({
key: "@",
template,
dataSource: () => ["test1", "test2"],
});
await simulateKeys(element, "@t\r");
assert.strictEqual(element.value, "@test1 ");
document.execCommand("undo");
assert.strictEqual(element.value, "@t");
});
}); });