Decorations to match regex and insert text

Hi,

I’m having problems trying to add decorations (highlights) to my CodeMirror editor (I’m using version 6).

I need the editor to match certain regex patterns and insert an element with some text instead of the matched pattern. The inserted elements must be completely deleted when pressing Backspace infront of them and if they could be selectable, even better.

Example: Regex /%d/ is replaced with the text “string” inside a span element.

Can anyone help me with this?

This example should get you mostly there. Making widgets selectable is not something that the library will handle for you, but the general approach there would be to add a state field that tracks if a widget is selected and emits an additional decoration around it to make it recognizeable as such, and some mouse/key handlers that set this state (along with the real selection) when at the appropriate times.

Thank you! Where is the PlaceholderWidget type imported from in that example? And how would I have multiple regex instead of just handling one case, as the example shows? :slight_smile:

@marijn

I now have this code (see below), but it doesn’t seem to be working. Can you help me with what I’m doing wrong?

class PlaceholderWidget extends WidgetType {
    value: string;
    constructor(value: string) {
        super();
        this.value = value;
    }
    toDOM(view: EditorView): HTMLElement {
        let span = document.createElement('span');
        span.className = 'placeholder-widget';
        span.textContent = this.value;

        return span;
    }
}

const patterns = [
    { regexp: /\[\[pattern1\]\]/g, text: 'Replacement for pattern1' },
    { regexp: /\[\[pattern2\]\]/g, text: 'Replacement for pattern2' },
];

const matchDecorators = patterns.map(pattern =>
    new MatchDecorator({
        regexp: pattern.regexp,
        decoration: match => Decoration.replace({
            widget: new PlaceholderWidget(pattern.text),
        }),
    })
);

const placeholders = ViewPlugin.fromClass(class {
        placeholderSets: DecorationSet[]
        constructor(view: EditorView) {
            this.placeholderSets = matchDecorators.map(decorator => decorator.createDeco(view));
        }
        update(update: ViewUpdate) {
            this.placeholderSets = this.placeholderSets.map((decoSet, index) =>
                matchDecorators[index].updateDeco(update, decoSet)
            );
        }
    }, 
    {
        decorations: instance => instance.placeholderSets,
        provide: plugin => EditorView.atomicRanges.of(view => {
            return view.plugin(plugin)?.placeholderSets || Decoration.none
        })
    }
);

I’m using TypeScript and “decorations:” gives the error “Type ‘(instance: (Anonymous class)) => DecorationSet’ is not assignable to type ‘(value: (Anonymous class)) => DecorationSet’.
Type ‘DecorationSet’ is missing the following properties from type ‘RangeSet’: size, update, between, iterts(2322)” and “view =>” gives the error “Argument of type ‘(view: EditorView) => DecorationSet | DecorationSet’ is not assignable to parameter of type ‘(view: EditorView) => RangeSet’.
Type ‘DecorationSet | DecorationSet’ is not assignable to type ‘RangeSet’.ts(2345)”.

Both EditorView.decorations and EditorView.atomicRanges expect a single decoration set, not an array of them. You could do something like mapping over matchDecorators and adding an accessor for every element in the array in the provide result, but I think this is going to be easier (and more efficient) if you create a combined regexp that matches all of the patterns, and checks the groups in the match result to determine what kind of widget to return, so that you have only one decoration set for these.

@marijn

Thank you so much, I think I’ll go with your last suggestion. One last question, if you don’t mind. How can I make these placeholders (decorators) removable in the editor? Nothing seems to happen when I press backspace/delete on them :slight_smile:

The widgets in the example can be deleted with backspace, so I’m not sure what’s going on there.

@marijn

I was missing the minimal setup (CodeMirror Reference Manual), my bad. Do you have any examples of how I could make tooltips for these placeholder, so that when they’re hovered a tooltip will appear with some text? :slight_smile:

There’s an example for that too!

@marijn

Thank you very much for the help, I really appreciate it!

@marijn

I’m having a hard time getting the tooltip to work with the placeholders. It does work, but only if either side of the placeholder element is hovered, not the middle of the element.

Do you have any idea of why that is?

Here’s my code, where tooltipExtension is the function that handles the tooltips:

class PlaceholderWidget extends WidgetType {
    value: string;
    constructor(value: string) {
        super();
        this.value = value;
    }
    toDOM(): HTMLElement {
        const span = document.createElement('span');

        span.textContent = this.value;
        span.className = 'bg-red-400 rounded text-white px-1 py-0.5';

        return span;
    }
}

const placeholderMatcher = new MatchDecorator({
    regexp: /%(d|s)/g,
    decoration: match => Decoration.replace({
        widget: new PlaceholderWidget(match[0]),
    }),
});

const placeholderExtension = ViewPlugin.fromClass(class {
    placeholders: DecorationSet
    constructor(view: EditorView) {
        this.placeholders = placeholderMatcher.createDeco(view);
    }
    update(update: ViewUpdate) {
        this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
    }
}, {
    decorations: instance => instance.placeholders,
    provide: plugin => EditorView.atomicRanges.of(view => {
        return view.plugin(plugin)?.placeholders || Decoration.none;
    }),
});

const tooltipExtension = hoverTooltip((view, pos, side) => {
  let {from, to, text} = view.state.doc.lineAt(pos);

  let regex = /%(d|s)/g, match;
  let found: any = null;

  while (match = regex.exec(text)) {
    let start = from + match.index;
    let end = start + match[0].length;

    if (pos >= start && pos <= end) {
      found = {start, end};
      break;
    }
  }

  if (!found) return null;
  if (found.start == pos && side < 0 || found.end == pos && side > 0) return null;

  return {
    pos: found.start,
    end: found.end,
    above: true,
    create(view) {
      let dom = document.createElement("div");
      dom.textContent = text.slice(found.start - from, found.end - from);
      return {dom};
    }
  };
});

That was a bug in the hover-detection code. This patch should fix that.

@marijn Awesome, thank you! Any idea of when the patch will be released? :slight_smile:

I’ve tagged @codemirror/view 6.17.0 with this patch included.

1 Like

Hi @marijn @kris_dev,
I have @codemirror/view@6.25.1 and trying to show tooptips on hover on widget placeholders, its working fine with normal text, but with placeholders tooltip is not appearing.
Any suggestions please?