Trigger a StateEffect when focus changes

I need to dispatch a state effect when the editor’s focus changes (on focus and on blur), and I am struggling to figure out the right way to go about this. I initially tried to use a transaction extender:

function emitEffectOnFocusChange(effect: StateEffect<unknown>) {
  return EditorState.transactionExtender.of((tr) => {
    // add effect here
  });
}

But I realized that there aren’t any transactions associated with a focus change, and that it’s entirely a view concern (which makes sense to me). I then tried to use a view plugin:

function emitEffectOnFocusChange(
  getEffect: () => StateEffect<unknown>,
) {
  return ViewPlugin.fromClass(
    class {
      update(update: ViewUpdate) {
        if (update.focusChanged) {
          update.view.dispatch({effects: getEffect()});
        }
      }
    },
  );
}

While this gives me direct access to the focus change via the ViewUpdate, it complains that I am trying to dispatch an effect during an update.

I could alternatively schedule an update with setTimeout():

function emitEffectOnFocusChange(
  getEffect: () => StateEffect<unknown>,
) {
  return ViewPlugin.fromClass(
    class {
      update(update: ViewUpdate) {
        if (update.focusChanged) {
          setTimeout(() => {
            update.view.dispatch({effects: getEffect()});
          }, 0);
        }
      }
    },
  );
}

But I would prefer to avoid that approach if possible.

Is there any alternative way I could go about this? I’ve looked through the docs and from what I can see, there isn’t a way to create an extension that receives a ViewUpdate that lets me post new state effects (but I could be missing something).

That wasn’t cleanly possible, but the patch below should help. Does it work for you?

Yes, thank you!

@marijn I went to update to 6.9.0 to use the new focusChangeEffect and I’m seeing a very strange bug. On both of my code editors (I have two separate editors with two separate languages), the syntax highlighting is gone. In the DOM, I don’t see any token information at all. One of my editors has a custom language implementation and the other uses a lezer grammar, so I don’t think it’s related to the language implementation.

Are you seeing this? I have tried all sorts of things like re-installing and downgrading/upgrading and it seems that whenever I’m on 6.9.0 is when the issue happens.

I suspect you used npm to upgrade specific dependencies, which for some reason usually causes it to install multiple versions of packages that are requires by multiple other dependencies. Remove your package lock and reinstall and you should be good.

@marijn I appreciate the idea! Looks like you were right, the resolutions needed to be fixed.

I also noticed that focusChangeEffect seems to not properly register when the focus is moved to the editor via mouse click. I saw consistent behavior when using tab to move focus on and off the editor, but when I used a mouse to do that, it didn’t seem to register when the editor would receive focus via mouse click.

I used the following code as a very basic smoke to test the mouse focusing:

import {basicSetup, EditorView} from "codemirror"
import {javascript} from "@codemirror/lang-javascript"
import {StateEffect} from '@codemirror/state';

const smokeEffect = StateEffect.define(undefined);

const extension = EditorView.focusChangeEffect.of((_, focusing) => {
  console.log('hasFocus', focusing);
  return smokeEffect.of(undefined);
});

new EditorView({
  doc: "console.log('hello')\n",
  extensions: [basicSetup, javascript(), extension],
  parent: document.body
})

You can see the example here.

1 Like

Indeed, that wasn’t working quite right. @codemirror/view 6.9.1 should improve it.

Would it be possible for focusChangeEffect to pass the source of the focus change (pointer vs keyboard) as an argument? I’m building some functionality to allow the user to control tab trapping, and knowing how the user focused the editor in the first place would be useful for inferring the default behavior.

The mouse vs keyboard bug in 9.6.0 suggests that this is possible, but I don’t know if there are other nuances. My backup strategy is to check view.dom.matches(":focus-visible"), probably in a ViewPlugin.

The editor knows just as little about that as you do. It’s just responding to a focus or blur event.