DEV: Extract textarea text manipulation to mixin (#14201)

This commit is contained in:
Mark VanLandingham 2021-08-31 14:36:26 -05:00 committed by GitHub
parent 2bf81592ec
commit eba317b74e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 308 additions and 308 deletions

View File

@ -1,18 +1,12 @@
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { import { caretPosition, inCodeBlock } from "discourse/lib/utilities";
caretPosition,
clipboardHelpers,
determinePostReplaceSelection,
inCodeBlock,
safariHacksDisabled,
} from "discourse/lib/utilities";
import discourseComputed, { import discourseComputed, {
observes, observes,
on, on,
} from "discourse-common/utils/decorators"; } from "discourse-common/utils/decorators";
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; 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 Component from "@ember/component";
import I18n from "I18n"; import I18n from "I18n";
import Mousetrap from "mousetrap"; 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 { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import { siteDir } from "discourse/lib/text-direction"; import { siteDir } from "discourse/lib/text-direction";
import toMarkdown from "discourse/lib/to-markdown";
import { translations } from "pretty-text/emoji/data"; import { translations } from "pretty-text/emoji/data";
import { wantsNewWindow } from "discourse/lib/intercept-click"; import { wantsNewWindow } from "discourse/lib/intercept-click";
import { action } from "@ember/object"; 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 // Our head can be a static string or a function that returns a string
// based on input (like for numbered lists). // based on input (like for numbered lists).
@ -64,11 +58,6 @@ const FOUR_SPACES_INDENT = "4-spaces-indent";
let _createCallbacks = []; let _createCallbacks = [];
const isInside = (text, regex) => {
const matches = text.match(regex);
return matches && matches.length % 2;
};
class Toolbar { class Toolbar {
constructor(opts) { constructor(opts) {
const { siteSettings } = opts; const { siteSettings } = opts;
@ -245,7 +234,7 @@ export function onToolbarCreate(func) {
addToolbarCallback(func); addToolbarCallback(func);
} }
export default Component.extend({ export default Component.extend(TextareaTextManipulation, {
classNames: ["d-editor"], classNames: ["d-editor"],
ready: false, ready: false,
lastSel: null, lastSel: null,
@ -255,6 +244,7 @@ export default Component.extend({
emojiStore: service("emoji-store"), emojiStore: service("emoji-store"),
isEditorFocused: false, isEditorFocused: false,
processPreview: true, processPreview: true,
composerFocusSelector: "#reply-control .d-editor-input",
@discourseComputed("placeholder") @discourseComputed("placeholder")
placeholderTranslated(placeholder) { placeholderTranslated(placeholder) {
@ -268,7 +258,7 @@ export default Component.extend({
this.set("ready", true); this.set("ready", true);
if (this.autofocus) { if (this.autofocus) {
this.element.querySelector("textarea").focus(); this._textarea.focus();
} }
}, },
@ -281,15 +271,14 @@ export default Component.extend({
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
const $editorInput = $(this.element.querySelector(".d-editor-input")); this._textarea = this.element.querySelector("textarea.d-editor-input");
this._applyEmojiAutocomplete($editorInput); this._$textarea = $(this._textarea);
this._applyCategoryHashtagAutocomplete($editorInput); this._applyEmojiAutocomplete(this._$textarea);
this._applyCategoryHashtagAutocomplete(this._$textarea);
scheduleOnce("afterRender", this, this._readyNow); scheduleOnce("afterRender", this, this._readyNow);
this._mouseTrap = new Mousetrap( this._mouseTrap = new Mousetrap(this._textarea);
this.element.querySelector(".d-editor-input")
);
const shortcuts = this.get("toolbar.shortcuts"); const shortcuts = this.get("toolbar.shortcuts");
Object.keys(shortcuts).forEach((sc) => { 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") @on("willDestroyElement")
_shutDown() { _shutDown() {
if (this.composerEvents) { if (this.composerEvents) {
@ -479,7 +460,7 @@ export default Component.extend({
_applyCategoryHashtagAutocomplete() { _applyCategoryHashtagAutocomplete() {
const siteSettings = this.siteSettings; const siteSettings = this.siteSettings;
$(this.element.querySelector(".d-editor-input")).autocomplete({ this._$textarea.autocomplete({
template: findRawTemplate("category-tag-autocomplete"), template: findRawTemplate("category-tag-autocomplete"),
key: "#", key: "#",
afterComplete: (value) => { afterComplete: (value) => {
@ -501,12 +482,12 @@ export default Component.extend({
}); });
}, },
_applyEmojiAutocomplete($editorInput) { _applyEmojiAutocomplete($textarea) {
if (!this.siteSettings.enable_emoji) { if (!this.siteSettings.enable_emoji) {
return; return;
} }
$editorInput.autocomplete({ $textarea.autocomplete({
template: findRawTemplate("emoji-selector-autocomplete"), template: findRawTemplate("emoji-selector-autocomplete"),
key: ":", key: ":",
afterComplete: (text) => { afterComplete: (text) => {
@ -533,7 +514,7 @@ export default Component.extend({
this.emojiStore.track(v.code); this.emojiStore.track(v.code);
return `${v.code}:`; return `${v.code}:`;
} else { } else {
$editorInput.autocomplete({ cancel: true }); $textarea.autocomplete({ cancel: true });
this.set("emojiPickerIsActive", true); this.set("emojiPickerIsActive", true);
schedule("afterRender", () => { 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 // perform the same operation over many lines of text
_getMultilineContents(lines, head, hval, hlen, tail, tlen, opts) { _getMultilineContents(lines, head, hval, hlen, tail, tlen, opts) {
let operation = OP.NONE; 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, "<br>").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() { _toggleDirection() {
const $textArea = $(".d-editor-input"); let currentDir = this._$textarea.attr("dir")
let currentDir = $textArea.attr("dir") ? $textArea.attr("dir") : siteDir(), ? this._$textarea.attr("dir")
: siteDir(),
newDir = currentDir === "ltr" ? "rtl" : "ltr"; newDir = currentDir === "ltr" ? "rtl" : "ltr";
$textArea.attr("dir", newDir).focus(); this._$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();
});
}, },
@action @action

View File

@ -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, "<br>").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();
}
},
});