diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 80e63ce8fbe..81dddeda768 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -35,29 +35,15 @@ import { siteDir } from "discourse/lib/text-direction"; import { translations } from "pretty-text/emoji/data"; import { wantsNewWindow } from "discourse/lib/intercept-click"; 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 -// based on input (like for numbered lists). -function getHead(head, prev) { - if (typeof head === "string") { - return [head, head.length]; - } else { - return getHead(head(prev)); - } -} +import TextareaTextManipulation, { + getHead, +} from "discourse/mixins/textarea-text-manipulation"; function getButtonLabel(labelKey, defaultLabel) { // use the Font Awesome icon if the label matches the default return I18n.t(labelKey) === defaultLabel ? null : labelKey; } -const OP = { - NONE: 0, - REMOVED: 1, - ADDED: 2, -}; - const FOUR_SPACES_INDENT = "4-spaces-indent"; let _createCallbacks = []; @@ -285,8 +271,8 @@ export default Component.extend(TextareaTextManipulation, { }); }); - this._itsatrap.bind("tab", () => this._indentSelection("right")); - this._itsatrap.bind("shift+tab", () => this._indentSelection("left")); + this._itsatrap.bind("tab", () => this.indentSelection("right")); + this._itsatrap.bind("shift+tab", () => this.indentSelection("left")); // disable clicking on links in the preview this.element @@ -294,13 +280,13 @@ export default Component.extend(TextareaTextManipulation, { .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, "insertBlock"); + this.appEvents.on("composer:insert-text", this, "insertText"); + this.appEvents.on("composer:replace-text", this, "replaceText"); this.appEvents.on( "composer:indent-selected-text", this, - "_indentSelection" + "indentSelection" ); } @@ -338,13 +324,13 @@ export default Component.extend(TextareaTextManipulation, { @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, "insertBlock"); + this.appEvents.off("composer:insert-text", this, "insertText"); + this.appEvents.off("composer:replace-text", this, "replaceText"); this.appEvents.off( "composer:indent-selected-text", this, - "_indentSelection" + "indentSelection" ); } @@ -477,7 +463,7 @@ export default Component.extend(TextareaTextManipulation, { key: "#", afterComplete: (value) => { this.set("value", value); - schedule("afterRender", this, this._focusTextArea); + schedule("afterRender", this, this.focusTextArea); }, transformComplete: (obj) => { return obj.text; @@ -504,7 +490,7 @@ export default Component.extend(TextareaTextManipulation, { key: ":", afterComplete: (text) => { this.set("value", text); - schedule("afterRender", this, this._focusTextArea); + schedule("afterRender", this, this.focusTextArea); }, 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) { if (sel.value.indexOf("\n") !== -1) { - this._applySurround(sel, head, "", exampleKey, opts); + this.applySurround(sel, head, "", exampleKey, opts); } else { const [hval, hlen] = getHead(head); if (sel.start === sel.end) { @@ -745,7 +623,7 @@ export default Component.extend(TextareaTextManipulation, { const post = trimmedPost.length ? `\n\n${trimmedPost}` : trimmedPost; 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; } - const selected = this._getSelected(button.trimLeading); + const selected = this.getSelected(button.trimLeading); const toolbarEvent = { selected, selectText: (from, length) => - this._selectText(from, length, { scroll: false }), + this.selectText(from, length, { scroll: false }), applySurround: (head, tail, exampleKey, opts) => - this._applySurround(selected, head, tail, exampleKey, opts), + this.applySurround(selected, head, tail, exampleKey, opts), applyList: (head, exampleKey, opts) => this._applyList(selected, head, exampleKey, opts), - addText: (text) => this._addText(selected, text), + addText: (text) => this.addText(selected, text), getText: () => this.value, toggleDirection: () => this._toggleDirection(), }; @@ -855,7 +733,7 @@ export default Component.extend(TextareaTextManipulation, { return; } - const sel = this._getSelected("", { lineVal: true }); + const sel = this.getSelected("", { lineVal: true }); const selValue = sel.value; const hasNewLine = selValue.indexOf("\n") !== -1; const isBlankLine = sel.lineVal.trim().length === 0; @@ -867,25 +745,20 @@ export default Component.extend(TextareaTextManipulation, { 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.selectText(sel.pre.length + 4, example.length); } else { - return this._applySurround( - sel, - "```\n", - "\n```", - "paste_code_text" - ); + return this.applySurround(sel, "```\n", "\n```", "paste_code_text"); } } else { - return this._applySurround(sel, "`", "`", "code_title"); + return this.applySurround(sel, "`", "`", "code_title"); } } else { if (isFourSpacesIndent) { - return this._applySurround(sel, " ", "", "code_text"); + return this.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.addText( sel, `${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}` ); diff --git a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js index 896cfd9aeae..3a62254fdbc 100644 --- a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js @@ -1,4 +1,5 @@ import { bind } from "discourse-common/utils/decorators"; +import I18n from "I18n"; import Mixin from "@ember/object/mixin"; import { generateLinkifyFunction } from "discourse/lib/text"; 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_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({ init() { this._super(...arguments); @@ -24,6 +41,13 @@ export default Mixin.create({ }, // 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() { if (!this.element || this.isDestroying || this.isDestroyed) { return; @@ -37,12 +61,30 @@ export default Mixin.create({ 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) { - 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) { - 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) { @@ -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 }) { next(() => { 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 = {}) { const val = this.value; const needleStart = val.indexOf(oldVal); @@ -135,13 +189,127 @@ export default Mixin.create({ !opts.skipNewSelection ) { // Restore cursor. - this._selectText( + this.selectText( 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) { text = (text || "").trim(); if (text.length === 0) { @@ -172,6 +340,12 @@ export default Mixin.create({ 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) { if (options && options.ensureSpace) { if ((sel.pre + "").length > 0) { @@ -196,6 +370,12 @@ export default Mixin.create({ 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) { if (text.endsWith("\n")) { text = text.substring(0, text.length - 1); @@ -233,6 +413,12 @@ export default Mixin.create({ 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) { const matches = text.match(regex); return matches && matches.length % 2; @@ -254,7 +440,7 @@ export default Mixin.create({ let html = clipboard.getData("text/html"); let handled = false; - const selected = this._getSelected(null, { lineVal: true }); + const selected = this.getSelected(null, { lineVal: true }); const { pre, value: selectedValue, lineVal } = selected; const isInlinePasting = pre.match(/[^\n]$/); const isCodeBlock = this._isInside(pre, /(^|\n)```/g); @@ -349,12 +535,12 @@ export default Mixin.create({ }, @bind - _indentSelection(direction) { + indentSelection(direction) { if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) { return; } - const selected = this._getSelected(null, { lineVal: true }); + const selected = this.getSelected(null, { lineVal: true }); const { lineVal } = selected; let value = selected.value; @@ -414,14 +600,14 @@ export default Mixin.create({ .join("\n"); if (newValue.trim() !== "") { - this._replaceText(value, newValue, { skipNewSelection: true }); - this._selectText(this.value.indexOf(newValue), newValue.length); + this.replaceText(value, newValue, { skipNewSelection: true }); + this.selectText(this.value.indexOf(newValue), newValue.length); } }, @action emojiSelected(code) { - let selected = this._getSelected(); + let selected = this.getSelected(); const captures = selected.pre.match(/\B:(\w*)$/); if (isEmpty(captures)) {