Repository navigation
fix(editor): preserve indentation on soft-wrapped lines #2880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| import { | ||
| EditorState, | ||
| type Extension, | ||
| RangeSetBuilder, | ||
| StateEffect, | ||
| } from "@codemirror/state"; | ||
| import { | ||
| Decoration, | ||
| type DecorationSet, | ||
| EditorView, | ||
| ViewPlugin, | ||
| type ViewUpdate, | ||
| } from "@codemirror/view"; | ||
|
|
||
| const wrapWidth = StateEffect.define<number>(); | ||
|
|
||
| /** Keep tab stops on the first visual row unchanged by the negative indent. */ | ||
| export function wrappedIndentColumns( | ||
| text: string, | ||
| tabSize: number, | ||
| limit: number, | ||
| ): number { | ||
| // Unindented/minified lines need no tab scan, even when they are megabytes long. | ||
| if (limit <= 0 || (text[0] !== " " && text[0] !== "\t")) return 0; | ||
| // Include content tabs deliberately: negative text-indent changes the origin | ||
| // of every tab stop on the first visual row. For " key\tvalue", using 2ch | ||
| // shifts "value" left by two columns in Chromium; 4ch preserves its position. | ||
| // The extra continuation indent is the tradeoff for keeping tab alignment. | ||
| const hasTabs = text.includes("\t"); | ||
| const step = hasTabs ? tabSize : 1; | ||
| const cap = Math.max(0, Math.floor(limit / step) * step); | ||
| let columns = 0; | ||
| for (const char of text) { | ||
| if (char === " ") columns++; | ||
| else if (char === "\t") columns += tabSize - (columns % tabSize); | ||
| else break; | ||
| if (columns >= cap) return cap; | ||
| } | ||
| return Math.min(cap, Math.ceil(columns / step) * step); | ||
| } | ||
|
|
||
| interface CachedIndent { | ||
| text: string; | ||
| decoration: Decoration | null; | ||
| } | ||
|
|
||
| function decorate( | ||
| view: EditorView, | ||
| limit: number, | ||
| previous: Map<number, CachedIndent>, | ||
| ): { decorations: DecorationSet; cache: Map<number, CachedIndent> } { | ||
| const builder = new RangeSetBuilder<Decoration>(); | ||
| const tabSize = view.state.facet(EditorState.tabSize); | ||
| const cache = new Map<number, CachedIndent>(); | ||
| const styles = new Map<number, Decoration>(); | ||
| let lastLine = -1; | ||
| for (const { from, to } of view.visibleRanges) { | ||
| for (let pos = from; pos <= to; ) { | ||
| const line = view.state.doc.lineAt(pos); | ||
| if (line.from > lastLine) { | ||
| let entry = previous.get(line.from); | ||
| if (!entry || entry.text !== line.text) { | ||
| const columns = wrappedIndentColumns(line.text, tabSize, limit); | ||
| let decoration = columns ? styles.get(columns) : null; | ||
| if (columns && !decoration) { | ||
| decoration = Decoration.line({ | ||
| attributes: { | ||
| class: "cm-wrap-indent", | ||
| style: `--cm-wrap-indent: ${columns}ch`, | ||
| }, | ||
| }); | ||
| styles.set(columns, decoration); | ||
| } | ||
| entry = { text: line.text, decoration: decoration ?? null }; | ||
| } | ||
| cache.set(line.from, entry); | ||
| if (entry.decoration) | ||
| builder.add(line.from, line.from, entry.decoration); | ||
| lastLine = line.from; | ||
| } | ||
| pos = line.to + 1; | ||
| } | ||
| } | ||
| // Drop offscreen entries rather than retaining strings from the whole file. | ||
| return { decorations: builder.finish(), cache }; | ||
| } | ||
|
|
||
| const plugin = ViewPlugin.fromClass( | ||
| class { | ||
| decorations: DecorationSet = Decoration.none; | ||
| limit = 0; | ||
| cache = new Map<number, CachedIndent>(); | ||
|
|
||
| constructor(view: EditorView) { | ||
| this.measure(view); | ||
| } | ||
|
|
||
| measure(view: EditorView) { | ||
| view.requestMeasure({ | ||
| key: this, | ||
| read: () => | ||
| Math.max( | ||
| 0, | ||
| Math.floor( | ||
| (view.contentDOM.clientWidth - 8) / | ||
| view.defaultCharacterWidth / | ||
| 2, | ||
| ), | ||
| ), | ||
| write: (limit) => { | ||
| if (limit === this.limit) return; | ||
| // Measurement writes run inside CodeMirror's update. Dispatch only | ||
| // after it finishes, and ignore work queued by a removed plugin. | ||
| queueMicrotask(() => { | ||
| if (view.plugin(plugin) === this && limit !== this.limit) { | ||
| view.dispatch({ effects: wrapWidth.of(limit) }); | ||
| } | ||
| }); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| update(update: ViewUpdate) { | ||
| let changed = false; | ||
| for (const transaction of update.transactions) { | ||
| for (const effect of transaction.effects) { | ||
| if (effect.is(wrapWidth)) { | ||
| this.limit = effect.value; | ||
| changed = true; | ||
| } | ||
| } | ||
| } | ||
| const tabSizeChanged = | ||
| update.startState.facet(EditorState.tabSize) !== | ||
| update.state.facet(EditorState.tabSize); | ||
| if (changed || tabSizeChanged) { | ||
| this.cache.clear(); | ||
| } else if (update.docChanged) { | ||
| // Keep unchanged visible lines cached when earlier edits shift them. | ||
| this.cache = new Map( | ||
| Array.from(this.cache, ([pos, entry]) => [ | ||
| update.changes.mapPos(pos, -1), | ||
| entry, | ||
| ]), | ||
| ); | ||
| } | ||
| if ( | ||
| changed || | ||
| update.docChanged || | ||
| update.viewportChanged || | ||
| tabSizeChanged | ||
| ) { | ||
| const result = decorate(update.view, this.limit, this.cache); | ||
| this.decorations = result.decorations; | ||
| this.cache = result.cache; | ||
| } | ||
| if (update.geometryChanged) this.measure(update.view); | ||
| } | ||
| }, | ||
| { decorations: (value) => value.decorations }, | ||
| ); | ||
|
|
||
| /** | ||
| * Browser-native soft wrapping, with no widgets, replacement text, or input | ||
| * handlers. Line attributes leave CodeMirror's text/selection/composition DOM | ||
| * under its own control. `ch` tracks font changes without rounding tab stops. | ||
| * Lines containing tabs round their indent up to a tab stop, including when | ||
| * tabs occur after the leading whitespace. Oversized indents are capped | ||
| * at half the available columns so narrow panes still have room for content. | ||
| */ | ||
| export function indentedLineWrapping(): Extension { | ||
| return [ | ||
| EditorView.lineWrapping, | ||
| plugin, | ||
| EditorView.baseTheme({ | ||
| ".cm-line.cm-wrap-indent": { | ||
| paddingInlineStart: "calc(6px + var(--cm-wrap-indent))", | ||
| textIndent: "calc(-1 * var(--cm-wrap-indent))", | ||
| }, | ||
| ".cm-wrap-indent > *": { | ||
| // Inline-block indent guides and widgets must not inherit the indent. | ||
| textIndent: "0", | ||
| }, | ||
| }), | ||
| ]; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { wrappedIndentColumns } from "cm/indentedLineWrapping"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| describe("wrapped line indentation", () => { | ||
| it("preserves space indentation and stops at content", () => { | ||
| expect(wrappedIndentColumns(" code ", 4, 40)).toBe(3); | ||
| expect(wrappedIndentColumns("code", 4, 40)).toBe(0); | ||
| expect(wrappedIndentColumns("", 4, 40)).toBe(0); | ||
| }); | ||
| it("counts tabs from their current column and rounds mixed indentation up", () => { | ||
| expect(wrappedIndentColumns(" \tcode", 4, 40)).toBe(4); | ||
| expect(wrappedIndentColumns(" \t code", 4, 40)).toBe(8); | ||
| expect(wrappedIndentColumns("\t code", 8, 40)).toBe(16); | ||
| }); | ||
| it("rounds space indentation to preserve content tab alignment", () => { | ||
| expect(wrappedIndentColumns(" key\tvalue", 4, 40)).toBe(4); | ||
| }); | ||
| it("caps deep indentation without introducing fractional tab stops", () => { | ||
| expect(wrappedIndentColumns(" ".repeat(10000), 4, 13)).toBe(13); | ||
| expect(wrappedIndentColumns("\t".repeat(10000), 4, 13)).toBe(12); | ||
| expect(wrappedIndentColumns("\tcode", 4, 3)).toBe(0); | ||
| expect(wrappedIndentColumns(" code", 4, 0)).toBe(0); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.