I'm missing something about how atomicRange works

Hi there,

I’m trying to implement a feature where I provide a set of ranges (start,end), and the CodeMirror editor highlights those and makes them atomicRanges. Based on the atomicRanges example here, I was expecting my implementation to work (below).

I’m finding the highlighting works fine, but the cursor can still be moved into each of the ranges.

I’ve found a few similar questions in the forum here but they seem to all be from before atomicRanges was moved from PluginFields to EditorView, so I’m not sure if there have been other breaking changes since then.

What am I misunderstanding here?

import {
  EditorView,
  ViewPlugin,
  Decoration,
  DecorationSet,
  ViewUpdate,
} from "@codemirror/view";

const rangeHighlighter = (ranges: [number, number][]) =>
  ViewPlugin.fromClass(
    class {
      decorations: DecorationSet;
      constructor(view: EditorView) {
        this.decorations = Decoration.set(
          ranges.map(([start, end]) =>
            Decoration.mark({
              class: "bg-wf-greenBackground text-wf-text2 p-1 rounded-wf",
            }).range(start, end)
          )
        );
      }
    },

    {
      decorations: (instance) => instance.decorations,
      provide: (plugin) =>
        EditorView.atomicRanges.of(
          (view: EditorView) =>
            view.plugin(plugin)?.decorations || Decoration.none
        ),
    }
  );

Are you using the default keymap? Only cursor motion actually handled by the editor will use these ranges.

Ah indeed! I wasn’t. Adding a keymap does fix it:

import {
  EditorView,
  ViewPlugin,
  Decoration,
  DecorationSet,
  ViewUpdate,
  keymap,
} from "@codemirror/view";
import { standardKeymap } from "@codemirror/commands";


const extensions = [
  EditorView.lineWrapping,
  EditorView.baseTheme({
    ".cm-content": { caretColor: "var(--text1)" },
  }),
  keymap.of(standardKeymap),
];

Is there an example you can point me to that shows a good way to handle updating the parent state when a highlighted range is deleted? i.e. I am storing my text as an array of objects that are either plain text, or a highlighted text. As a user deletes a highlightedText object, I need to remove that object from the parent, and also remove the Decoration from the CodeMirror.

I’m sure it’s a pretty well covered use case, but I thought I’d ask so I can try to match best practice.

You’ll have to update your data for every change that happens in the editor. A RangeSet (of which decoration sets are an instance) might be a practical data type for that, since its map operation probably mostly already does what you want to do (adjust positions for the change, drop ranges that are deleted).

Thanks for the quick response @marijn . I’m managing to build the RangeSet, but again there’s something I’m not understanding correctly because I’m not seeing the .map() apply changes.

In the code below newTextWithFields has the same number of elements, even when I remove one that is an atomicRange by deleting it in the CodeMirror. Do I need to manually find which Range changed in the viewUpdate and propagate that change into newRanges? My understanding was .map() would handle that for me.

I’m also not handling the case of updating the actual textValue in the underlying objects when there’s a viewUpdate. Is there a smart way to map those changes into the underlying object, for example by overloading an update method. I couldn’t see an appropriate method on RangeValue or related classes.

type Text = { type: "text"; textValue: string };
type Field = {
  type: "field";
  textValue: string;
  field: {
    fieldId: string;
  };
};
export type TextWithField = (Text | Field)[];


class TextOrFieldRange extends RangeValue {
  type: "text" | "field";
  textOrField: Text | Field;
  theRange;
  constructor(
    from: number,
    to: number,
    type: "text" | "field",
    textOrField: Text | Field
  ) {
    super();
    this.type = type;
    this.textOrField = { ...textOrField };
    this.theRange = this.range(from, to);
  }

  eq(other: RangeValue): boolean {
    return (
      other instanceof TextOrFieldRange &&
      this.type === other.type &&
      this.theRange.from === other.theRange.from &&
      this.theRange.to === other.theRange.to
    );
  }
}

const InputWithFields = ({
  textWithFields,
  setTextWithFields,
}: {
  textWithFields: TextWithField;
  setTextWithFields: (textWithFields: TextWithField) => void;
}) => {
  const rangeBuilder = new RangeSetBuilder<TextOrFieldRange>();
  let combinedText = "";
  const fieldRanges: [number, number][] = [];

  textWithFields.forEach((item) => {
    const start = combinedText.length;
    combinedText += item.textValue;
    const end = combinedText.length;
    rangeBuilder.add(
      start,
      end,
      new TextOrFieldRange(start, combinedText.length, item.type, item)
    );
    item.type == "field" && fieldRanges.push([start, end]);
  });
  const combinedRanges = rangeBuilder.finish();

  const handleTextChange = (value: string, viewUpdate: ViewUpdate) => {
    const newRanges = combinedRanges.map(viewUpdate.changes.desc);
    const iterator = newRanges.iter();
    const newTextWithFields: TextWithField = [];
    while (iterator.value !== null) {
      newTextWithFields.push(iterator.value.textOrField);
      iterator.next();
    }
    setTextWithFields(newTextWithFields);
  };

  const highlightRanges = rangeHighlighter(fieldRanges);

  return (
    <CodeMirror
      value={combinedText}
      onChange={handleTextChange}
      theme="none"
      basicSetup={false}
      editable={true}
      extensions={[...extensions, highlightRanges]}
      maxWidth="100%"
      height="100px"
    />
  );
};

By default, ranges are only dropped entirely if their content plus at least one character beyond that is deleted (i.e. if none of the positions associated with the range remain in the document). You can customize this with the mapMode property. But in this case I was actually recommending to just use the same set for your decorations and for your position tracking (non-inclusive mark decorations will be dropped when their content is deleted).

I wouldn’t try to keep the content string in the values separately. You can easily derive that from the range extent + the current document when you need it.