mirror of
https://github.com/discourse/discourse.git
synced 2024-12-19 12:33:47 +08:00
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:
parent
e37952c9db
commit
4a5a499d94
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user