DEV: Extract textarea text manipulation to mixin (#14201)
This commit is contained in:
parent
2bf81592ec
commit
eba317b74e
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
Loading…
Reference in New Issue