From 5d1e67b3e106dfc64428b562bef92e88cb9cb9a2 Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Tue, 22 Oct 2024 17:20:11 -0300 Subject: [PATCH] DEV: refactor textarea text manipulation mixin (#29294) Refactor of the TextareaTextManipulation from a Mixin to a native class --- .../discourse/app/components/d-editor.hbs | 2 +- .../discourse/app/components/d-editor.js | 147 ++++++++++++----- .../textarea-text-manipulation.js | 148 ++++++++---------- .../discourse/lib/textarea-interactor.js | 63 +++++--- 4 files changed, 218 insertions(+), 142 deletions(-) rename app/assets/javascripts/discourse/app/{mixins => lib}/textarea-text-manipulation.js (89%) diff --git a/app/assets/javascripts/discourse/app/components/d-editor.hbs b/app/assets/javascripts/discourse/app/components/d-editor.hbs index cfb7698814c..299241a8dce 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.hbs +++ b/app/assets/javascripts/discourse/app/components/d-editor.hbs @@ -101,6 +101,6 @@ @isActive={{this.emojiPickerIsActive}} @isEditorFocused={{this.isEditorFocused}} @initialFilter={{this.emojiFilter}} - @emojiSelected={{action "emojiSelected"}} + @emojiSelected={{this.textManipulation.emojiSelected}} @onEmojiPickerClose={{this.onEmojiPickerClose}} /> \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 139c3be3fed..fdbaf179d59 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -1,5 +1,6 @@ import Component from "@ember/component"; import { action, computed } from "@ember/object"; +import { getOwner } from "@ember/owner"; import { schedule, scheduleOnce } from "@ember/runloop"; import { service } from "@ember/service"; import ItsATrap from "@discourse/itsatrap"; @@ -21,14 +22,14 @@ import { linkSeenMentions } from "discourse/lib/link-mentions"; import { loadOneboxes } from "discourse/lib/load-oneboxes"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; import { siteDir } from "discourse/lib/text-direction"; +import TextareaTextManipulation, { + getHead, +} from "discourse/lib/textarea-text-manipulation"; import { caretPosition, inCodeBlock, translateModKey, } from "discourse/lib/utilities"; -import TextareaTextManipulation, { - getHead, -} from "discourse/mixins/textarea-text-manipulation"; import { isTesting } from "discourse-common/config/environment"; import discourseDebounce from "discourse-common/lib/debounce"; import deprecated from "discourse-common/lib/deprecated"; @@ -233,12 +234,12 @@ export function onToolbarCreate(func) { } @classNames("d-editor") -export default class DEditor extends Component.extend( - TextareaTextManipulation -) { +export default class DEditor extends Component { @service("emoji-store") emojiStore; @service modal; + textManipulation; + ready = false; lastSel = null; showLink = true; @@ -246,7 +247,6 @@ export default class DEditor extends Component.extend( emojiFilter = ""; isEditorFocused = false; processPreview = true; - composerFocusSelector = "#reply-control .d-editor-input"; morphingOptions = { beforeAttributeUpdated: (element, attributeName) => { // Don't morph the open attribute of
elements @@ -309,6 +309,15 @@ export default class DEditor extends Component.extend( this._textarea = this.element.querySelector("textarea.d-editor-input"); this._$textarea = $(this._textarea); + + this.set( + "textManipulation", + new TextareaTextManipulation(getOwner(this), { + markdownOptions: this.markdownOptions, + textarea: this._textarea, + }) + ); + this._applyEmojiAutocomplete(this._$textarea); this._applyHashtagAutocomplete(this._$textarea); @@ -345,8 +354,12 @@ export default class DEditor extends Component.extend( }); } - this._itsatrap.bind("tab", () => this.indentSelection("right")); - this._itsatrap.bind("shift+tab", () => this.indentSelection("left")); + this._itsatrap.bind("tab", () => + this.textManipulation.indentSelection("right") + ); + this._itsatrap.bind("shift+tab", () => + this.textManipulation.indentSelection("left") + ); this._itsatrap.bind(`${PLATFORM_KEY_MODIFIER}+shift+.`, () => this.send("insertCurrentTime") ); @@ -366,6 +379,8 @@ export default class DEditor extends Component.extend( this.onBeforeInputSmartList ); this._textarea.addEventListener("input", this.onInputSmartList); + + this.element.addEventListener("paste", this.textManipulation.paste); } // disable clicking on links in the preview @@ -374,13 +389,25 @@ export default class DEditor extends Component.extend( .addEventListener("click", this._handlePreviewLinkClick); if (this.composerEvents) { - this.appEvents.on("composer:insert-block", this, "insertBlock"); - this.appEvents.on("composer:insert-text", this, "insertText"); - this.appEvents.on("composer:replace-text", this, "replaceText"); + this.appEvents.on( + "composer:insert-block", + this.textManipulation, + "insertBlock" + ); + this.appEvents.on( + "composer:insert-text", + this.textManipulation, + "insertText" + ); + this.appEvents.on( + "composer:replace-text", + this.textManipulation, + "replaceText" + ); this.appEvents.on("composer:apply-surround", this, "_applySurround"); this.appEvents.on( "composer:indent-selected-text", - this, + this.textManipulation, "indentSelection" ); } @@ -396,7 +423,7 @@ export default class DEditor extends Component.extend( @bind onInputSmartList() { if (this.handleSmartListAutocomplete) { - this.maybeContinueList(); + this.textManipulation.maybeContinueList(); } this.handleSmartListAutocomplete = false; } @@ -432,13 +459,25 @@ export default class DEditor extends Component.extend( @on("willDestroyElement") _shutDown() { if (this.composerEvents) { - this.appEvents.off("composer:insert-block", this, "insertBlock"); - this.appEvents.off("composer:insert-text", this, "insertText"); - this.appEvents.off("composer:replace-text", this, "replaceText"); + this.appEvents.off( + "composer:insert-block", + this.textManipulation, + "insertBlock" + ); + this.appEvents.off( + "composer:insert-text", + this.textManipulation, + "insertText" + ); + this.appEvents.off( + "composer:replace-text", + this.textManipulation, + "replaceText" + ); this.appEvents.off("composer:apply-surround", this, "_applySurround"); this.appEvents.off( "composer:indent-selected-text", - this, + this.textManipulation, "indentSelection" ); } @@ -460,9 +499,7 @@ export default class DEditor extends Component.extend( this._previewMutationObserver?.disconnect(); - if (isTesting()) { - this.element.removeEventListener("paste", this.paste); - } + this.element.removeEventListener("paste", this.textManipulation.paste); this._cachedCookFunction = null; } @@ -590,7 +627,11 @@ export default class DEditor extends Component.extend( { afterComplete: (value) => { this.set("value", value); - schedule("afterRender", this, this.focusTextArea); + schedule( + "afterRender", + this.textManipulation, + this.textManipulation.blurAndFocus + ); }, } ); @@ -606,7 +647,11 @@ export default class DEditor extends Component.extend( key: ":", afterComplete: (text) => { this.set("value", text); - schedule("afterRender", this, this.focusTextArea); + schedule( + "afterRender", + this.textManipulation, + this.textManipulation.blurAndFocus + ); }, onKeyUp: (text, cp) => { @@ -720,7 +765,7 @@ export default class DEditor extends Component.extend( _applyList(sel, head, exampleKey, opts) { if (sel.value.includes("\n")) { - this.applySurround(sel, head, "", exampleKey, opts); + this.textManipulation.applySurround(sel, head, "", exampleKey, opts); } else { const [hval, hlen] = getHead(head); if (sel.start === sel.end) { @@ -737,13 +782,13 @@ export default class DEditor extends Component.extend( const post = trimmedPost.length ? `\n\n${trimmedPost}` : trimmedPost; this.set("value", `${preLines}${number}${post}`); - this.selectText(preLines.length, number.length); + this.textManipulation.selectText(preLines.length, number.length); } } _applySurround(head, tail, exampleKey, opts) { - const selected = this.getSelected(); - this.applySurround(selected, head, tail, exampleKey, opts); + const selected = this.textManipulation.getSelected(); + this.textManipulation.applySurround(selected, head, tail, exampleKey, opts); } _toggleDirection() { @@ -802,21 +847,27 @@ export default class DEditor extends Component.extend( } newToolbarEvent(trimLeading) { - const selected = this.getSelected(trimLeading); + const selected = this.textManipulation.getSelected(trimLeading); return { selected, selectText: (from, length) => - this.selectText(from, length, { scroll: false }), + this.textManipulation.selectText(from, length, { scroll: false }), applySurround: (head, tail, exampleKey, opts) => - this.applySurround(selected, head, tail, exampleKey, opts), + this.textManipulation.applySurround( + selected, + head, + tail, + exampleKey, + opts + ), applyList: (head, exampleKey, opts) => this._applyList(selected, head, exampleKey, opts), formatCode: (...args) => this.send("formatCode", args), - addText: (text) => this.addText(selected, text), + addText: (text) => this.textManipulation.addText(selected, text), getText: () => this.value, toggleDirection: () => this._toggleDirection(), replaceText: (oldVal, newVal, opts) => - this.replaceText(oldVal, newVal, opts), + this.textManipulation.replaceText(oldVal, newVal, opts), }; } @@ -870,7 +921,7 @@ export default class DEditor extends Component.extend( return; } - const sel = this.getSelected("", { lineVal: true }); + const sel = this.textManipulation.getSelected("", { lineVal: true }); const selValue = sel.value; const hasNewLine = selValue.includes("\n"); const isBlankLine = sel.lineVal.trim().length === 0; @@ -882,20 +933,33 @@ export default class DEditor extends Component.extend( if (isFourSpacesIndent) { const example = I18n.t(`composer.code_text`); this.set("value", `${sel.pre} ${example}${sel.post}`); - return this.selectText(sel.pre.length + 4, example.length); + return this.textManipulation.selectText( + sel.pre.length + 4, + example.length + ); } else { - return this.applySurround(sel, "```\n", "\n```", "paste_code_text"); + return this.textManipulation.applySurround( + sel, + "```\n", + "\n```", + "paste_code_text" + ); } } else { - return this.applySurround(sel, "`", "`", "code_title"); + return this.textManipulation.applySurround(sel, "`", "`", "code_title"); } } else { if (isFourSpacesIndent) { - return this.applySurround(sel, " ", "", "code_text"); + return this.textManipulation.applySurround( + sel, + " ", + "", + "code_text" + ); } else { const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : ""; const postNewline = sel.post[0] !== "\n" ? "\n" : ""; - return this.addText( + return this.textManipulation.addText( sel, `${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}` ); @@ -905,12 +969,15 @@ export default class DEditor extends Component.extend( @action insertCurrentTime() { - const sel = this.getSelected("", { lineVal: true }); + const sel = this.textManipulation.getSelected("", { lineVal: true }); const timezone = this.currentUser.user_option.timezone; const time = moment().format("HH:mm:ss"); const date = moment().format("YYYY-MM-DD"); - this.addText(sel, `[date=${date} time=${time} timezone="${timezone}"]`); + this.textManipulation.addText( + sel, + `[date=${date} time=${time} timezone="${timezone}"]` + ); } @action diff --git a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js similarity index 89% rename from app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js rename to app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js index 2914a049b9f..3ed5585f97d 100644 --- a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js @@ -1,6 +1,7 @@ import { action } from "@ember/object"; -import Mixin from "@ember/object/mixin"; +import { setOwner } from "@ember/owner"; import { next, schedule } from "@ember/runloop"; +import { service } from "@ember/service"; import { isEmpty } from "@ember/utils"; import { generateLinkifyFunction } from "discourse/lib/text"; import toMarkdown from "discourse/lib/to-markdown"; @@ -35,52 +36,48 @@ export function getHead(head, prev) { } } -export default Mixin.create({ - init() { - this._super(...arguments); +export default class TextareaTextManipulation { + @service appEvents; + @service siteSettings; + @service capabilities; - // fallback in the off chance someone has implemented a custom composer - // which does not define this - if (!this.composerEventPrefix) { - this.composerEventPrefix = "composer"; - } + eventPrefix; + textarea; - generateLinkifyFunction(this.markdownOptions || {}).then((linkify) => { + constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) { + setOwner(this, owner); + + this.eventPrefix = eventPrefix; + this.textarea = 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; }); - }, + } + + get value() { + return this.textarea.value; + } // ensures textarea scroll position is correct - focusTextArea() { - if (!this.element || this.isDestroying || this.isDestroyed) { - return; - } - - if (!this._textarea) { - return; - } - - this._textarea.blur(); - this._textarea.focus(); - }, + blurAndFocus() { + this.textarea?.blur(); + this.textarea?.focus(); + } insertBlock(text) { this._addBlock(this.getSelected(), text); - }, + } insertText(text, options) { this.addText(this.getSelected(), text, options); - }, + } getSelected(trimLeading, opts) { - if (!this.ready || !this.element) { - return; - } - - const value = this._textarea.value; - let start = this._textarea.selectionStart; - let end = this._textarea.selectionEnd; + const value = this.value; + let start = this.textarea.selectionStart; + let end = this.textarea.selectionEnd; // trim trailing spaces cause **test ** would be invalid while (end > start && /\s/.test(value.charAt(end - 1))) { @@ -101,33 +98,30 @@ export default Mixin.create({ if (opts && opts.lineVal) { const lineVal = value.split("\n")[ - value.slice(0, this._textarea.selectionStart).split("\n").length - 1 + value.slice(0, this.textarea.selectionStart).split("\n").length - 1 ]; return { start, end, value: selVal, pre, post, lineVal }; } else { return { start, end, value: selVal, pre, post }; } - }, + } selectText(from, length, opts = { scroll: true }) { next(() => { - if (!this.element) { - return; - } - this._textarea.selectionStart = from; - this._textarea.selectionEnd = from + length; + this.textarea.selectionStart = from; + this.textarea.selectionEnd = from + length; if (opts.scroll === true || typeof opts.scroll === "number") { const oldScrollPos = typeof opts.scroll === "number" ? opts.scroll - : this._textarea.scrollTop; + : this.textarea.scrollTop; if (!this.capabilities.isIOS) { - this._textarea.focus(); + this.textarea.focus(); } - this._textarea.scrollTop = oldScrollPos; + this.textarea.scrollTop = oldScrollPos; } }); - }, + } replaceText(oldVal, newVal, opts = {}) { const val = this.value; @@ -141,8 +135,8 @@ export default Mixin.create({ // Determine post-replace selection. const newSelection = determinePostReplaceSelection({ selection: { - start: this._textarea.selectionStart, - end: this._textarea.selectionEnd, + start: this.textarea.selectionStart, + end: this.textarea.selectionEnd, }, needle: { start: needleStart, end: needleStart + oldVal.length }, replacement: { start: needleStart, end: needleStart + newVal.length }, @@ -167,7 +161,7 @@ export default Mixin.create({ } if ( - (opts.forceFocus || this._$textarea.is(":focus")) && + (opts.forceFocus || this.textarea === document.activeElement) && !opts.skipNewSelection ) { // Restore cursor. @@ -176,7 +170,7 @@ export default Mixin.create({ newSelection.end - newSelection.start ); } - }, + } applySurround(sel, head, tail, exampleKey, opts) { const pre = sel.pre; @@ -240,7 +234,7 @@ export default Mixin.create({ } } } - }, + } // perform the same operation over many lines of text _getMultilineContents(lines, head, hval, hlen, tail, tlen, opts) { @@ -280,7 +274,7 @@ export default Mixin.create({ return result; }) .join("\n"); - }, + } _addBlock(sel, text) { text = (text || "").trim(); @@ -312,9 +306,9 @@ export default Mixin.create({ } this._insertAt(start, end, text); - this._textarea.setSelectionRange(start + text.length, start + text.length); - schedule("afterRender", this, this.focusTextArea); - }, + this.textarea.setSelectionRange(start + text.length, start + text.length); + schedule("afterRender", this, this.blurAndFocus); + } addText(sel, text, options) { if (options && options.ensureSpace) { @@ -331,18 +325,18 @@ export default Mixin.create({ } this._insertAt(sel.start, sel.end, text); - this.focusTextArea(); - }, + this.blurAndFocus(); + } _insertAt(start, end, text) { - this._textarea.setSelectionRange(start, end); - this._textarea.focus(); + this.textarea.setSelectionRange(start, end); + this.textarea.focus(); if (start !== end && text === "") { document.execCommand("delete", false); } else { document.execCommand("insertText", false, text); } - }, + } extractTable(text) { if (text.endsWith("\n")) { @@ -379,17 +373,16 @@ export default Mixin.create({ } } return null; - }, + } isInside(text, regex) { const matches = text.match(regex); return matches && matches.length % 2; - }, + } @bind paste(e) { - const isComposer = - document.querySelector(this.composerFocusSelector) === e.target; + const isComposer = this.textarea === e.target; if (!isComposer && !isTesting()) { return; @@ -418,11 +411,8 @@ export default Mixin.create({ plainText = plainText.replace(/\r/g, ""); const table = this.extractTable(plainText); if (table) { - this.composerEventPrefix - ? this.appEvents.trigger( - `${this.composerEventPrefix}:insert-text`, - table - ) + this.eventPrefix + ? this.appEvents.trigger(`${this.eventPrefix}:insert-text`, table) : this.insertText(table); handled = true; } @@ -475,9 +465,9 @@ export default Mixin.create({ } if (isComposer) { - this.composerEventPrefix + this.eventPrefix ? this.appEvents.trigger( - `${this.composerEventPrefix}:insert-text`, + `${this.eventPrefix}:insert-text`, markdown ) : this.insertText(markdown); @@ -489,7 +479,7 @@ export default Mixin.create({ if (handled || (canUpload && !plainText)) { e.preventDefault(); } - }, + } /** * Removes the provided char from the provided str up @@ -506,7 +496,7 @@ export default Mixin.create({ } } return str; - }, + } _updateListNumbers(text, currentNumber) { return text @@ -523,12 +513,12 @@ export default Mixin.create({ return line; }) .join("\n"); - }, + } @bind maybeContinueList() { - const offset = caretPosition(this._textarea); - const text = this._textarea.value; + const offset = caretPosition(this.textarea); + const text = this.value; const lines = text.substring(0, offset).split("\n"); // Only continue if the previous line was a list item. @@ -610,7 +600,7 @@ export default Mixin.create({ numericBullet + 1 ); autocompletePrefix += autocompletePostfix; - scrollPosition = this._textarea.scrollTop; + scrollPosition = this.textarea.scrollTop; this.replaceText( text.substring(offset, offset + autocompletePrefix.length), @@ -636,7 +626,7 @@ export default Mixin.create({ ); this.selectText(offsetWithoutPrefix, 0); } - }, + } @bind indentSelection(direction) { @@ -711,7 +701,7 @@ export default Mixin.create({ this.replaceText(value, newValue, { skipNewSelection: true }); this.selectText(this.value.indexOf(newValue), newValue.length); } - }, + } @action emojiSelected(code) { @@ -732,9 +722,9 @@ export default Mixin.create({ `${code}:` ); } - }, + } isInsideCodeFence(beforeText) { return this.isInside(beforeText, /(^|\n)```/g); - }, -}); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js index fd71e1f94e1..1d4c98b7267 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js +++ b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js @@ -3,52 +3,52 @@ import EmberObject from "@ember/object"; import { setOwner } from "@ember/owner"; import { next, schedule } from "@ember/runloop"; import { service } from "@ember/service"; -import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation"; +import TextareaTextManipulation from "discourse/lib/textarea-text-manipulation"; // This class sole purpose is to provide a way to interact with the textarea // using the existing TextareaTextManipulation mixin without using it directly // in the composer component. It will make future migration easier. -export default class TextareaInteractor extends EmberObject.extend( - TextareaTextManipulation -) { +export default class TextareaInteractor extends EmberObject { @service capabilities; @service site; @service siteSettings; + textManipulation; + constructor(owner, textarea) { super(...arguments); setOwner(this, owner); this.textarea = textarea; - this._textarea = textarea; - this.element = this._textarea; - this.ready = true; - this.composerFocusSelector = `#${textarea.id}`; + this.element = textarea; - this.init(); // mixin init wouldn't be called otherwise - this.composerEventPrefix = null; // we don't need app events + this.textManipulation = new TextareaTextManipulation(owner, { + textarea, + // we don't need app events + eventPrefix: null, + }); // paste is using old native ember events defined on composer - this.textarea.addEventListener("paste", this.paste); + this.textarea.addEventListener("paste", this.textManipulation.paste); registerDestructor(this, (instance) => instance.teardown()); } teardown() { - this.textarea.removeEventListener("paste", this.paste); + this.textarea.removeEventListener("paste", this.textManipulation.paste); } set value(value) { - this._textarea.value = value; + this.textarea.value = value; const event = new Event("input", { bubbles: true, cancelable: true, }); - this._textarea.dispatchEvent(event); + this.textarea.dispatchEvent(event); } blur() { next(() => { schedule("afterRender", () => { - this._textarea.blur(); + this.textarea.blur(); }); }); } @@ -69,19 +69,22 @@ export default class TextareaInteractor extends EmberObject.extend( } if (opts.addText) { - this.addText(this.getSelected(), opts.addText); + this.textManipulation.addText( + this.textManipulation.getSelected(), + opts.addText + ); } - this.focusTextArea(); + this.textManipulation.blurAndFocus(); }); }); } ensureCaretAtEnd() { schedule("afterRender", () => { - this._textarea.setSelectionRange( - this._textarea.value.length, - this._textarea.value.length + this.textarea.setSelectionRange( + this.textarea.value.length, + this.textarea.value.length ); }); } @@ -90,11 +93,27 @@ export default class TextareaInteractor extends EmberObject.extend( schedule("afterRender", () => { // this is a quirk which forces us to `auto` first or textarea // won't resize - this._textarea.style.height = "auto"; + this.textarea.style.height = "auto"; // +1 is to workaround a rounding error visible on electron // causing scrollbars to show when they shouldn’t - this._textarea.style.height = this._textarea.scrollHeight + 1 + "px"; + this.textarea.style.height = this.textarea.scrollHeight + 1 + "px"; }); } + + getSelected() { + return this.textManipulation.getSelected(...arguments); + } + + applySurround() { + return this.textManipulation.applySurround(...arguments); + } + + addText() { + return this.textManipulation.addText(...arguments); + } + + isInside() { + return this.textManipulation.isInside(...arguments); + } }