DEV: refactor textarea text manipulation mixin (#29294)

Refactor of the TextareaTextManipulation from a Mixin to a native class
This commit is contained in:
Renato Atilio 2024-10-22 17:20:11 -03:00 committed by GitHub
parent 72f57524b4
commit 5d1e67b3e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 218 additions and 142 deletions

View File

@ -101,6 +101,6 @@
@isActive={{this.emojiPickerIsActive}} @isActive={{this.emojiPickerIsActive}}
@isEditorFocused={{this.isEditorFocused}} @isEditorFocused={{this.isEditorFocused}}
@initialFilter={{this.emojiFilter}} @initialFilter={{this.emojiFilter}}
@emojiSelected={{action "emojiSelected"}} @emojiSelected={{this.textManipulation.emojiSelected}}
@onEmojiPickerClose={{this.onEmojiPickerClose}} @onEmojiPickerClose={{this.onEmojiPickerClose}}
/> />

View File

@ -1,5 +1,6 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { action, computed } from "@ember/object"; import { action, computed } from "@ember/object";
import { getOwner } from "@ember/owner";
import { schedule, scheduleOnce } from "@ember/runloop"; import { schedule, scheduleOnce } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import ItsATrap from "@discourse/itsatrap"; import ItsATrap from "@discourse/itsatrap";
@ -21,14 +22,14 @@ import { linkSeenMentions } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes"; import { loadOneboxes } from "discourse/lib/load-oneboxes";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import { siteDir } from "discourse/lib/text-direction"; import { siteDir } from "discourse/lib/text-direction";
import TextareaTextManipulation, {
getHead,
} from "discourse/lib/textarea-text-manipulation";
import { import {
caretPosition, caretPosition,
inCodeBlock, inCodeBlock,
translateModKey, translateModKey,
} from "discourse/lib/utilities"; } from "discourse/lib/utilities";
import TextareaTextManipulation, {
getHead,
} from "discourse/mixins/textarea-text-manipulation";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import deprecated from "discourse-common/lib/deprecated"; import deprecated from "discourse-common/lib/deprecated";
@ -233,12 +234,12 @@ export function onToolbarCreate(func) {
} }
@classNames("d-editor") @classNames("d-editor")
export default class DEditor extends Component.extend( export default class DEditor extends Component {
TextareaTextManipulation
) {
@service("emoji-store") emojiStore; @service("emoji-store") emojiStore;
@service modal; @service modal;
textManipulation;
ready = false; ready = false;
lastSel = null; lastSel = null;
showLink = true; showLink = true;
@ -246,7 +247,6 @@ export default class DEditor extends Component.extend(
emojiFilter = ""; emojiFilter = "";
isEditorFocused = false; isEditorFocused = false;
processPreview = true; processPreview = true;
composerFocusSelector = "#reply-control .d-editor-input";
morphingOptions = { morphingOptions = {
beforeAttributeUpdated: (element, attributeName) => { beforeAttributeUpdated: (element, attributeName) => {
// Don't morph the open attribute of <details> elements // Don't morph the open attribute of <details> elements
@ -309,6 +309,15 @@ export default class DEditor extends Component.extend(
this._textarea = this.element.querySelector("textarea.d-editor-input"); this._textarea = this.element.querySelector("textarea.d-editor-input");
this._$textarea = $(this._textarea); this._$textarea = $(this._textarea);
this.set(
"textManipulation",
new TextareaTextManipulation(getOwner(this), {
markdownOptions: this.markdownOptions,
textarea: this._textarea,
})
);
this._applyEmojiAutocomplete(this._$textarea); this._applyEmojiAutocomplete(this._$textarea);
this._applyHashtagAutocomplete(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("tab", () =>
this._itsatrap.bind("shift+tab", () => this.indentSelection("left")); this.textManipulation.indentSelection("right")
);
this._itsatrap.bind("shift+tab", () =>
this.textManipulation.indentSelection("left")
);
this._itsatrap.bind(`${PLATFORM_KEY_MODIFIER}+shift+.`, () => this._itsatrap.bind(`${PLATFORM_KEY_MODIFIER}+shift+.`, () =>
this.send("insertCurrentTime") this.send("insertCurrentTime")
); );
@ -366,6 +379,8 @@ export default class DEditor extends Component.extend(
this.onBeforeInputSmartList this.onBeforeInputSmartList
); );
this._textarea.addEventListener("input", this.onInputSmartList); this._textarea.addEventListener("input", this.onInputSmartList);
this.element.addEventListener("paste", this.textManipulation.paste);
} }
// disable clicking on links in the preview // disable clicking on links in the preview
@ -374,13 +389,25 @@ export default class DEditor extends Component.extend(
.addEventListener("click", this._handlePreviewLinkClick); .addEventListener("click", this._handlePreviewLinkClick);
if (this.composerEvents) { if (this.composerEvents) {
this.appEvents.on("composer:insert-block", this, "insertBlock"); this.appEvents.on(
this.appEvents.on("composer:insert-text", this, "insertText"); "composer:insert-block",
this.appEvents.on("composer:replace-text", this, "replaceText"); 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:apply-surround", this, "_applySurround");
this.appEvents.on( this.appEvents.on(
"composer:indent-selected-text", "composer:indent-selected-text",
this, this.textManipulation,
"indentSelection" "indentSelection"
); );
} }
@ -396,7 +423,7 @@ export default class DEditor extends Component.extend(
@bind @bind
onInputSmartList() { onInputSmartList() {
if (this.handleSmartListAutocomplete) { if (this.handleSmartListAutocomplete) {
this.maybeContinueList(); this.textManipulation.maybeContinueList();
} }
this.handleSmartListAutocomplete = false; this.handleSmartListAutocomplete = false;
} }
@ -432,13 +459,25 @@ export default class DEditor extends Component.extend(
@on("willDestroyElement") @on("willDestroyElement")
_shutDown() { _shutDown() {
if (this.composerEvents) { if (this.composerEvents) {
this.appEvents.off("composer:insert-block", this, "insertBlock"); this.appEvents.off(
this.appEvents.off("composer:insert-text", this, "insertText"); "composer:insert-block",
this.appEvents.off("composer:replace-text", this, "replaceText"); 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:apply-surround", this, "_applySurround");
this.appEvents.off( this.appEvents.off(
"composer:indent-selected-text", "composer:indent-selected-text",
this, this.textManipulation,
"indentSelection" "indentSelection"
); );
} }
@ -460,9 +499,7 @@ export default class DEditor extends Component.extend(
this._previewMutationObserver?.disconnect(); this._previewMutationObserver?.disconnect();
if (isTesting()) { this.element.removeEventListener("paste", this.textManipulation.paste);
this.element.removeEventListener("paste", this.paste);
}
this._cachedCookFunction = null; this._cachedCookFunction = null;
} }
@ -590,7 +627,11 @@ export default class DEditor extends Component.extend(
{ {
afterComplete: (value) => { afterComplete: (value) => {
this.set("value", 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: ":", key: ":",
afterComplete: (text) => { afterComplete: (text) => {
this.set("value", text); this.set("value", text);
schedule("afterRender", this, this.focusTextArea); schedule(
"afterRender",
this.textManipulation,
this.textManipulation.blurAndFocus
);
}, },
onKeyUp: (text, cp) => { onKeyUp: (text, cp) => {
@ -720,7 +765,7 @@ export default class DEditor extends Component.extend(
_applyList(sel, head, exampleKey, opts) { _applyList(sel, head, exampleKey, opts) {
if (sel.value.includes("\n")) { if (sel.value.includes("\n")) {
this.applySurround(sel, head, "", exampleKey, opts); this.textManipulation.applySurround(sel, head, "", exampleKey, opts);
} else { } else {
const [hval, hlen] = getHead(head); const [hval, hlen] = getHead(head);
if (sel.start === sel.end) { if (sel.start === sel.end) {
@ -737,13 +782,13 @@ export default class DEditor extends Component.extend(
const post = trimmedPost.length ? `\n\n${trimmedPost}` : trimmedPost; const post = trimmedPost.length ? `\n\n${trimmedPost}` : trimmedPost;
this.set("value", `${preLines}${number}${post}`); this.set("value", `${preLines}${number}${post}`);
this.selectText(preLines.length, number.length); this.textManipulation.selectText(preLines.length, number.length);
} }
} }
_applySurround(head, tail, exampleKey, opts) { _applySurround(head, tail, exampleKey, opts) {
const selected = this.getSelected(); const selected = this.textManipulation.getSelected();
this.applySurround(selected, head, tail, exampleKey, opts); this.textManipulation.applySurround(selected, head, tail, exampleKey, opts);
} }
_toggleDirection() { _toggleDirection() {
@ -802,21 +847,27 @@ export default class DEditor extends Component.extend(
} }
newToolbarEvent(trimLeading) { newToolbarEvent(trimLeading) {
const selected = this.getSelected(trimLeading); const selected = this.textManipulation.getSelected(trimLeading);
return { return {
selected, selected,
selectText: (from, length) => selectText: (from, length) =>
this.selectText(from, length, { scroll: false }), this.textManipulation.selectText(from, length, { scroll: false }),
applySurround: (head, tail, exampleKey, opts) => applySurround: (head, tail, exampleKey, opts) =>
this.applySurround(selected, head, tail, exampleKey, opts), this.textManipulation.applySurround(
selected,
head,
tail,
exampleKey,
opts
),
applyList: (head, exampleKey, opts) => applyList: (head, exampleKey, opts) =>
this._applyList(selected, head, exampleKey, opts), this._applyList(selected, head, exampleKey, opts),
formatCode: (...args) => this.send("formatCode", args), formatCode: (...args) => this.send("formatCode", args),
addText: (text) => this.addText(selected, text), addText: (text) => this.textManipulation.addText(selected, text),
getText: () => this.value, getText: () => this.value,
toggleDirection: () => this._toggleDirection(), toggleDirection: () => this._toggleDirection(),
replaceText: (oldVal, newVal, opts) => 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; return;
} }
const sel = this.getSelected("", { lineVal: true }); const sel = this.textManipulation.getSelected("", { lineVal: true });
const selValue = sel.value; const selValue = sel.value;
const hasNewLine = selValue.includes("\n"); const hasNewLine = selValue.includes("\n");
const isBlankLine = sel.lineVal.trim().length === 0; const isBlankLine = sel.lineVal.trim().length === 0;
@ -882,20 +933,33 @@ export default class DEditor extends Component.extend(
if (isFourSpacesIndent) { if (isFourSpacesIndent) {
const example = I18n.t(`composer.code_text`); const example = I18n.t(`composer.code_text`);
this.set("value", `${sel.pre} ${example}${sel.post}`); 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 { } else {
return this.applySurround(sel, "```\n", "\n```", "paste_code_text"); return this.textManipulation.applySurround(
sel,
"```\n",
"\n```",
"paste_code_text"
);
} }
} else { } else {
return this.applySurround(sel, "`", "`", "code_title"); return this.textManipulation.applySurround(sel, "`", "`", "code_title");
} }
} else { } else {
if (isFourSpacesIndent) { if (isFourSpacesIndent) {
return this.applySurround(sel, " ", "", "code_text"); return this.textManipulation.applySurround(
sel,
" ",
"",
"code_text"
);
} else { } else {
const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : ""; const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : "";
const postNewline = sel.post[0] !== "\n" ? "\n" : ""; const postNewline = sel.post[0] !== "\n" ? "\n" : "";
return this.addText( return this.textManipulation.addText(
sel, sel,
`${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}` `${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}`
); );
@ -905,12 +969,15 @@ export default class DEditor extends Component.extend(
@action @action
insertCurrentTime() { insertCurrentTime() {
const sel = this.getSelected("", { lineVal: true }); const sel = this.textManipulation.getSelected("", { lineVal: true });
const timezone = this.currentUser.user_option.timezone; const timezone = this.currentUser.user_option.timezone;
const time = moment().format("HH:mm:ss"); const time = moment().format("HH:mm:ss");
const date = moment().format("YYYY-MM-DD"); 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 @action

View File

@ -1,6 +1,7 @@
import { action } from "@ember/object"; import { action } from "@ember/object";
import Mixin from "@ember/object/mixin"; import { setOwner } from "@ember/owner";
import { next, schedule } from "@ember/runloop"; import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { generateLinkifyFunction } from "discourse/lib/text"; import { generateLinkifyFunction } from "discourse/lib/text";
import toMarkdown from "discourse/lib/to-markdown"; import toMarkdown from "discourse/lib/to-markdown";
@ -35,52 +36,48 @@ export function getHead(head, prev) {
} }
} }
export default Mixin.create({ export default class TextareaTextManipulation {
init() { @service appEvents;
this._super(...arguments); @service siteSettings;
@service capabilities;
// fallback in the off chance someone has implemented a custom composer eventPrefix;
// which does not define this textarea;
if (!this.composerEventPrefix) {
this.composerEventPrefix = "composer";
}
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. // 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; this._cachedLinkify = linkify;
}); });
}, }
get value() {
return this.textarea.value;
}
// ensures textarea scroll position is correct // ensures textarea scroll position is correct
focusTextArea() { blurAndFocus() {
if (!this.element || this.isDestroying || this.isDestroyed) { this.textarea?.blur();
return; this.textarea?.focus();
} }
if (!this._textarea) {
return;
}
this._textarea.blur();
this._textarea.focus();
},
insertBlock(text) { insertBlock(text) {
this._addBlock(this.getSelected(), text); this._addBlock(this.getSelected(), text);
}, }
insertText(text, options) { insertText(text, options) {
this.addText(this.getSelected(), text, options); this.addText(this.getSelected(), text, options);
},
getSelected(trimLeading, opts) {
if (!this.ready || !this.element) {
return;
} }
const value = this._textarea.value; getSelected(trimLeading, opts) {
let start = this._textarea.selectionStart; const value = this.value;
let end = this._textarea.selectionEnd; let start = this.textarea.selectionStart;
let end = this.textarea.selectionEnd;
// trim trailing spaces cause **test ** would be invalid // trim trailing spaces cause **test ** would be invalid
while (end > start && /\s/.test(value.charAt(end - 1))) { while (end > start && /\s/.test(value.charAt(end - 1))) {
@ -101,33 +98,30 @@ export default Mixin.create({
if (opts && opts.lineVal) { if (opts && opts.lineVal) {
const lineVal = const lineVal =
value.split("\n")[ 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 }; return { start, end, value: selVal, pre, post, lineVal };
} else { } else {
return { start, end, value: selVal, pre, post }; return { start, end, value: selVal, pre, post };
} }
}, }
selectText(from, length, opts = { scroll: true }) { selectText(from, length, opts = { scroll: true }) {
next(() => { next(() => {
if (!this.element) { this.textarea.selectionStart = from;
return; this.textarea.selectionEnd = from + length;
}
this._textarea.selectionStart = from;
this._textarea.selectionEnd = from + length;
if (opts.scroll === true || typeof opts.scroll === "number") { if (opts.scroll === true || typeof opts.scroll === "number") {
const oldScrollPos = const oldScrollPos =
typeof opts.scroll === "number" typeof opts.scroll === "number"
? opts.scroll ? opts.scroll
: this._textarea.scrollTop; : this.textarea.scrollTop;
if (!this.capabilities.isIOS) { if (!this.capabilities.isIOS) {
this._textarea.focus(); this.textarea.focus();
} }
this._textarea.scrollTop = oldScrollPos; this.textarea.scrollTop = oldScrollPos;
} }
}); });
}, }
replaceText(oldVal, newVal, opts = {}) { replaceText(oldVal, newVal, opts = {}) {
const val = this.value; const val = this.value;
@ -141,8 +135,8 @@ export default Mixin.create({
// Determine post-replace selection. // Determine post-replace selection.
const newSelection = determinePostReplaceSelection({ const newSelection = determinePostReplaceSelection({
selection: { selection: {
start: this._textarea.selectionStart, start: this.textarea.selectionStart,
end: this._textarea.selectionEnd, end: this.textarea.selectionEnd,
}, },
needle: { start: needleStart, end: needleStart + oldVal.length }, needle: { start: needleStart, end: needleStart + oldVal.length },
replacement: { start: needleStart, end: needleStart + newVal.length }, replacement: { start: needleStart, end: needleStart + newVal.length },
@ -167,7 +161,7 @@ export default Mixin.create({
} }
if ( if (
(opts.forceFocus || this._$textarea.is(":focus")) && (opts.forceFocus || this.textarea === document.activeElement) &&
!opts.skipNewSelection !opts.skipNewSelection
) { ) {
// Restore cursor. // Restore cursor.
@ -176,7 +170,7 @@ export default Mixin.create({
newSelection.end - newSelection.start newSelection.end - newSelection.start
); );
} }
}, }
applySurround(sel, head, tail, exampleKey, opts) { applySurround(sel, head, tail, exampleKey, opts) {
const pre = sel.pre; const pre = sel.pre;
@ -240,7 +234,7 @@ export default Mixin.create({
} }
} }
} }
}, }
// 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) {
@ -280,7 +274,7 @@ export default Mixin.create({
return result; return result;
}) })
.join("\n"); .join("\n");
}, }
_addBlock(sel, text) { _addBlock(sel, text) {
text = (text || "").trim(); text = (text || "").trim();
@ -312,9 +306,9 @@ export default Mixin.create({
} }
this._insertAt(start, end, text); this._insertAt(start, end, text);
this._textarea.setSelectionRange(start + text.length, start + text.length); this.textarea.setSelectionRange(start + text.length, start + text.length);
schedule("afterRender", this, this.focusTextArea); schedule("afterRender", this, this.blurAndFocus);
}, }
addText(sel, text, options) { addText(sel, text, options) {
if (options && options.ensureSpace) { if (options && options.ensureSpace) {
@ -331,18 +325,18 @@ export default Mixin.create({
} }
this._insertAt(sel.start, sel.end, text); this._insertAt(sel.start, sel.end, text);
this.focusTextArea(); this.blurAndFocus();
}, }
_insertAt(start, end, text) { _insertAt(start, end, text) {
this._textarea.setSelectionRange(start, end); this.textarea.setSelectionRange(start, end);
this._textarea.focus(); this.textarea.focus();
if (start !== end && text === "") { if (start !== end && text === "") {
document.execCommand("delete", false); document.execCommand("delete", false);
} else { } else {
document.execCommand("insertText", false, text); document.execCommand("insertText", false, text);
} }
}, }
extractTable(text) { extractTable(text) {
if (text.endsWith("\n")) { if (text.endsWith("\n")) {
@ -379,17 +373,16 @@ export default Mixin.create({
} }
} }
return null; return null;
}, }
isInside(text, regex) { isInside(text, regex) {
const matches = text.match(regex); const matches = text.match(regex);
return matches && matches.length % 2; return matches && matches.length % 2;
}, }
@bind @bind
paste(e) { paste(e) {
const isComposer = const isComposer = this.textarea === e.target;
document.querySelector(this.composerFocusSelector) === e.target;
if (!isComposer && !isTesting()) { if (!isComposer && !isTesting()) {
return; return;
@ -418,11 +411,8 @@ export default Mixin.create({
plainText = plainText.replace(/\r/g, ""); plainText = plainText.replace(/\r/g, "");
const table = this.extractTable(plainText); const table = this.extractTable(plainText);
if (table) { if (table) {
this.composerEventPrefix this.eventPrefix
? this.appEvents.trigger( ? this.appEvents.trigger(`${this.eventPrefix}:insert-text`, table)
`${this.composerEventPrefix}:insert-text`,
table
)
: this.insertText(table); : this.insertText(table);
handled = true; handled = true;
} }
@ -475,9 +465,9 @@ export default Mixin.create({
} }
if (isComposer) { if (isComposer) {
this.composerEventPrefix this.eventPrefix
? this.appEvents.trigger( ? this.appEvents.trigger(
`${this.composerEventPrefix}:insert-text`, `${this.eventPrefix}:insert-text`,
markdown markdown
) )
: this.insertText(markdown); : this.insertText(markdown);
@ -489,7 +479,7 @@ export default Mixin.create({
if (handled || (canUpload && !plainText)) { if (handled || (canUpload && !plainText)) {
e.preventDefault(); e.preventDefault();
} }
}, }
/** /**
* Removes the provided char from the provided str up * Removes the provided char from the provided str up
@ -506,7 +496,7 @@ export default Mixin.create({
} }
} }
return str; return str;
}, }
_updateListNumbers(text, currentNumber) { _updateListNumbers(text, currentNumber) {
return text return text
@ -523,12 +513,12 @@ export default Mixin.create({
return line; return line;
}) })
.join("\n"); .join("\n");
}, }
@bind @bind
maybeContinueList() { maybeContinueList() {
const offset = caretPosition(this._textarea); const offset = caretPosition(this.textarea);
const text = this._textarea.value; const text = this.value;
const lines = text.substring(0, offset).split("\n"); const lines = text.substring(0, offset).split("\n");
// Only continue if the previous line was a list item. // Only continue if the previous line was a list item.
@ -610,7 +600,7 @@ export default Mixin.create({
numericBullet + 1 numericBullet + 1
); );
autocompletePrefix += autocompletePostfix; autocompletePrefix += autocompletePostfix;
scrollPosition = this._textarea.scrollTop; scrollPosition = this.textarea.scrollTop;
this.replaceText( this.replaceText(
text.substring(offset, offset + autocompletePrefix.length), text.substring(offset, offset + autocompletePrefix.length),
@ -636,7 +626,7 @@ export default Mixin.create({
); );
this.selectText(offsetWithoutPrefix, 0); this.selectText(offsetWithoutPrefix, 0);
} }
}, }
@bind @bind
indentSelection(direction) { indentSelection(direction) {
@ -711,7 +701,7 @@ export default Mixin.create({
this.replaceText(value, newValue, { skipNewSelection: true }); this.replaceText(value, newValue, { skipNewSelection: true });
this.selectText(this.value.indexOf(newValue), newValue.length); this.selectText(this.value.indexOf(newValue), newValue.length);
} }
}, }
@action @action
emojiSelected(code) { emojiSelected(code) {
@ -732,9 +722,9 @@ export default Mixin.create({
`${code}:` `${code}:`
); );
} }
}, }
isInsideCodeFence(beforeText) { isInsideCodeFence(beforeText) {
return this.isInside(beforeText, /(^|\n)```/g); return this.isInside(beforeText, /(^|\n)```/g);
}, }
}); }

View File

@ -3,52 +3,52 @@ import EmberObject from "@ember/object";
import { setOwner } from "@ember/owner"; import { setOwner } from "@ember/owner";
import { next, schedule } from "@ember/runloop"; import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service"; 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 // This class sole purpose is to provide a way to interact with the textarea
// using the existing TextareaTextManipulation mixin without using it directly // using the existing TextareaTextManipulation mixin without using it directly
// in the composer component. It will make future migration easier. // in the composer component. It will make future migration easier.
export default class TextareaInteractor extends EmberObject.extend( export default class TextareaInteractor extends EmberObject {
TextareaTextManipulation
) {
@service capabilities; @service capabilities;
@service site; @service site;
@service siteSettings; @service siteSettings;
textManipulation;
constructor(owner, textarea) { constructor(owner, textarea) {
super(...arguments); super(...arguments);
setOwner(this, owner); setOwner(this, owner);
this.textarea = textarea; this.textarea = textarea;
this._textarea = textarea; this.element = textarea;
this.element = this._textarea;
this.ready = true;
this.composerFocusSelector = `#${textarea.id}`;
this.init(); // mixin init wouldn't be called otherwise this.textManipulation = new TextareaTextManipulation(owner, {
this.composerEventPrefix = null; // we don't need app events textarea,
// we don't need app events
eventPrefix: null,
});
// paste is using old native ember events defined on composer // 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()); registerDestructor(this, (instance) => instance.teardown());
} }
teardown() { teardown() {
this.textarea.removeEventListener("paste", this.paste); this.textarea.removeEventListener("paste", this.textManipulation.paste);
} }
set value(value) { set value(value) {
this._textarea.value = value; this.textarea.value = value;
const event = new Event("input", { const event = new Event("input", {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
this._textarea.dispatchEvent(event); this.textarea.dispatchEvent(event);
} }
blur() { blur() {
next(() => { next(() => {
schedule("afterRender", () => { schedule("afterRender", () => {
this._textarea.blur(); this.textarea.blur();
}); });
}); });
} }
@ -69,19 +69,22 @@ export default class TextareaInteractor extends EmberObject.extend(
} }
if (opts.addText) { if (opts.addText) {
this.addText(this.getSelected(), opts.addText); this.textManipulation.addText(
this.textManipulation.getSelected(),
opts.addText
);
} }
this.focusTextArea(); this.textManipulation.blurAndFocus();
}); });
}); });
} }
ensureCaretAtEnd() { ensureCaretAtEnd() {
schedule("afterRender", () => { schedule("afterRender", () => {
this._textarea.setSelectionRange( this.textarea.setSelectionRange(
this._textarea.value.length, this.textarea.value.length,
this._textarea.value.length this.textarea.value.length
); );
}); });
} }
@ -90,11 +93,27 @@ export default class TextareaInteractor extends EmberObject.extend(
schedule("afterRender", () => { schedule("afterRender", () => {
// this is a quirk which forces us to `auto` first or textarea // this is a quirk which forces us to `auto` first or textarea
// won't resize // won't resize
this._textarea.style.height = "auto"; this.textarea.style.height = "auto";
// +1 is to workaround a rounding error visible on electron // +1 is to workaround a rounding error visible on electron
// causing scrollbars to show when they shouldnt // causing scrollbars to show when they shouldnt
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);
}
} }