Link widget is clicked only if editor is out of focus

Hi,

I have implemented a StateField that uses Decoration.replace() to replace parts of a markdown document with actual html elements using widgets. However, if the widget is clickable (e.g. <a> links) and the editor has focus, the element needs to be clicked twice: the first time the editor just loses focus and only the second time the element is actually clicked.

Am I doing something wrong? Ideally, I’d like to click only once while preserving the editor’s focus.

Here’s a simplified version of the code I’m running

  class Link extends WidgetType {
    constructor(
      readonly node: SyntaxNode,
      readonly state: EditorState,
    ) {
      super();
    }
    toDOM() {
      let link = document.createElement("a");

      let marks = this.node.getChildren("LinkMark");
      if (marks.length >= 2) {
        link.innerText = this.state.sliceDoc(marks[0].to, marks[1].from);
      }

      let url = this.node.getChild("URL");
      if (url) link.href = this.state.sliceDoc(url.from, url.to);

      return link;
    }
  }

  let decorationsField = StateField.define<DecorationSet>({
    create() {
      return Decoration.none;
    },
    update(_, tr) {
      const builder = new RangeSetBuilder<Decoration>();
      let cursor = tr.state.selection.main.head;
      syntaxTree(tr.state).iterate({
        enter: (node) => {
          if ((cursor < node.from || cursor > node.to) && node.name == "Link") {
            builder.add(
              node.from,
              node.to,
              Decoration.replace({
                widget: new Link(node.node, tr.state),
              }),
            );
            return false;
          }
          return true;
        },
      });
      return builder.finish();
    },
    provide: (f) => EditorView.decorations.from(f),
  });

  new EditorView({
    doc: "[Test](https://example.com)",
    extensions: [markdown(), decorationsField],
    parent: document.body,
  });

I was also able to reproduce this on codemirror.net

The issue is that your widget doesn’t define an eq method (and generally isn’t set up to be easily comparable), causing the editor to re-render it every time you recreate your decorations. This causes the link you’re clicking to disappear as the editor loses focus, breaking the browser’s native behavior.

This version works better.

1 Like

I see. In the actual code, Link extends a WidgetType subclass which does define an eq method, but wasn’t working as intended.

As I’d rather not change the interface, I found that

eq(other) {
      return this.node.tree == other.node.tree;
}

works as expected (so far).

Thank you so much!

I’m trying to adapt this to a slightly different use case.

1). In my test the hyperlink isn’t rendered until the editor first gains focus. How can I get the decorator to run immediately?

2). I want to adapt this using the tooltip to navigate (I have this working), so instead of rendering a hyperlink I want to just render the link text – but cause this to expand to the markdown when clicked. Currently the expansion happens when moving the cursor into the text but I don’t understand how that is triggered.

3). Later I might try to create a popup window to edit the link itself – is there a better starting point than the current search dialog demo?

Thanks!

I think I found some of the answers, but would appreciate any hints on #1 and #3.

Re #2. I realize that the widget’s toDom gets passed the view object, so I’ve setup an event handler directly there, then I pass in the anchor position from the statefield iterator so that when clicked, the widget’s handler can dispatch to the start of the range:

  toDOM(view: EditorView) {
    const link = document.createElement('a');
    link.setAttribute('class', 'cm-link');
    link.textContent = this._text;
    if (this._url) {
      link.setAttribute('target', '_blank');
      link.href = this._url;
    } else {
      link.onclick = () => {
        view.dispatch({
          selection: {
            anchor: this._anchor,
          },
        });
      };
    }

    return link;
  }

For #1 you’ll have to change

    create() {
      return Decoration.none;
    }

In my case, I found that just re-using the update function I had defined worked fine, e.g.

  function loadDecorations(state: EditorState) {
    // previous update() code goes here
  }

   let decorationsField  = StateField.define<DecorationSet>({
    create: (state) => loadDecorations(state),
    update: (_prevState, tr) => loadDecorations(tr.state),
    provide: (f) => EditorView.decorations.from(f),
  });

Hope this helps!

1 Like

Thanks very much – that worked.