diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js
index c6ebb29af0..1d337cb0ba 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 0000000000..ce66fc1d44
--- /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();
+ }
+ },
+});