diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 771c9bef2a8..5d228de9830 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -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, diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index 0e263ea8d6a..7f2c9fde7a2 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -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(); diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js index 3cd5d46acf6..30d838caa4a 100644 --- a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js @@ -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, }; } diff --git a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js index 29c29680f2f..80e40737563 100644 --- a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js @@ -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) + ); } } diff --git a/app/assets/javascripts/discourse/tests/unit/lib/autocomplete-test.js b/app/assets/javascripts/discourse/tests/unit/lib/autocomplete-test.js index 66f689a39e9..92538da136f 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/autocomplete-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/autocomplete-test.js @@ -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"); + }); });