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}}
@isEditorFocused={{this.isEditorFocused}}
@initialFilter={{this.emojiFilter}}
@emojiSelected={{action "emojiSelected"}}
@emojiSelected={{this.textManipulation.emojiSelected}}
@onEmojiPickerClose={{this.onEmojiPickerClose}}
/>

View File

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

View File

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

View File

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