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.
This commit is contained in:
Martin Brennan 2021-12-13 09:31:49 +10:00 committed by GitHub
parent bfe47038bb
commit fc01619bcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 202 additions and 1 deletions

View File

@ -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();

View File

@ -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();

View File

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