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

View File

@ -2,7 +2,7 @@ import { cancel } from "@ember/runloop";
import { createPopper } from "@popperjs/core";
import $ from "jquery";
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 { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
@ -114,6 +114,8 @@ export default function (options) {
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
let inputSelectedItems = [];
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
function handlePaste() {
discourseLater(() => me.trigger("keydown"), 50);
}
@ -216,8 +218,6 @@ export default function (options) {
}
let completeTerm = async function (term, event) {
let completeEnd = null;
if (term) {
if (isInput) {
me.val("");
@ -231,15 +231,13 @@ export default function (options) {
}
if (term) {
let text = me.val();
// 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.
let pos = await guessCompletePosition({ completeTerm: true });
let completeEnd = null;
if (
pos.completeStart !== undefined &&
pos.completeEnd !== undefined
@ -247,31 +245,18 @@ export default function (options) {
completeStart = pos.completeStart;
completeEnd = pos.completeEnd;
} else {
completeStart = completeEnd = caretPosition(me[0]);
completeStart = completeEnd =
options.textHandler.getCaretPosition();
}
let space =
text.substring(completeEnd + 1, completeEnd + 2) === " " ? "" : " ";
text =
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);
options.textHandler.replaceTerm({
start: completeStart,
end: completeEnd,
term: (options.preserveKey ? options.key || "" : "") + term,
});
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 pos = me.caretPosition({
pos: completeStart + 1,
});
let pos = options.textHandler.getCaretCoords(completeStart);
if (options.treatAsTextarea) {
vOffset = -32;
@ -539,7 +522,11 @@ export default function (options) {
closeAutocomplete();
});
async function checkTriggerRule(opts) {
async function checkTriggerRule(_opts) {
const opts = {
..._opts,
inCodeBlock: () => options.textHandler.inCodeBlock(),
};
const shouldTrigger = await options.triggerRule?.(me[0], opts);
return shouldTrigger ?? true;
}
@ -557,12 +544,12 @@ export default function (options) {
return true;
}
let cp = caretPosition(me[0]);
const key = me[0].value[cp - 1];
let cp = options.textHandler.getCaretPosition();
const key = options.textHandler.value[cp - 1];
if (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) {
completeStart = cp - match[0].length;
@ -574,10 +561,9 @@ export default function (options) {
if (completeStart === null && cp > 0) {
if (key === options.key) {
let prevChar = me.val().charAt(cp - 2);
const shouldTrigger = await checkTriggerRule();
let prevChar = options.textHandler.value.charAt(cp - 2);
if (
shouldTrigger &&
(await checkTriggerRule()) &&
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar))
) {
completeStart = cp - 1;
@ -585,7 +571,10 @@ export default function (options) {
}
}
} 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));
}
}
@ -593,10 +582,9 @@ export default function (options) {
async function guessCompletePosition(opts) {
let prev, stopFound, term;
let prevIsGood = true;
let element = me[0];
let backSpace = opts?.backSpace;
let completeTermOption = opts?.completeTerm;
let caretPos = caretPosition(element);
let caretPos = options.textHandler.getCaretPosition();
if (backSpace) {
caretPos -= 1;
@ -609,12 +597,12 @@ export default function (options) {
while (prevIsGood && caretPos >= 0) {
caretPos -= 1;
prev = element.value[caretPos];
prev = options.textHandler.value[caretPos];
stopFound = prev === options.key;
if (stopFound) {
prev = element.value[caretPos - 1];
prev = options.textHandler.value[caretPos - 1];
const shouldTrigger = await checkTriggerRule({ backSpace });
if (
@ -622,7 +610,10 @@ export default function (options) {
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
) {
start = caretPos;
term = element.value.substring(caretPos + 1, initialCaretPos);
term = options.textHandler.value.substring(
caretPos + 1,
initialCaretPos
);
end = caretPos + term.length;
break;
}
@ -653,9 +644,10 @@ export default function (options) {
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.push(me.val());
inputSelectedItems.push(value);
if (options.onChangeItems) {
options.onChangeItems(inputSelectedItems);
}
@ -693,10 +685,13 @@ export default function (options) {
}
if (completeStart !== null) {
cp = caretPosition(me[0]);
cp = options.textHandler.getCaretPosition();
// 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();
return true;
}
@ -771,7 +766,10 @@ export default function (options) {
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) {
closeAutocomplete();

View File

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

View File

@ -12,6 +12,7 @@ import {
clipboardHelpers,
determinePostReplaceSelection,
inCodeBlock,
setCaretPosition,
} from "discourse/lib/utilities";
import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators";
@ -52,6 +53,7 @@ export default class TextareaTextManipulation {
textarea;
$textarea;
autocompleteHandler;
placeholder;
constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) {
@ -62,6 +64,8 @@ export default class TextareaTextManipulation {
this.textarea = textarea;
this.$textarea = $(textarea);
this.autocompleteHandler = new TextareaAutocompleteHandler(textarea);
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.
this._cachedLinkify = linkify;
@ -349,13 +353,7 @@ export default class TextareaTextManipulation {
}
_insertAt(start, end, text) {
this.textarea.setSelectionRange(start, end);
this.textarea.focus();
if (start !== end && text === "") {
document.execCommand("delete", false);
} else {
document.execCommand("insertText", false, text);
}
insertAtTextarea(this.textarea, start, end, text);
}
extractTable(text) {
@ -742,7 +740,6 @@ export default class TextareaTextManipulation {
}
}
@bind
async inCodeBlock() {
return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(),
@ -829,8 +826,57 @@ export default class TextareaTextManipulation {
putCursorAtEnd(this.textarea);
}
autocomplete() {
return this.$textarea.autocomplete(...arguments);
autocomplete(options) {
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").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");
});
});