diff --git a/src/cm/indentedLineWrapping.ts b/src/cm/indentedLineWrapping.ts index bc7f3b8d2..60b703aa6 100644 --- a/src/cm/indentedLineWrapping.ts +++ b/src/cm/indentedLineWrapping.ts @@ -12,6 +12,7 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; +import { punctuationWrapping } from "./punctuationWrapping"; const wrapWidth = StateEffect.define(); export type WrappingIndent = "none" | "same" | "indent" | "deepIndent"; @@ -178,19 +179,19 @@ const plugin = ViewPlugin.fromClass( ); /** - * 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. + * Browser-native soft wrapping with punctuation break opportunities. + * Line attributes provide indentation; `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(mode: WrappingIndent = "same"): Extension { - if (mode === "none") return EditorView.lineWrapping; + if (mode === "none") return [EditorView.lineWrapping, punctuationWrapping]; // Settings imported from older or manually edited files may be invalid. if (mode !== "indent" && mode !== "deepIndent") mode = "same"; return [ EditorView.lineWrapping, + punctuationWrapping, wrappingIndent.of(mode), plugin, EditorView.baseTheme({ diff --git a/src/cm/punctuationWrapping.ts b/src/cm/punctuationWrapping.ts new file mode 100644 index 000000000..917c3c1c4 --- /dev/null +++ b/src/cm/punctuationWrapping.ts @@ -0,0 +1,52 @@ +import { + Decoration, + type DecorationSet, + EditorView, + MatchDecorator, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from "@codemirror/view"; + +// VS Code's default wordWrapBreak{After,Before}Characters: +// https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/config/editorOptions.ts +const after = " \t})]?|/&.,;¢°′″‰℃、。。、¢,.:;?!%・・ゝゞヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻ァィゥェォャュョッー”〉》」』】〕)]}」"; +const before = "([{‘“〈《「『【〔([{「£¥$£¥++"; +const escapeClass = (text: string) => text.replace(/[\\\]\[\-^]/g, "\\$&"); + +class WrapOpportunity extends WidgetType { + toDOM(view: EditorView): HTMLElement { + return view.dom.ownerDocument.createElement("wbr"); + } +} + +const opportunity = Decoration.widget({ + widget: new WrapOpportunity(), + side: 1, +}); +const matcher = new MatchDecorator({ + // Consume the character before each boundary. Keep runs of closing + // punctuation together and break before a run of opening punctuation. + // Whitespace already provides native breaks, so needs no widget. + regexp: new RegExp( + `[${escapeClass(after.trim())}](?=[^${escapeClass(after)}])|[^${escapeClass(before)}\\s](?=[${escapeClass(before)}])`, + "gu", + ), + decorate: (add, _from, to) => add(to, to, opportunity), +}); + +/** Add punctuation break opportunities; retain native layout and long-word wrapping. */ +export const punctuationWrapping = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = matcher.createDeco(view); + } + + update(update: ViewUpdate) { + this.decorations = matcher.updateDeco(update, this.decorations); + } + }, + { decorations: (value) => value.decorations }, +); diff --git a/tests/unit/punctuationWrapping.test.ts b/tests/unit/punctuationWrapping.test.ts new file mode 100644 index 000000000..4bcd3039b --- /dev/null +++ b/tests/unit/punctuationWrapping.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { Compartment, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { indentedLineWrapping } from "cm/indentedLineWrapping"; +import { punctuationWrapping } from "cm/punctuationWrapping"; +import { afterEach, describe, expect, it } from "vitest"; + +const views: EditorView[] = []; +afterEach(() => { + for (const view of views.splice(0)) view.destroy(); + document.body.replaceChildren(); +}); + +function editor(doc: string) { + const wrapping = new Compartment(); + const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: wrapping.of(indentedLineWrapping("none")), + }), + parent: document.body, + }); + views.push(view); + return { view, wrapping }; +} + +function breaks(view: EditorView) { + const positions: number[] = []; + view.plugin(punctuationWrapping)?.decorations.between( + 0, + view.state.doc.length, + (from) => { + positions.push(from); + }, + ); + return positions; +} + +describe("punctuation wrapping", () => { + it("adds breaks after commas and before opening brackets without spaces", () => { + const { view } = editor("foo(alpha,beta,gamma)"); + expect(breaks(view)).toEqual([3, 10, 15]); + expect(view.contentDOM.textContent).toBe("foo(alpha,beta,gamma)"); + }); + + it("keeps punctuation runs together and does not add equals or whitespace breaks", () => { + const { view } = editor("a=bbbb c d\ta([{x})],y+z"); + expect(breaks(view)).toEqual([14, 22, 23]); + }); + + it("supports Unicode punctuation without splitting surrogate pairs", () => { + const { view } = editor("😀+猫、犬"); + expect(breaks(view)).toEqual([2, 5]); + }); + + it("updates after edits and removes widgets when wrapping is disabled", () => { + const { view, wrapping } = editor("alpha,beta"); + expect(breaks(view)).toEqual([6]); + view.dispatch({ changes: { from: 5, to: 6, insert: "=" } }); + expect(breaks(view)).toEqual([]); + view.dispatch({ changes: { from: 5, to: 6, insert: "," } }); + expect(breaks(view)).toEqual([6]); + view.dispatch({ effects: wrapping.reconfigure([]) }); + expect(view.contentDOM.querySelector("wbr")).toBeNull(); + expect(view.state.doc.toString()).toBe("alpha,beta"); + }); +});