mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
fix: Outdent code with shift-tab behavior (#12514)
* fix: Outdent code with shift-tab behavior * PR feedback
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user