Update/move a gutter marker

If you have a RangeSet of GutterMarkers, is there a way to update the position of a specific GutterMarker rather than removing the marker and creating a new one?

Use case: Adding indicators to specific lines in a gutter. The indicator can only be on one line and may move around between lines.

Problem: If there’s an img in the marker, the remove-and-add-new process causes the browser to reload the image causing a visual flash.

The RangeSet API doesn’t seem to have a way to change an existing cursor’s range unless I’m missing something.

It seems like the Marker’s toDOM is called each time so a new element is made and so the browser completely re-renders it.

I’ve tried keeping a cache of the markers in the StateField outside of the RangeSet so the actual marker isn’t being created or destroyed, but that doesn’t seem to help.

marker_flash

Example code:

StateField.define<CollabRemoteGutterState>({
    create: () => {
      return {
        markers: {},
        rangeSet: RangeSet.empty
      };
    },
    provide: (s) => [
        gutter({
          class: GUTTER_CLASS,
          renderEmptyElements: true,
          markers(view: EditorView) {
            return view.state.field(s).rangeSet ?? RangeSet.empty;
          }
        })
    ],

    update({ markers, rangeSet }, tr) {
      if (tr.docChanged) rangeSet = rangeSet.map(tr.changes);

      const add: Range<CollabGutterMarker>[] = [];
      for (const e of tr.effects) {
        if (e.is(updateMarkerPositionEffect)) {
          const { id, start } = e.value;
          if (!id) continue;

          let marker = markers[id];
          if (!marker) {
            marker = new MyCustomMarker(id);
            markers[id] = marker;
          }

          const startLine = tr.state.doc.lineAt(start);
          add.push(marker.range(startLine.from, startLine.from));
        }
      }

      if (add.length > 0) {
        rangeSet = rangeSet.update({
          add,
          sort: true,
          filter: (f, t, m) => !add.some(({ value }) => value.id === m.id)
        });
      }

      return { markers, rangeSet };
    }
  });

No.

But also, unless the image has some really strict cache headers, I wouldn’t expect browsers to reload it when they were just displaying it.

Hm. Okay. It may be de-caching at least partially due to Dev Tools being open and the network settings.

Regardless, part of the issue is the element being created/destroyed every time it moves. I came up with this approach which alleviates the problem by keeping the element around on the MyCustomMarker instance so it’s not recreated every time I do marker.range().

export class MyCustomMarker extends GutterMarker {
  id: string;
  el?: HTMLSpanElement | HTMLImageElement;

  constructor(id: string, user: CollabUser) {
    super();
    this.id = id;
  }

  toDOM() {
    if (this.el) return this.el;
    
    const el = document.createElement('span');
...
    this.el = el;
    return el;
  }
}

Unfortunately it appears that marker.destroy() calls happening at some point in the Range updates then kill that DOM element and prevent the markers from being re-inserted. Don’t cache in this way :upside_down_face: