fix: Outdent code with shift-tab behavior (#12514)

* fix: Outdent code with shift-tab behavior

* PR feedback
This commit is contained in:
Tom Moor
2026-05-28 21:08:46 -04:00
committed by GitHub
parent ae5cd6a159
commit 3f92e96006
2 changed files with 173 additions and 13 deletions
+137
View File
@@ -0,0 +1,137 @@
import { TextSelection } from "prosemirror-state";
import { createEditorState, codeBlock, doc } from "@shared/test/editor";
import { indentInCode, outdentInCode } from "./codeFence";
/**
* Helper that runs a command against a code block document with the given
* selection and returns the resulting code block text content.
*/
function runInCodeBlock(
code: string,
from: number,
to: number,
command: typeof outdentInCode
) {
const testDoc = doc([codeBlock(code)]);
let state = createEditorState(testDoc);
state = state.apply(
state.tr.setSelection(TextSelection.create(state.doc, from, to))
);
let result = state;
command(state, (tr) => {
result = state.apply(tr);
});
return result.doc.firstChild?.textContent;
}
/**
* Helper that selects the entire code block, indents it, then outdents it and
* returns the resulting text content (which should match the input).
*/
function indentThenOutdentAll(code: string) {
const testDoc = doc([codeBlock(code)]);
let state = createEditorState(testDoc);
const selectAll = () => {
const block = state.doc.firstChild;
const end = block ? block.nodeSize - 1 : 1;
return state.apply(
state.tr.setSelection(TextSelection.create(state.doc, 1, end))
);
};
state = selectAll();
indentInCode(state, (tr) => {
state = state.apply(tr);
});
state = selectAll();
outdentInCode(state, (tr) => {
state = state.apply(tr);
});
return state.doc.firstChild?.textContent;
}
describe("outdentInCode", () => {
it("removes leading spaces from a single line", () => {
// " a" cursor after the content (tab size 2)
const text = runInCodeBlock(" a", 4, 4, outdentInCode);
expect(text).toBe("a");
});
it("outdents all selected lines", () => {
const code = " a\n b\n c";
// select from start of line 1 to end of line 3
const text = runInCodeBlock(code, 1, code.length + 1, outdentInCode);
expect(text).toBe("a\nb\nc");
});
it("does not affect lines above the selection", () => {
// Four lines, each indented two spaces. Select only the last two lines.
const code = " a\n b\n c\n d";
// line three starts at position 9 (1-indexed inside the block)
const startOfLine3 = " a\n b\n".length + 1;
const text = runInCodeBlock(
code,
startOfLine3,
code.length + 1,
outdentInCode
);
// Lines 1 and 2 must keep their indentation, lines 3 and 4 outdented.
expect(text).toBe(" a\n b\nc\nd");
});
it("does not affect the line above when selecting a single later line", () => {
const code = " a\n b\n c";
const startOfLine3 = " a\n b\n".length + 1;
const text = runInCodeBlock(
code,
startOfLine3,
code.length + 1,
outdentInCode
);
expect(text).toBe(" a\n b\nc");
});
it("removes only one indent step from already-indented nested lines", () => {
// Two-space code with a deeper (four-space) nested line. Outdent must
// remove two spaces per line, not four, even though a four-space run exists.
const code = "a\n b\n c";
const text = runInCodeBlock(code, 1, code.length + 1, outdentInCode);
expect(text).toBe("a\nb\n c");
});
});
describe("indent then outdent", () => {
it("round-trips two-space code without removing too many spaces", () => {
const code = "function () {\n return 1;\n}";
expect(indentThenOutdentAll(code)).toBe(code);
});
it("round-trips four-space code", () => {
const code = "def foo():\n return 1";
expect(indentThenOutdentAll(code)).toBe(code);
});
it("round-trips deeply nested two-space code", () => {
const code = "function () {\n if (x) {\n return 1;\n }\n}";
expect(indentThenOutdentAll(code)).toBe(code);
});
});
describe("indentInCode", () => {
it("indents all selected lines without touching others", () => {
const code = "a\nb\nc";
const startOfLine2 = "a\n".length + 1;
const text = runInCodeBlock(
code,
startOfLine2,
code.length + 1,
(state, dispatch) => indentInCode(state, dispatch)
);
expect(text).toBe("a\n b\n c");
});
});
+36 -13
View File
@@ -149,17 +149,21 @@ export const outdentInCode: Command = (state, dispatch) => {
const { tr, selection } = state;
const { $from, from, to } = selection;
const selectionLength = to - from;
let line = 1;
// Find all newlines in the selection and remove tab-sized spaces before
// them, working backwards to avoid changing the offset.
let index = to - 1;
let totalSpacesRemoved = 0;
let spacesRemovedOnFirstLine = 0;
const startOfFirstLine = findPreviousNewline($from);
const tabSize = getTabSize(state);
while (index >= startOfFirstLine - line * tabSize) {
// Walk backwards from the end of the selection down to the start of its
// first line. Deletions happen at positions >= index, so earlier positions
// never shift and no offset compensation is needed. Stopping at
// startOfFirstLine ensures lines above the selection are left untouched.
let index = Math.max(to - 1, startOfFirstLine);
while (index >= startOfFirstLine) {
const newLineBefore =
tr.doc.textBetween(index - 1, index) === newline ||
index === startOfFirstLine;
@@ -183,18 +187,20 @@ export const outdentInCode: Command = (state, dispatch) => {
tr.delete(index, index + spaces);
totalSpacesRemoved += spaces;
}
line++;
}
index--;
}
tr.setSelection(
TextSelection.create(
tr.doc,
to - selectionLength - spacesRemovedOnFirstLine,
to - totalSpacesRemoved
)
// Restore the selection, shifting each end back by the spaces removed
// before it. Clamp to the start of the first line so a selection that
// began within the removed leading whitespace doesn't underflow.
const newFrom = Math.max(
startOfFirstLine,
to - selectionLength - spacesRemovedOnFirstLine
);
const newTo = Math.max(newFrom, to - totalSpacesRemoved);
tr.setSelection(TextSelection.create(tr.doc, newFrom, newTo));
dispatch(tr);
return true;
@@ -282,7 +288,24 @@ function getTabSize(state: EditorState): number {
return 4;
}
const existingText = codeBlock.node.textContent;
const usesFourSpaces = existingText.includes(" ");
return usesFourSpaces ? 4 : 2;
// Infer the indent size from the existing indentation. Treat the block as
// four-space indented only when every indented line is a multiple of four
// spaces a simple `includes(" ")` check misfires on two-space code,
// which naturally contains four-space runs at deeper nesting levels or
// immediately after an indent, causing outdent to remove too many spaces.
// Only space characters are counted, since indent/outdent operate on spaces.
let hasIndentedLine = false;
for (const line of codeBlock.node.textContent.split(newline)) {
const leadingSpaces = line.length - line.replace(/^ +/, "").length;
// Ignore unindented and whitespace-only lines.
if (leadingSpaces === 0 || leadingSpaces === line.length) {
continue;
}
if (leadingSpaces % 4 !== 0) {
return 2;
}
hasIndentedLine = true;
}
return hasIndentedLine ? 4 : 2;
}