Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/cm/indentedLineWrapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ViewPlugin,
type ViewUpdate,
} from "@codemirror/view";
import { punctuationWrapping } from "./punctuationWrapping";

const wrapWidth = StateEffect.define<number>();
export type WrappingIndent = "none" | "same" | "indent" | "deepIndent";
Expand Down Expand Up @@ -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({
Expand Down
52 changes: 52 additions & 0 deletions src/cm/punctuationWrapping.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
68 changes: 68 additions & 0 deletions tests/unit/punctuationWrapping.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});