Toggling Extensions

The new CodeMirror 6 setup of “everything’s an extension” is taking some time to get used to.

Say I want to provide editor settings to a user, such as:

  • The language used
  • Show/hide line numbers
  • Enable/disable line wrapping
  • Change font family/size/line height

I understand creating compartments and reconfiguring those compartments, but what’s the best way to trigger reconfiguration based on outside user input?

I’ve poured through the docs, and tried several approaches. One big hurdle is the async nature of some of the changes. For example: loading languages through @codemirror/language-data.

The best approach I’ve found so far is this:


export const editorSettingsUpdateEffect = StateEffect.define();

export function makeEditorSettingExtension(onChange) {
  const compartment = new Compartment();
  const updateCompartment = ViewPlugin.fromClass(
    class {
      update(u) {
        u.transactions.map(async (tr) => {
          for (let e of tr.effects) {
            if (e.is(editorSettingsUpdateEffect)) {
              let newValue = await onChange(e.value, tr);
              if (newValue !== undefined) {
                u.view.dispatch({
                  effects: compartment.reconfigure(newValue)
                });
              }
            }
          }
        });
      }
    }
  );
  return [compartment.of([]), updateCompartment];
}

In Use:

const lineNumbersExtension = makeEditorSettingExtension(
  function (editorSettings) {
    const value = editorSettings.lineNumbers;
    if (value !== undefined) {
      return value ? lineNumbers() : [];
    }
  }
});

const view = new EditorView({
  parent: document.body,
  state: EditorState.create({
    doc: '1\n2\n3\n4',
    extensions: [lineNumbersExtension]
  })
});
// Immediately sending this `editorSettingsUpdateEffect`
// but it could be called later when user changes a setting
view.dispatch({
  effects: editorSettingsUpdateEffect.of({
    lineNumbers: true
  })
});

Is there a better way to dispatch an update to a compartment based on an outside change?

I don’t like using ViewPlugin in this way, but that’s the only way I’ve found that I can both listen to an effect and asynchronously send a view.dispatch. Seems like this is pretty inefficient for a dozen settings.

EditorState.transactionExtender doesn’t allow async, the effect change has to be done right away. It also doesn’t work for multiple settings at once. It only seems to allow one transactionExtender to change the effects, overriding all the others

I’d sure love a way to just “listen” to a particular effect being dispatched and trigger another view.dispatch as necessary, or some other more direct way for an extension/compartment to dynamically reconfigure itself.

References:

Why are you using a custom effect and an update handler? Why not directly dispatch the reconfiguring effects from the code that triggers the setting change?

I’m looking at around a dozen or more settings that toggle extensions in various ways on multiple editor views. Integrating into React for more added fun.

As much as possible, I’d like each setting’s extension logic self contained, and an easy, consistent way to update multiple settings at once.

It can probably be set up as a helper function that dispatches effects to reconfigure compartments, though I’ll likely have to create Objects to keep track of it all outside of CodeMirror.

Followup: I’ve taken an outside controller approach that’s roughly like this:

export class CompartmentController {
  compartments = {};
  controllers = {};

  register(key, controller) {
    this.compartments[key] = new Compartment();
    this.controllers[key] = controller;
  }

  get extension() {
    return Object.values(this.compartments).map(
      (compartment) => compartment.of([])
    );
  }

  async update(view, settings) {
    Object.entries(settings).map(async ([key, value]) => {
      if (value !== undefined && this.controllers[key]) {
        const newValue = await this.controllers[key](value, settings, view);
        if (newValue !== null) {
          view.dispatch({
            effects: this.compartments[key].reconfigure(newValue || [])
          });
        }
      }
    });
  }
}

Usage:

import { lineNumbers } from '@codemirror/view';

export const settingsController = new CompartmentController();

settingsController.register(
  'lineNumbers', 
  function (lineNumbersEnabled) {
    return lineNumbersEnabled ? lineNumbers() : [];
  }
);
const view = new EditorView({
  parent: document.body,
  state: EditorState.create({
    doc: '1\n2\n3\n4',
    extensions: [settingsController.extension]
  })
});

settingsController.update(view, { lineNumbers: true });

Not ideal, but it’s working well for most of what I need. Becomes a bit hard to deal with extensions that require multiple settings but I can rework some of the controller.

Ultimately, I’d love some better ways to listen for changes in CodeMirror and be able to respond with effects.

For example: the Emmet extension can only work on certain languages, so I add a transactionExtender that watches the languages facet for changes and enables/disables emmet accordingly. Works well in this exact case, but seems inefficient to run this on every transaction.

const emmetCompartment = new Compartment();

const enableEmmetForSupportedLanguages = EditorState.transactionExtender.of(
  (tr) => {
    const languageBefore = tr.startState.facet(language)[0];
    const languageAfter = tr.state.facet(language)[0];

    if (languageBefore !== languageAfter) {
      const canUseEmmet = validEmmetEditorLanguage(languageAfter);
      return {
        effects: emmetCompartment.reconfigure(
          canUseEmmet ? emmetExtensions : []
        )
      };
    }
  }
);