Skip to content

Commit 71d2bbb

Browse files
committed
fix(editor): add monac-style punctuation wrap opportunities
1 parent 32d56c6 commit 71d2bbb

3 files changed

Lines changed: 125 additions & 4 deletions

File tree

src/cm/indentedLineWrapping.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ViewPlugin,
1313
type ViewUpdate,
1414
} from "@codemirror/view";
15+
import { punctuationWrapping } from "./punctuationWrapping";
1516

1617
const wrapWidth = StateEffect.define<number>();
1718
export type WrappingIndent = "none" | "same" | "indent" | "deepIndent";
@@ -178,19 +179,19 @@ const plugin = ViewPlugin.fromClass(
178179
);
179180

180181
/**
181-
* Browser-native soft wrapping, with no widgets, replacement text, or input
182-
* handlers. Line attributes leave CodeMirror's text/selection/composition DOM
183-
* under its own control. `ch` tracks font changes without rounding tab stops.
182+
* Browser-native soft wrapping with punctuation break opportunities.
183+
* Line attributes provide indentation; `ch` tracks font changes without rounding tab stops.
184184
* Lines containing tabs round their indent up to a tab stop, including when
185185
* tabs occur after the leading whitespace. Oversized indents are capped
186186
* at half the available columns so narrow panes still have room for content.
187187
*/
188188
export function indentedLineWrapping(mode: WrappingIndent = "same"): Extension {
189-
if (mode === "none") return EditorView.lineWrapping;
189+
if (mode === "none") return [EditorView.lineWrapping, punctuationWrapping];
190190
// Settings imported from older or manually edited files may be invalid.
191191
if (mode !== "indent" && mode !== "deepIndent") mode = "same";
192192
return [
193193
EditorView.lineWrapping,
194+
punctuationWrapping,
194195
wrappingIndent.of(mode),
195196
plugin,
196197
EditorView.baseTheme({

src/cm/punctuationWrapping.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
Decoration,
3+
type DecorationSet,
4+
EditorView,
5+
MatchDecorator,
6+
ViewPlugin,
7+
type ViewUpdate,
8+
WidgetType,
9+
} from "@codemirror/view";
10+
11+
// VS Code's default wordWrapBreak{After,Before}Characters:
12+
// https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/config/editorOptions.ts
13+
const after = " \t})]?|/&.,;¢°′″‰℃、。。、¢,.:;?!%・・ゝゞヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻ァィゥェォャュョッー”〉》」』】〕)]}」";
14+
const before = "([{‘“〈《「『【〔([{「£¥$£¥++";
15+
const escapeClass = (text: string) => text.replace(/[\\\]\[\-^]/g, "\\$&");
16+
17+
class WrapOpportunity extends WidgetType {
18+
toDOM(view: EditorView): HTMLElement {
19+
return view.dom.ownerDocument.createElement("wbr");
20+
}
21+
}
22+
23+
const opportunity = Decoration.widget({
24+
widget: new WrapOpportunity(),
25+
side: 1,
26+
});
27+
const matcher = new MatchDecorator({
28+
// Consume the character before each boundary. Keep runs of closing
29+
// punctuation together and break before a run of opening punctuation.
30+
// Whitespace already provides native breaks, so needs no widget.
31+
regexp: new RegExp(
32+
`[${escapeClass(after.trim())}](?=[^${escapeClass(after)}])|[^${escapeClass(before)}\\s](?=[${escapeClass(before)}])`,
33+
"gu",
34+
),
35+
decorate: (add, _from, to) => add(to, to, opportunity),
36+
});
37+
38+
/** Add punctuation break opportunities; retain native layout and long-word wrapping. */
39+
export const punctuationWrapping = ViewPlugin.fromClass(
40+
class {
41+
decorations: DecorationSet;
42+
43+
constructor(view: EditorView) {
44+
this.decorations = matcher.createDeco(view);
45+
}
46+
47+
update(update: ViewUpdate) {
48+
this.decorations = matcher.updateDeco(update, this.decorations);
49+
}
50+
},
51+
{ decorations: (value) => value.decorations },
52+
);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// @vitest-environment happy-dom
2+
3+
import { Compartment, EditorState } from "@codemirror/state";
4+
import { EditorView } from "@codemirror/view";
5+
import { indentedLineWrapping } from "cm/indentedLineWrapping";
6+
import { punctuationWrapping } from "cm/punctuationWrapping";
7+
import { afterEach, describe, expect, it } from "vitest";
8+
9+
const views: EditorView[] = [];
10+
afterEach(() => {
11+
for (const view of views.splice(0)) view.destroy();
12+
document.body.replaceChildren();
13+
});
14+
15+
function editor(doc: string) {
16+
const wrapping = new Compartment();
17+
const view = new EditorView({
18+
state: EditorState.create({
19+
doc,
20+
extensions: wrapping.of(indentedLineWrapping("none")),
21+
}),
22+
parent: document.body,
23+
});
24+
views.push(view);
25+
return { view, wrapping };
26+
}
27+
28+
function breaks(view: EditorView) {
29+
const positions: number[] = [];
30+
view.plugin(punctuationWrapping)?.decorations.between(
31+
0,
32+
view.state.doc.length,
33+
(from) => {
34+
positions.push(from);
35+
},
36+
);
37+
return positions;
38+
}
39+
40+
describe("punctuation wrapping", () => {
41+
it("adds breaks after commas and before opening brackets without spaces", () => {
42+
const { view } = editor("foo(alpha,beta,gamma)");
43+
expect(breaks(view)).toEqual([3, 10, 15]);
44+
expect(view.contentDOM.textContent).toBe("foo(alpha,beta,gamma)");
45+
});
46+
47+
it("keeps punctuation runs together and does not add equals or whitespace breaks", () => {
48+
const { view } = editor("a=bbbb c d\ta([{x})],y+z");
49+
expect(breaks(view)).toEqual([14, 22, 23]);
50+
});
51+
52+
it("supports Unicode punctuation without splitting surrogate pairs", () => {
53+
const { view } = editor("😀+猫、犬");
54+
expect(breaks(view)).toEqual([2, 5]);
55+
});
56+
57+
it("updates after edits and removes widgets when wrapping is disabled", () => {
58+
const { view, wrapping } = editor("alpha,beta");
59+
expect(breaks(view)).toEqual([6]);
60+
view.dispatch({ changes: { from: 5, to: 6, insert: "=" } });
61+
expect(breaks(view)).toEqual([]);
62+
view.dispatch({ changes: { from: 5, to: 6, insert: "," } });
63+
expect(breaks(view)).toEqual([6]);
64+
view.dispatch({ effects: wrapping.reconfigure([]) });
65+
expect(view.contentDOM.querySelector("wbr")).toBeNull();
66+
expect(view.state.doc.toString()).toBe("alpha,beta");
67+
});
68+
});

0 commit comments

Comments
 (0)