From fc01619bcb6105cd142b44f4086376cf33291d3f Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 13 Dec 2021 09:31:49 +1000 Subject: [PATCH] FEATURE: Use Tab for indenting text in composer (#15208) This commit allows for using Tab and Shift+Tab to indent and de-indent selected text in the composer. The selected text is searched for the most occurrences of either tabs (\t) or spaces at the start of each line, and that character is used for indentation of all lines. --- .../discourse/app/components/d-editor.js | 13 +++ .../app/mixins/textarea-text-manipulation.js | 96 ++++++++++++++++++- .../integration/components/d-editor-test.js | 94 ++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 1b0ce15734a..16d3796e42d 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -285,6 +285,9 @@ export default Component.extend(TextareaTextManipulation, { }); }); + this._itsatrap.bind("tab", () => this._indentSelection("right")); + this._itsatrap.bind("shift+tab", () => this._indentSelection("left")); + // disable clicking on links in the preview this.element .querySelector(".d-editor-preview") @@ -294,6 +297,11 @@ export default Component.extend(TextareaTextManipulation, { 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" + ); } if (isTesting()) { @@ -333,6 +341,11 @@ export default Component.extend(TextareaTextManipulation, { 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" + ); } this._itsatrap?.destroy(); 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 a364defddde..2db4cb85e35 100644 --- a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js @@ -16,6 +16,9 @@ const isInside = (text, regex) => { return matches && matches.length % 2; }; +const INDENT_DIRECTION_LEFT = "left"; +const INDENT_DIRECTION_RIGHT = "right"; + export default Mixin.create({ init() { this._super(...arguments); @@ -134,7 +137,10 @@ export default Mixin.create({ this.set("value", val.replace(oldVal, newVal)); } - if (opts.forceFocus || this._$textarea.is(":focus")) { + if ( + (opts.forceFocus || this._$textarea.is(":focus")) && + !opts.skipNewSelection + ) { // Restore cursor. this._selectText( newSelection.start, @@ -327,6 +333,94 @@ export default Mixin.create({ } }, + /** + * Removes the provided char from the provided str up + * until the limit, or until a character that is _not_ + * the provided one is encountered. + */ + _deindentLine(str, char, limit) { + let eaten = 0; + for (let i = 0; i < str.length; i++) { + if (eaten < limit && str[i] === char) { + eaten += 1; + } else { + return str.slice(eaten); + } + } + return str; + }, + + @bind + _indentSelection(direction) { + if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) { + return; + } + + const selected = this._getSelected(null, { lineVal: true }); + const { lineVal } = selected; + let value = selected.value; + + // Perhaps this is a bit simplistic, but it is a fairly reliable + // guess to say whether we are indenting with tabs or spaces. for + // example some programming languages prefer tabs, others prefer + // spaces, and for the cases with no tabs it's safer to use spaces + let indentationSteps, indentationChar; + let linesStartingWithTabCount = value.match(/^\t/gm)?.length || 0; + let linesStartingWithSpaceCount = value.match(/^ /gm)?.length || 0; + if (linesStartingWithTabCount > linesStartingWithSpaceCount) { + indentationSteps = 1; + indentationChar = "\t"; + } else { + indentationChar = " "; + indentationSteps = 2; + } + + // We want to include all the spaces on the selected line as + // well, no matter where the cursor begins on the first line, + // because we want to indent those too. * is the cursor/selection + // and . are spaces: + // + // BEFORE AFTER + // + // * * + // ....text here ....text here + // ....some more text ....some more text + // * * + // + // BEFORE AFTER + // + // * * + // ....text here ....text here + // ....some more text ....some more text + // * * + const indentationRegexp = new RegExp(`^${indentationChar}+`); + const lineStartsWithIndentationChar = lineVal.match(indentationRegexp); + const intentationCharsBeforeSelection = value.match(indentationRegexp); + if (lineStartsWithIndentationChar) { + const charsToSubtract = intentationCharsBeforeSelection + ? intentationCharsBeforeSelection[0] + : ""; + value = + lineStartsWithIndentationChar[0].replace(charsToSubtract, "") + value; + } + + const splitSelection = value.split("\n"); + const newValue = splitSelection + .map((line) => { + if (direction === INDENT_DIRECTION_LEFT) { + return this._deindentLine(line, indentationChar, indentationSteps); + } else { + return `${Array(indentationSteps + 1).join(indentationChar)}${line}`; + } + }) + .join("\n"); + + if (newValue.trim() !== "") { + this._replaceText(value, newValue, { skipNewSelection: true }); + this._selectText(this.value.indexOf(newValue), newValue.length); + } + }, + @action emojiSelected(code) { let selected = this._getSelected(); diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js index da9cf70f094..d80d186ff98 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js @@ -728,6 +728,100 @@ third line` assert.strictEqual(this.value, "red yellow blue"); }); + async function indentSelection(container, direction) { + await container + .lookup("service:app-events") + .trigger("composer:indent-selected-text", direction); + } + + composerTestCase( + "indents a single line of text to the right", + async function (assert, textarea) { + this.set("value", "Hello world"); + setTextareaSelection(textarea, 0, textarea.value.length); + await indentSelection(this.container, "right"); + + assert.strictEqual( + this.value, + " Hello world", + "a single line of selection is indented correctly" + ); + } + ); + + composerTestCase( + "de-indents a single line of text to the left", + async function (assert, textarea) { + this.set("value", " Hello world"); + setTextareaSelection(textarea, 0, textarea.value.length); + await indentSelection(this.container, "left"); + + assert.strictEqual( + this.value, + "Hello world", + "a single line of selection is deindented correctly" + ); + } + ); + + composerTestCase( + "indents multiple lines of text to the right", + async function (assert, textarea) { + this.set("value", " Hello world\nThis is me"); + setTextareaSelection(textarea, 2, textarea.value.length); + await indentSelection(this.container, "right"); + + assert.strictEqual( + this.value, + " Hello world\n This is me", + "multiple lines are indented correctly without selecting preceding space" + ); + + this.set("value", " Hello world\nThis is me"); + setTextareaSelection(textarea, 0, textarea.value.length); + await indentSelection(this.container, "right"); + + assert.strictEqual( + this.value, + " Hello world\n This is me", + "multiple lines are indented correctly with selecting preceding space" + ); + } + ); + + composerTestCase( + "de-indents multiple lines of text to the left", + async function (assert, textarea) { + this.set("value", " Hello world\nThis is me"); + setTextareaSelection(textarea, 2, textarea.value.length); + await indentSelection(this.container, "left"); + + assert.strictEqual( + this.value, + "Hello world\nThis is me", + "multiple lines are de-indented correctly without selecting preceding space" + ); + } + ); + + composerTestCase( + "detects the indentation character (tab vs. string) and uses that", + async function (assert, textarea) { + this.set( + "value", + "```\nfunc init() {\n strings = generateStrings()\n}\n```" + ); + setTextareaSelection(textarea, 4, textarea.value.length - 4); + await indentSelection(this.container, "right"); + + assert.strictEqual( + this.value, + "```\n func init() {\n strings = generateStrings()\n }\n```", + "detects the prevalent indentation character and uses that (tab)" + ); + } + ); + async function paste(element, text) { let e = new Event("paste", { cancelable: true }); e.clipboardData = { getData: () => text };