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 };