DEV: Move text area surround code out of d-editor (#15950)

This commit moves _getMultilineContents and _applySurround into
TextareaTextManipulation, so other text area components using
that mixin can benefit from them (such as the chat composer).

It also creates a public function wrapper for many TextareaTextManipulation
functions that should not have underscore prefixes because they are
used outside the file. Will make follow-up PRs for each plugin/theme using
those functions then a final follow-up core PR to fix these up.
This commit is contained in:
Martin Brennan 2022-02-18 08:56:37 +10:00 committed by GitHub
parent c92e62a271
commit 6a5ef27eaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 222 additions and 163 deletions

View File

@ -35,29 +35,15 @@ import { siteDir } from "discourse/lib/text-direction";
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"; import TextareaTextManipulation, {
getHead,
// Our head can be a static string or a function that returns a string } from "discourse/mixins/textarea-text-manipulation";
// based on input (like for numbered lists).
function getHead(head, prev) {
if (typeof head === "string") {
return [head, head.length];
} else {
return getHead(head(prev));
}
}
function getButtonLabel(labelKey, defaultLabel) { function getButtonLabel(labelKey, defaultLabel) {
// use the Font Awesome icon if the label matches the default // use the Font Awesome icon if the label matches the default
return I18n.t(labelKey) === defaultLabel ? null : labelKey; return I18n.t(labelKey) === defaultLabel ? null : labelKey;
} }
const OP = {
NONE: 0,
REMOVED: 1,
ADDED: 2,
};
const FOUR_SPACES_INDENT = "4-spaces-indent"; const FOUR_SPACES_INDENT = "4-spaces-indent";
let _createCallbacks = []; let _createCallbacks = [];
@ -285,8 +271,8 @@ export default Component.extend(TextareaTextManipulation, {
}); });
}); });
this._itsatrap.bind("tab", () => this._indentSelection("right")); this._itsatrap.bind("tab", () => this.indentSelection("right"));
this._itsatrap.bind("shift+tab", () => this._indentSelection("left")); this._itsatrap.bind("shift+tab", () => this.indentSelection("left"));
// disable clicking on links in the preview // disable clicking on links in the preview
this.element this.element
@ -294,13 +280,13 @@ export default Component.extend(TextareaTextManipulation, {
.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("composer:insert-block", this, "insertBlock");
this.appEvents.on("composer:insert-text", this, "_insertText"); this.appEvents.on("composer:insert-text", this, "insertText");
this.appEvents.on("composer:replace-text", this, "_replaceText"); this.appEvents.on("composer:replace-text", this, "replaceText");
this.appEvents.on( this.appEvents.on(
"composer:indent-selected-text", "composer:indent-selected-text",
this, this,
"_indentSelection" "indentSelection"
); );
} }
@ -338,13 +324,13 @@ export default Component.extend(TextareaTextManipulation, {
@on("willDestroyElement") @on("willDestroyElement")
_shutDown() { _shutDown() {
if (this.composerEvents) { if (this.composerEvents) {
this.appEvents.off("composer:insert-block", this, "_insertBlock"); this.appEvents.off("composer:insert-block", this, "insertBlock");
this.appEvents.off("composer:insert-text", this, "_insertText"); this.appEvents.off("composer:insert-text", this, "insertText");
this.appEvents.off("composer:replace-text", this, "_replaceText"); this.appEvents.off("composer:replace-text", this, "replaceText");
this.appEvents.off( this.appEvents.off(
"composer:indent-selected-text", "composer:indent-selected-text",
this, this,
"_indentSelection" "indentSelection"
); );
} }
@ -477,7 +463,7 @@ export default Component.extend(TextareaTextManipulation, {
key: "#", key: "#",
afterComplete: (value) => { afterComplete: (value) => {
this.set("value", value); this.set("value", value);
schedule("afterRender", this, this._focusTextArea); schedule("afterRender", this, this.focusTextArea);
}, },
transformComplete: (obj) => { transformComplete: (obj) => {
return obj.text; return obj.text;
@ -504,7 +490,7 @@ export default Component.extend(TextareaTextManipulation, {
key: ":", key: ":",
afterComplete: (text) => { afterComplete: (text) => {
this.set("value", text); this.set("value", text);
schedule("afterRender", this, this._focusTextArea); schedule("afterRender", this, this.focusTextArea);
}, },
onKeyUp: (text, cp) => { onKeyUp: (text, cp) => {
@ -617,117 +603,9 @@ export default Component.extend(TextareaTextManipulation, {
}); });
}, },
// perform the same operation over many lines of text
_getMultilineContents(lines, head, hval, hlen, tail, tlen, opts) {
let operation = OP.NONE;
const applyEmptyLines = opts && opts.applyEmptyLines;
return lines
.map((l) => {
if (!applyEmptyLines && l.length === 0) {
return l;
}
if (
operation !== OP.ADDED &&
((l.slice(0, hlen) === hval && tlen === 0) ||
(tail.length && l.slice(-tlen) === tail))
) {
operation = OP.REMOVED;
if (tlen === 0) {
const result = l.slice(hlen);
[hval, hlen] = getHead(head, hval);
return result;
} else if (l.slice(-tlen) === tail) {
const result = l.slice(hlen, -tlen);
[hval, hlen] = getHead(head, hval);
return result;
}
} else if (operation === OP.NONE) {
operation = OP.ADDED;
} else if (operation === OP.REMOVED) {
return l;
}
const result = `${hval}${l}${tail}`;
[hval, hlen] = getHead(head, hval);
return result;
})
.join("\n");
},
_applySurround(sel, head, tail, exampleKey, opts) {
const pre = sel.pre;
const post = sel.post;
const tlen = tail.length;
if (sel.start === sel.end) {
if (tlen === 0) {
return;
}
const [hval, hlen] = getHead(head);
const example = I18n.t(`composer.${exampleKey}`);
this.set("value", `${pre}${hval}${example}${tail}${post}`);
this._selectText(pre.length + hlen, example.length);
} else if (opts && !opts.multiline) {
let [hval, hlen] = getHead(head);
if (opts.useBlockMode && sel.value.split("\n").length > 1) {
hval += "\n";
hlen += 1;
tail = `\n${tail}`;
}
if (pre.slice(-hlen) === hval && post.slice(0, tail.length) === tail) {
this.set(
"value",
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tail.length)}`
);
this._selectText(sel.start - hlen, sel.value.length);
} else {
this.set("value", `${pre}${hval}${sel.value}${tail}${post}`);
this._selectText(sel.start + hlen, sel.value.length);
}
} else {
const lines = sel.value.split("\n");
let [hval, hlen] = getHead(head);
if (
lines.length === 1 &&
pre.slice(-tlen) === tail &&
post.slice(0, hlen) === hval
) {
this.set(
"value",
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tlen)}`
);
this._selectText(sel.start - hlen, sel.value.length);
} else {
const contents = this._getMultilineContents(
lines,
head,
hval,
hlen,
tail,
tlen,
opts
);
this.set("value", `${pre}${contents}${post}`);
if (lines.length === 1 && tlen > 0) {
this._selectText(sel.start + hlen, sel.value.length);
} else {
this._selectText(sel.start, contents.length);
}
}
}
},
_applyList(sel, head, exampleKey, opts) { _applyList(sel, head, exampleKey, opts) {
if (sel.value.indexOf("\n") !== -1) { if (sel.value.indexOf("\n") !== -1) {
this._applySurround(sel, head, "", exampleKey, opts); this.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) {
@ -745,7 +623,7 @@ export default Component.extend(TextareaTextManipulation, {
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.selectText(preLines.length, number.length);
} }
}, },
@ -811,16 +689,16 @@ export default Component.extend(TextareaTextManipulation, {
return; return;
} }
const selected = this._getSelected(button.trimLeading); const selected = this.getSelected(button.trimLeading);
const toolbarEvent = { const toolbarEvent = {
selected, selected,
selectText: (from, length) => selectText: (from, length) =>
this._selectText(from, length, { scroll: false }), this.selectText(from, length, { scroll: false }),
applySurround: (head, tail, exampleKey, opts) => applySurround: (head, tail, exampleKey, opts) =>
this._applySurround(selected, head, tail, exampleKey, opts), this.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),
addText: (text) => this._addText(selected, text), addText: (text) => this.addText(selected, text),
getText: () => this.value, getText: () => this.value,
toggleDirection: () => this._toggleDirection(), toggleDirection: () => this._toggleDirection(),
}; };
@ -855,7 +733,7 @@ export default Component.extend(TextareaTextManipulation, {
return; return;
} }
const sel = this._getSelected("", { lineVal: true }); const sel = this.getSelected("", { lineVal: true });
const selValue = sel.value; const selValue = sel.value;
const hasNewLine = selValue.indexOf("\n") !== -1; const hasNewLine = selValue.indexOf("\n") !== -1;
const isBlankLine = sel.lineVal.trim().length === 0; const isBlankLine = sel.lineVal.trim().length === 0;
@ -867,25 +745,20 @@ export default Component.extend(TextareaTextManipulation, {
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.selectText(sel.pre.length + 4, example.length);
} else { } else {
return this._applySurround( return this.applySurround(sel, "```\n", "\n```", "paste_code_text");
sel,
"```\n",
"\n```",
"paste_code_text"
);
} }
} else { } else {
return this._applySurround(sel, "`", "`", "code_title"); return this.applySurround(sel, "`", "`", "code_title");
} }
} else { } else {
if (isFourSpacesIndent) { if (isFourSpacesIndent) {
return this._applySurround(sel, " ", "", "code_text"); return this.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.addText(
sel, sel,
`${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}` `${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}`
); );

View File

@ -1,4 +1,5 @@
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import I18n from "I18n";
import Mixin from "@ember/object/mixin"; import Mixin from "@ember/object/mixin";
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";
@ -14,6 +15,22 @@ import { next, schedule } from "@ember/runloop";
const INDENT_DIRECTION_LEFT = "left"; const INDENT_DIRECTION_LEFT = "left";
const INDENT_DIRECTION_RIGHT = "right"; const INDENT_DIRECTION_RIGHT = "right";
const OP = {
NONE: 0,
REMOVED: 1,
ADDED: 2,
};
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
export function getHead(head, prev) {
if (typeof head === "string") {
return [head, head.length];
} else {
return getHead(head(prev));
}
}
export default Mixin.create({ export default Mixin.create({
init() { init() {
this._super(...arguments); this._super(...arguments);
@ -24,6 +41,13 @@ export default Mixin.create({
}, },
// ensures textarea scroll position is correct // ensures textarea scroll position is correct
//
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
focusTextArea() {
this._focusTextArea();
},
_focusTextArea() { _focusTextArea() {
if (!this.element || this.isDestroying || this.isDestroyed) { if (!this.element || this.isDestroying || this.isDestroyed) {
return; return;
@ -37,12 +61,30 @@ export default Mixin.create({
this._textarea.focus(); this._textarea.focus();
}, },
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
insertBlock(text) {
this._insertBlock(text);
},
_insertBlock(text) { _insertBlock(text) {
this._addBlock(this._getSelected(), text); this._addBlock(this.getSelected(), text);
},
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
insertText(text, options) {
this._insertText(text, options);
}, },
_insertText(text, options) { _insertText(text, options) {
this._addText(this._getSelected(), text, options); this._addText(this.getSelected(), text, options);
},
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
getSelected(trimLeading, opts) {
return this._getSelected(trimLeading, opts);
}, },
_getSelected(trimLeading, opts) { _getSelected(trimLeading, opts) {
@ -80,6 +122,12 @@ export default Mixin.create({
} }
}, },
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
selectText(from, length, opts = { scroll: true }) {
this._selectText(from, length, opts);
},
_selectText(from, length, opts = { scroll: true }) { _selectText(from, length, opts = { scroll: true }) {
next(() => { next(() => {
if (!this.element) { if (!this.element) {
@ -99,6 +147,12 @@ export default Mixin.create({
}); });
}, },
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
replaceText(oldVal, newVal, opts = {}) {
this._replaceText(oldVal, newVal, opts);
},
_replaceText(oldVal, newVal, opts = {}) { _replaceText(oldVal, newVal, opts = {}) {
const val = this.value; const val = this.value;
const needleStart = val.indexOf(oldVal); const needleStart = val.indexOf(oldVal);
@ -135,13 +189,127 @@ export default Mixin.create({
!opts.skipNewSelection !opts.skipNewSelection
) { ) {
// Restore cursor. // Restore cursor.
this._selectText( this.selectText(
newSelection.start, newSelection.start,
newSelection.end - newSelection.start newSelection.end - newSelection.start
); );
} }
}, },
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
applySurround(sel, head, tail, exampleKey, opts) {
this._applySurround(sel, head, tail, exampleKey, opts);
},
_applySurround(sel, head, tail, exampleKey, opts) {
const pre = sel.pre;
const post = sel.post;
const tlen = tail.length;
if (sel.start === sel.end) {
if (tlen === 0) {
return;
}
const [hval, hlen] = getHead(head);
const example = I18n.t(`composer.${exampleKey}`);
this.set("value", `${pre}${hval}${example}${tail}${post}`);
this.selectText(pre.length + hlen, example.length);
} else if (opts && !opts.multiline) {
let [hval, hlen] = getHead(head);
if (opts.useBlockMode && sel.value.split("\n").length > 1) {
hval += "\n";
hlen += 1;
tail = `\n${tail}`;
}
if (pre.slice(-hlen) === hval && post.slice(0, tail.length) === tail) {
this.set(
"value",
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tail.length)}`
);
this.selectText(sel.start - hlen, sel.value.length);
} else {
this.set("value", `${pre}${hval}${sel.value}${tail}${post}`);
this.selectText(sel.start + hlen, sel.value.length);
}
} else {
const lines = sel.value.split("\n");
let [hval, hlen] = getHead(head);
if (
lines.length === 1 &&
pre.slice(-tlen) === tail &&
post.slice(0, hlen) === hval
) {
this.set(
"value",
`${pre.slice(0, -hlen)}${sel.value}${post.slice(tlen)}`
);
this.selectText(sel.start - hlen, sel.value.length);
} else {
const contents = this._getMultilineContents(
lines,
head,
hval,
hlen,
tail,
tlen,
opts
);
this.set("value", `${pre}${contents}${post}`);
if (lines.length === 1 && tlen > 0) {
this.selectText(sel.start + hlen, sel.value.length);
} else {
this.selectText(sel.start, contents.length);
}
}
}
},
// perform the same operation over many lines of text
_getMultilineContents(lines, head, hval, hlen, tail, tlen, opts) {
let operation = OP.NONE;
const applyEmptyLines = opts && opts.applyEmptyLines;
return lines
.map((l) => {
if (!applyEmptyLines && l.length === 0) {
return l;
}
if (
operation !== OP.ADDED &&
((l.slice(0, hlen) === hval && tlen === 0) ||
(tail.length && l.slice(-tlen) === tail))
) {
operation = OP.REMOVED;
if (tlen === 0) {
const result = l.slice(hlen);
[hval, hlen] = getHead(head, hval);
return result;
} else if (l.slice(-tlen) === tail) {
const result = l.slice(hlen, -tlen);
[hval, hlen] = getHead(head, hval);
return result;
}
} else if (operation === OP.NONE) {
operation = OP.ADDED;
} else if (operation === OP.REMOVED) {
return l;
}
const result = `${hval}${l}${tail}`;
[hval, hlen] = getHead(head, hval);
return result;
})
.join("\n");
},
_addBlock(sel, text) { _addBlock(sel, text) {
text = (text || "").trim(); text = (text || "").trim();
if (text.length === 0) { if (text.length === 0) {
@ -172,6 +340,12 @@ export default Mixin.create({
schedule("afterRender", this, this._focusTextArea); schedule("afterRender", this, this._focusTextArea);
}, },
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
addText(sel, text, options) {
this._addText(sel, text, options);
},
_addText(sel, text, options) { _addText(sel, text, options) {
if (options && options.ensureSpace) { if (options && options.ensureSpace) {
if ((sel.pre + "").length > 0) { if ((sel.pre + "").length > 0) {
@ -196,6 +370,12 @@ export default Mixin.create({
this._focusTextArea(); this._focusTextArea();
}, },
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
extractTable(text) {
return this._extractTable(text);
},
_extractTable(text) { _extractTable(text) {
if (text.endsWith("\n")) { if (text.endsWith("\n")) {
text = text.substring(0, text.length - 1); text = text.substring(0, text.length - 1);
@ -233,6 +413,12 @@ export default Mixin.create({
return null; return null;
}, },
// TODO (martin) clean up this indirection, functions used outside this
// file should not be prefixed with lowercase
isInside(text, regex) {
return this._isInside(text, regex);
},
_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;
@ -254,7 +440,7 @@ export default Mixin.create({
let html = clipboard.getData("text/html"); let html = clipboard.getData("text/html");
let handled = false; let handled = false;
const selected = this._getSelected(null, { lineVal: true }); const selected = this.getSelected(null, { lineVal: true });
const { pre, value: selectedValue, lineVal } = selected; const { pre, value: selectedValue, lineVal } = selected;
const isInlinePasting = pre.match(/[^\n]$/); const isInlinePasting = pre.match(/[^\n]$/);
const isCodeBlock = this._isInside(pre, /(^|\n)```/g); const isCodeBlock = this._isInside(pre, /(^|\n)```/g);
@ -349,12 +535,12 @@ export default Mixin.create({
}, },
@bind @bind
_indentSelection(direction) { indentSelection(direction) {
if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) { if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) {
return; return;
} }
const selected = this._getSelected(null, { lineVal: true }); const selected = this.getSelected(null, { lineVal: true });
const { lineVal } = selected; const { lineVal } = selected;
let value = selected.value; let value = selected.value;
@ -414,14 +600,14 @@ export default Mixin.create({
.join("\n"); .join("\n");
if (newValue.trim() !== "") { if (newValue.trim() !== "") {
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) {
let selected = this._getSelected(); let selected = this.getSelected();
const captures = selected.pre.match(/\B:(\w*)$/); const captures = selected.pre.match(/\B:(\w*)$/);
if (isEmpty(captures)) { if (isEmpty(captures)) {