From eba317b74e53ed3ac4175fdd523c02c8f3f8567f Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Tue, 31 Aug 2021 14:36:26 -0500 Subject: [PATCH] DEV: Extract textarea text manipulation to mixin (#14201) --- .../discourse/app/components/d-editor.js | 327 +----------------- .../app/mixins/textarea-text-manipulation.js | 289 ++++++++++++++++ 2 files changed, 308 insertions(+), 308 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index c6ebb29af00..1d337cb0ba9 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -1,18 +1,12 @@ import { ajax } from "discourse/lib/ajax"; -import { - caretPosition, - clipboardHelpers, - determinePostReplaceSelection, - inCodeBlock, - safariHacksDisabled, -} from "discourse/lib/utilities"; +import { caretPosition, inCodeBlock } from "discourse/lib/utilities"; import discourseComputed, { observes, on, } from "discourse-common/utils/decorators"; import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; -import { later, next, schedule, scheduleOnce } from "@ember/runloop"; +import { later, schedule, scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; import I18n from "I18n"; import Mousetrap from "mousetrap"; @@ -34,10 +28,10 @@ import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; import { inject as service } from "@ember/service"; import showModal from "discourse/lib/show-modal"; import { siteDir } from "discourse/lib/text-direction"; -import toMarkdown from "discourse/lib/to-markdown"; import { translations } from "pretty-text/emoji/data"; import { wantsNewWindow } from "discourse/lib/intercept-click"; import { action } from "@ember/object"; +import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation"; // Our head can be a static string or a function that returns a string // based on input (like for numbered lists). @@ -64,11 +58,6 @@ const FOUR_SPACES_INDENT = "4-spaces-indent"; let _createCallbacks = []; -const isInside = (text, regex) => { - const matches = text.match(regex); - return matches && matches.length % 2; -}; - class Toolbar { constructor(opts) { const { siteSettings } = opts; @@ -245,7 +234,7 @@ export function onToolbarCreate(func) { addToolbarCallback(func); } -export default Component.extend({ +export default Component.extend(TextareaTextManipulation, { classNames: ["d-editor"], ready: false, lastSel: null, @@ -255,6 +244,7 @@ export default Component.extend({ emojiStore: service("emoji-store"), isEditorFocused: false, processPreview: true, + composerFocusSelector: "#reply-control .d-editor-input", @discourseComputed("placeholder") placeholderTranslated(placeholder) { @@ -268,7 +258,7 @@ export default Component.extend({ this.set("ready", true); if (this.autofocus) { - this.element.querySelector("textarea").focus(); + this._textarea.focus(); } }, @@ -281,15 +271,14 @@ export default Component.extend({ didInsertElement() { this._super(...arguments); - const $editorInput = $(this.element.querySelector(".d-editor-input")); - this._applyEmojiAutocomplete($editorInput); - this._applyCategoryHashtagAutocomplete($editorInput); + this._textarea = this.element.querySelector("textarea.d-editor-input"); + this._$textarea = $(this._textarea); + this._applyEmojiAutocomplete(this._$textarea); + this._applyCategoryHashtagAutocomplete(this._$textarea); scheduleOnce("afterRender", this, this._readyNow); - this._mouseTrap = new Mousetrap( - this.element.querySelector(".d-editor-input") - ); + this._mouseTrap = new Mousetrap(this._textarea); const shortcuts = this.get("toolbar.shortcuts"); Object.keys(shortcuts).forEach((sc) => { @@ -338,14 +327,6 @@ export default Component.extend({ } }, - _insertBlock(text) { - this._addBlock(this._getSelected(), text); - }, - - _insertText(text, options) { - this._addText(this._getSelected(), text, options); - }, - @on("willDestroyElement") _shutDown() { if (this.composerEvents) { @@ -479,7 +460,7 @@ export default Component.extend({ _applyCategoryHashtagAutocomplete() { const siteSettings = this.siteSettings; - $(this.element.querySelector(".d-editor-input")).autocomplete({ + this._$textarea.autocomplete({ template: findRawTemplate("category-tag-autocomplete"), key: "#", afterComplete: (value) => { @@ -501,12 +482,12 @@ export default Component.extend({ }); }, - _applyEmojiAutocomplete($editorInput) { + _applyEmojiAutocomplete($textarea) { if (!this.siteSettings.enable_emoji) { return; } - $editorInput.autocomplete({ + $textarea.autocomplete({ template: findRawTemplate("emoji-selector-autocomplete"), key: ":", afterComplete: (text) => { @@ -533,7 +514,7 @@ export default Component.extend({ this.emojiStore.track(v.code); return `${v.code}:`; } else { - $editorInput.autocomplete({ cancel: true }); + $textarea.autocomplete({ cancel: true }); this.set("emojiPickerIsActive", true); schedule("afterRender", () => { @@ -624,63 +605,6 @@ export default Component.extend({ }); }, - _getSelected(trimLeading, opts) { - if (!this.ready || !this.element) { - return; - } - - const textarea = this.element.querySelector("textarea.d-editor-input"); - const value = textarea.value; - let start = textarea.selectionStart; - let end = textarea.selectionEnd; - - // trim trailing spaces cause **test ** would be invalid - while (end > start && /\s/.test(value.charAt(end - 1))) { - end--; - } - - if (trimLeading) { - // trim leading spaces cause ** test** would be invalid - while (end > start && /\s/.test(value.charAt(start))) { - start++; - } - } - - const selVal = value.substring(start, end); - const pre = value.slice(0, start); - const post = value.slice(end); - - if (opts && opts.lineVal) { - const lineVal = value.split("\n")[ - value.substr(0, 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; - } - - const textarea = this.element.querySelector("textarea.d-editor-input"); - const $textarea = $(textarea); - textarea.selectionStart = from; - textarea.selectionEnd = from + length; - $textarea.trigger("change"); - if (opts.scroll) { - const oldScrollPos = $textarea.scrollTop(); - if (!this.capabilities.isIOS || safariHacksDisabled()) { - $textarea.focus(); - } - $textarea.scrollTop(oldScrollPos); - } - }); - }, - // perform the same operation over many lines of text _getMultilineContents(lines, head, hval, hlen, tail, tlen, opts) { let operation = OP.NONE; @@ -813,226 +737,13 @@ export default Component.extend({ } }, - _replaceText(oldVal, newVal, opts = {}) { - const val = this.value; - const needleStart = val.indexOf(oldVal); - - if (needleStart === -1) { - // Nothing to replace. - return; - } - - const textarea = this.element.querySelector("textarea.d-editor-input"); - - // Determine post-replace selection. - const newSelection = determinePostReplaceSelection({ - selection: { start: textarea.selectionStart, end: textarea.selectionEnd }, - needle: { start: needleStart, end: needleStart + oldVal.length }, - replacement: { start: needleStart, end: needleStart + newVal.length }, - }); - - if (opts.index && opts.regex) { - let i = -1; - const newValue = val.replace(opts.regex, (match) => { - i++; - return i === opts.index ? newVal : match; - }); - this.set("value", newValue); - } else { - // Replace value (side effect: cursor at the end). - this.set("value", val.replace(oldVal, newVal)); - } - - if (opts.forceFocus || $("textarea.d-editor-input").is(":focus")) { - // Restore cursor. - this._selectText( - newSelection.start, - newSelection.end - newSelection.start - ); - } - }, - - _addBlock(sel, text) { - text = (text || "").trim(); - if (text.length === 0) { - return; - } - - let pre = sel.pre; - let post = sel.value + sel.post; - - if (pre.length > 0) { - pre = pre.replace(/\n*$/, "\n\n"); - } - - if (post.length > 0) { - post = post.replace(/^\n*/, "\n\n"); - } else { - post = "\n"; - } - - const value = pre + text + post; - const $textarea = $(this.element.querySelector("textarea.d-editor-input")); - - this.set("value", value); - - $textarea.val(value); - $textarea.prop("selectionStart", (pre + text).length + 2); - $textarea.prop("selectionEnd", (pre + text).length + 2); - - this._focusTextArea(); - }, - - _addText(sel, text, options) { - const $textarea = $(this.element.querySelector("textarea.d-editor-input")); - - if (options && options.ensureSpace) { - if ((sel.pre + "").length > 0) { - if (!sel.pre.match(/\s$/)) { - text = " " + text; - } - } - if ((sel.post + "").length > 0) { - if (!sel.post.match(/^\s/)) { - text = text + " "; - } - } - } - - const insert = `${sel.pre}${text}`; - const value = `${insert}${sel.post}`; - this.set("value", value); - $textarea.val(value); - $textarea.prop("selectionStart", insert.length); - $textarea.prop("selectionEnd", insert.length); - next(() => $textarea.trigger("change")); - this._focusTextArea(); - }, - - _extractTable(text) { - if (text.endsWith("\n")) { - text = text.substring(0, text.length - 1); - } - - text = text.split(""); - let cell = false; - text.forEach((char, index) => { - if (char === "\n" && cell) { - text[index] = "\r"; - } - if (char === '"') { - text[index] = ""; - cell = !cell; - } - }); - - let rows = text.join("").replace(/\r/g, "
").split("\n"); - - if (rows.length > 1) { - const columns = rows.map((r) => r.split("\t").length); - const isTable = - columns.reduce((a, b) => a && columns[0] === b && b > 1) && - !(columns[0] === 2 && rows[0].split("\t")[0].match(/^•$|^\d+.$/)); // to skip tab delimited lists - - if (isTable) { - const splitterRow = [...Array(columns[0])].map(() => "---").join("\t"); - rows.splice(1, 0, splitterRow); - - return ( - "|" + rows.map((r) => r.split("\t").join("|")).join("|\n|") + "|\n" - ); - } - } - return null; - }, - _toggleDirection() { - const $textArea = $(".d-editor-input"); - let currentDir = $textArea.attr("dir") ? $textArea.attr("dir") : siteDir(), + let currentDir = this._$textarea.attr("dir") + ? this._$textarea.attr("dir") + : siteDir(), newDir = currentDir === "ltr" ? "rtl" : "ltr"; - $textArea.attr("dir", newDir).focus(); - }, - - paste(e) { - if (!$(".d-editor-input").is(":focus") && !isTesting()) { - return; - } - - const isComposer = $("#reply-control .d-editor-input").is(":focus"); - let { clipboard, canPasteHtml, canUpload } = clipboardHelpers(e, { - siteSettings: this.siteSettings, - canUpload: isComposer, - }); - - let plainText = clipboard.getData("text/plain"); - let html = clipboard.getData("text/html"); - let handled = false; - - const { pre, lineVal } = this._getSelected(null, { lineVal: true }); - const isInlinePasting = pre.match(/[^\n]$/); - const isCodeBlock = isInside(pre, /(^|\n)```/g); - - if ( - plainText && - this.siteSettings.enable_rich_text_paste && - !isInlinePasting && - !isCodeBlock - ) { - plainText = plainText.replace(/\r/g, ""); - const table = this._extractTable(plainText); - if (table) { - this.appEvents.trigger("composer:insert-text", table); - handled = true; - } - } - - if (canPasteHtml && plainText) { - if (isInlinePasting) { - canPasteHtml = !( - lineVal.match(/^```/) || - isInside(pre, /`/g) || - lineVal.match(/^ /) - ); - } else { - canPasteHtml = !isCodeBlock; - } - } - - if (canPasteHtml && !handled) { - let markdown = toMarkdown(html); - - if (!plainText || plainText.length < markdown.length) { - if (isInlinePasting) { - markdown = markdown.replace(/^#+/, "").trim(); - markdown = pre.match(/\S$/) ? ` ${markdown}` : markdown; - } - - this.appEvents.trigger("composer:insert-text", markdown); - handled = true; - } - } - - if (handled || (canUpload && !plainText)) { - e.preventDefault(); - } - }, - - // ensures textarea scroll position is correct - _focusTextArea() { - schedule("afterRender", () => { - if (!this.element || this.isDestroying || this.isDestroyed) { - return; - } - - const textarea = this.element.querySelector("textarea.d-editor-input"); - if (!textarea) { - return; - } - - textarea.blur(); - textarea.focus(); - }); + this._$textarea.attr("dir", newDir).focus(); }, @action diff --git a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js new file mode 100644 index 00000000000..ce66fc1d44b --- /dev/null +++ b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js @@ -0,0 +1,289 @@ +import Mixin from "@ember/object/mixin"; +import toMarkdown from "discourse/lib/to-markdown"; +import { isTesting } from "discourse-common/config/environment"; +import { + clipboardHelpers, + determinePostReplaceSelection, + safariHacksDisabled, +} from "discourse/lib/utilities"; +import { next, schedule } from "@ember/runloop"; + +const isInside = (text, regex) => { + const matches = text.match(regex); + return matches && matches.length % 2; +}; + +export default Mixin.create({ + // ensures textarea scroll position is correct + _focusTextArea() { + schedule("afterRender", () => { + if (!this.element || this.isDestroying || this.isDestroyed) { + return; + } + + if (!this._textarea) { + return; + } + + 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; + + // trim trailing spaces cause **test ** would be invalid + while (end > start && /\s/.test(value.charAt(end - 1))) { + end--; + } + + if (trimLeading) { + // trim leading spaces cause ** test** would be invalid + while (end > start && /\s/.test(value.charAt(start))) { + start++; + } + } + + const selVal = value.substring(start, end); + const pre = value.slice(0, start); + const post = value.slice(end); + + if (opts && opts.lineVal) { + const lineVal = value.split("\n")[ + value.substr(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.trigger("change"); + if (opts.scroll) { + const oldScrollPos = this._$textarea.scrollTop(); + if (!this.capabilities.isIOS || safariHacksDisabled()) { + this._$textarea.focus(); + } + this._$textarea.scrollTop(oldScrollPos); + } + }); + }, + + _replaceText(oldVal, newVal, opts = {}) { + const val = this.value; + const needleStart = val.indexOf(oldVal); + + if (needleStart === -1) { + // Nothing to replace. + return; + } + + // Determine post-replace selection. + const newSelection = determinePostReplaceSelection({ + selection: { + start: this._textarea.selectionStart, + end: this._textarea.selectionEnd, + }, + needle: { start: needleStart, end: needleStart + oldVal.length }, + replacement: { start: needleStart, end: needleStart + newVal.length }, + }); + + if (opts.index && opts.regex) { + let i = -1; + const newValue = val.replace(opts.regex, (match) => { + i++; + return i === opts.index ? newVal : match; + }); + this.set("value", newValue); + } else { + // Replace value (side effect: cursor at the end). + this.set("value", val.replace(oldVal, newVal)); + } + + if (opts.forceFocus || this._$textarea.is(":focus")) { + // Restore cursor. + this._selectText( + newSelection.start, + newSelection.end - newSelection.start + ); + } + }, + + _addBlock(sel, text) { + text = (text || "").trim(); + if (text.length === 0) { + return; + } + + let pre = sel.pre; + let post = sel.value + sel.post; + + if (pre.length > 0) { + pre = pre.replace(/\n*$/, "\n\n"); + } + + if (post.length > 0) { + post = post.replace(/^\n*/, "\n\n"); + } else { + post = "\n"; + } + + const value = pre + text + post; + + this.set("value", value); + + this._$textarea.val(value); + this._$textarea.prop("selectionStart", (pre + text).length + 2); + this._$textarea.prop("selectionEnd", (pre + text).length + 2); + + this._focusTextArea(); + }, + + _addText(sel, text, options) { + if (options && options.ensureSpace) { + if ((sel.pre + "").length > 0) { + if (!sel.pre.match(/\s$/)) { + text = " " + text; + } + } + if ((sel.post + "").length > 0) { + if (!sel.post.match(/^\s/)) { + text = text + " "; + } + } + } + + const insert = `${sel.pre}${text}`; + const value = `${insert}${sel.post}`; + this.set("value", value); + this._$textarea.val(value); + this._$textarea.prop("selectionStart", insert.length); + this._$textarea.prop("selectionEnd", insert.length); + next(() => this._$textarea.trigger("change")); + this._focusTextArea(); + }, + + _extractTable(text) { + if (text.endsWith("\n")) { + text = text.substring(0, text.length - 1); + } + + text = text.split(""); + let cell = false; + text.forEach((char, index) => { + if (char === "\n" && cell) { + text[index] = "\r"; + } + if (char === '"') { + text[index] = ""; + cell = !cell; + } + }); + + let rows = text.join("").replace(/\r/g, "
").split("\n"); + + if (rows.length > 1) { + const columns = rows.map((r) => r.split("\t").length); + const isTable = + columns.reduce((a, b) => a && columns[0] === b && b > 1) && + !(columns[0] === 2 && rows[0].split("\t")[0].match(/^•$|^\d+.$/)); // to skip tab delimited lists + + if (isTable) { + const splitterRow = [...Array(columns[0])].map(() => "---").join("\t"); + rows.splice(1, 0, splitterRow); + + return ( + "|" + rows.map((r) => r.split("\t").join("|")).join("|\n|") + "|\n" + ); + } + } + return null; + }, + + paste(e) { + if (!this._$textarea.is(":focus") && !isTesting()) { + return; + } + + const isComposer = $(this.composerFocusSelector).is(":focus"); + let { clipboard, canPasteHtml, canUpload } = clipboardHelpers(e, { + siteSettings: this.siteSettings, + canUpload: isComposer, + }); + + let plainText = clipboard.getData("text/plain"); + let html = clipboard.getData("text/html"); + let handled = false; + + const { pre, lineVal } = this._getSelected(null, { lineVal: true }); + const isInlinePasting = pre.match(/[^\n]$/); + const isCodeBlock = isInside(pre, /(^|\n)```/g); + + if ( + plainText && + this.siteSettings.enable_rich_text_paste && + !isInlinePasting && + !isCodeBlock + ) { + plainText = plainText.replace(/\r/g, ""); + const table = this._extractTable(plainText); + if (table) { + this.appEvents.trigger("composer:insert-text", table); + handled = true; + } + } + + if (canPasteHtml && plainText) { + if (isInlinePasting) { + canPasteHtml = !( + lineVal.match(/^```/) || + isInside(pre, /`/g) || + lineVal.match(/^ /) + ); + } else { + canPasteHtml = !isCodeBlock; + } + } + + if (canPasteHtml && !handled) { + let markdown = toMarkdown(html); + + if (!plainText || plainText.length < markdown.length) { + if (isInlinePasting) { + markdown = markdown.replace(/^#+/, "").trim(); + markdown = pre.match(/\S$/) ? ` ${markdown}` : markdown; + } + + this.appEvents.trigger("composer:insert-text", markdown); + handled = true; + } + } + + if (handled || (canUpload && !plainText)) { + e.preventDefault(); + } + }, +});