inclusive selection?

What I want to do is that I have this selection (marked with < and > for start and end, | means the cursor):

<my selection>|

and when I type

<my selection with newly inserted text>|

instead of (current behavior):

<my selection> with newly inserted text|

I’ve tried myEditorSelection.main.map(tr.changes, 1) (writing this as part of the update method in my custom StateField) but it doesn’t do what I want. I’ve also tried inclusive: true for my decorations in my custom ViewPlugin but that doesn’t work either.

It’s not clear whether you are talking about a selection or a decoration. Mapping a selection with a positive assoc parameter should make it include insertions at its end. And setting decorations to be inclusive will also make them cover insertions at their end.

Both, and neither setting assoc or inclusive works. I’m going to try to create an MVE to test later today

Sorry for the late response. Here is a minimal reproduction of the issue (I used the CSS class cm-comment in my marker):

import {
	EditorSelection,
	RangeSet,
	RangeSetBuilder,
	SelectionRange,
	StateEffect,
	StateField,
	type Transaction,
	type StateCommand,
} from "@codemirror/state";
import {
	Decoration,
	type EditorView,
	ViewPlugin,
	type ViewUpdate,
	type DecorationSet,
	type KeyBinding,
} from "@codemirror/view";
import { isMatch } from "lodash-es";

const addComment = StateEffect.define<EditorSelection>();
function cleanRangesOf(selection: EditorSelection) {
	const newRanges = selection.ranges.filter(
		(range) => range.from !== range.to,
	);
	return newRanges.length > 0
		? EditorSelection.create(newRanges, selection.mainIndex)
		: null;
}
export const commentField = StateField.define<EditorSelection[]>({
	create(): EditorSelection[] {
		return [];
	},
	update(oldComments: EditorSelection[], tr: Transaction): EditorSelection[] {
		let comments = oldComments;
		for (const e of tr.effects) {
			if (e.is(addComment)) {
				// XXX: Not sure if this is the right attribute to use
				comments = [...comments, e.value];
			}
		}
		// Map our old annotations to the new state
		// ranges, as we don't want our annotations/highlighted portion
		// to be static markers of a row and column but instead change with te
		comments = comments
			.map((x) => cleanRangesOf(x.map(tr.changes, 1)))
			.filter((x) => x !== null);

		return comments;
	},
});
const commentsChanged = (update: ViewUpdate) =>
	update.startState.field(commentField).every((val, idx) =>
		// OPTIMIZE: this may not be performant?
		// perhaps have a "last updated" field
		isMatch(val, update.state.field(commentField)[idx]),
	) ||
	update.transactions.some((tr) => tr.effects.some((e) => e.is(addComment)));

const commentDecorations = ViewPlugin.fromClass(
	class {
		decorations: DecorationSet;

		constructor(view: EditorView) {
			this.decorations = this.getDecorations(view, "cm-comment");
		}

		update(update: ViewUpdate) {
			// update.selectionSet also means "if cursor changed"
			if (
				update.selectionSet ||
				update.docChanged ||
				commentsChanged(update)
			) {
				this.decorations = this.getDecorations(
					update.view,
					"cm-comment",
				);
			}
		}

		getDecorations(view: EditorView, classPrefix: string): DecorationSet {
			// TODO: optimize algorithm to be linear time complexity
			// using some sort of greedy algorithm
			const builder = new RangeSetBuilder<Decoration>();

			// We can assume a single selection
			// because we are not implementing multi-selection support
			// for now
			const annotationRanges = view.state
				.field(commentField)
				.map((x) => x.main);

			// If you don't add annotations in order, the plugin will crash
			annotationRanges.sort((a, b) => a.from - b.from);
			console.log(annotationRanges);
			// TODO: care about multiple selections
			for (const { from, to } of annotationRanges) {
				console.log("adding mark");
				builder.add(
					from,
					to,
					Decoration.mark({
						class: classPrefix,
						inclusive: true,
					}),
				);
			}
			return builder.finish();
		}
	},
	{
		decorations: (v) => v.decorations,
	},
);
const createCommentCommand: StateCommand = ({ state, dispatch }) => {
	// TODO: multi selection support
	if (state.selection.main.empty) return false;
	console.log("asd");
	dispatch(
		state.update({
			effects: [addComment.of(state.selection)],
		}),
	);
	return true;
};
export const commentKeymap: KeyBinding[] = [
	{
		key: "Mod-Alt-x",
		run: createCommentCommand,
	},
];

export const comments = () => [commentField, commentDecorations];

Oh, right, you’re mapping non-empty selection ranges. Those don’t ever grow to cover insertions at their sides. And since you’re regenerating your decorations instead of mapping them, their inclusive option is never used. What you probably want to do is use the decoration set as the data structure that tracks the comments, rather than your array of selection ranges.

Is that why the .map in the StateField never works?

You mean in my StateField? In my original code, it’s actually an array of objects that contain a selection key (which contains an EditorSelection)

I guess what I have to do is go the long way of updating the decoration on old comments and adding new ones (do I need to care about deletion?) Thank you for the help