Custom React Spellcheck using Lint example as base


#1

I’m trying to implement some basic spellchecking functionality using React and Prosemirror using prosemirror lint example as a base.

So far the code snippet I’m pasting works fine and highlights the words contained in errors without an issue, but the problem comes when I want to make errors a dynamic state inside React.

I’m aware that I could just use a pointer to said state and have it working on Prosemirror state update but I was thinking about getting the new errors highlighted as soon as they came in. For that I thought about using the view props and update them on errors update but don’t know how to access the view from the plugin for that to work.

Any suggestions about how to approach this ?

import React from "react";` 

// Prosemirror
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { schema } from "prosemirror-schema-basic";
import { Plugin } from "prosemirror-state";

import { Decoration, DecorationSet } from "prosemirror-view";


let errors = ["hola"];

// Spellcheck plugin logic
// ================================================================================
function errorsRegexp(errors) {
  return RegExp("\\b(" + errors.join("|") + ")\\b", "gi")
}

function lint(doc) {
  let result = [];

  function record(from, to, word) {
    result.push({ from, to, word });
  }

  doc.descendants((node, currentPosition) => {
    let match,
        rgx = errorsRegexp(errors);

    if (node.isText) {
      while (match = rgx.exec(node.text)) record(
        currentPosition + match.index,
        currentPosition + match.index + match[0].length,
        match[0]
      );
    }
  });

  return result;
}

function lintDeco(doc) {
  let decorations = [];

  lint(doc).forEach(mistake => {
    decorations.push(Decoration.inline(mistake.from, mistake.to, { class: "spellcheck_mistake" }));
  });

  return DecorationSet.create(doc, decorations);
}

let lintPlugin = new Plugin({
  state: {
    init: (_, state ) => lintDeco(state.doc),
    apply: (tr, old, oldState, newState) => tr.docChanged ? lintDeco(newState.doc) : old
  },
  props: {
    decorations: function(state) { return this.getState(state); }
  }
});
// ================================================================================
// end spellcheck logic


class Editor extends React.Component {
  constructor() {
    super();

    let editorState = EditorState.create({ schema, plugins: [lintPlugin] });

    this.state = { editorState };
    this.dispatchTransaction = this.dispatchTransaction.bind(this);
  }

  componentDidMount() {
    this.view = new EditorView(document.querySelector("#test"), {
      state: this.state.editorState,
      dispatchTransaction: this.dispatchTransaction,
      errors: ["potato"]
    });
  }

  dispatchTransaction(transaction) {
    let editorState = this.state.editorState.apply(transaction);

    this.setState({ editorState });
    this.view.updateState(editorState);
  }

  render() {
    return (
      <div>
        <div ref={editor => this.editor = editor} id="test" spellCheck="false"></div>
      </div>
    );
  }
}

export default Editor;

#2

Plugins can expose their own props, so the way you’d do this is have your plugin store the highlight decorations in its state field, and return that from its decorations prop. Then when information comes in you fire a transaction with the new information in a meta property, and have the plugin update its state when it sees such a transaction.


#3

Hi Marijin, thanks a lot for the answer and your great work with prosemirror!

I managed to get it working using your answer as inspiration but not sure if this is the right way or I’m doing something horribly wrong within Prosemirror’s way of doing things.

let lintPlugin = new Plugin({
  state: {
    init: function(_, state) { return lintDeco(state.doc, this.spec.state.errors) },
    apply: function(tr, old, oldState, newState) {
      if (tr.meta.newError) {
        this.spec.state.errors.push(tr.meta.newError);
      }
      return tr.docChanged || tr.meta.newError ? lintDeco(newState.doc, this.spec.state.errors) : old;
    },
    errors: ["hola"]
  },
  props: {
    decorations: function(state) { return this.getState(state); },
  }
});

#4

You’re mutating state stored in the editor state (this.spec.state.errors), which means you won’t be able to use EditorState as if it is immutable anymore. Also, depending on how many errors there are, it might be much cheaper to call old.map when the doc changes, and then add the new error, if any, with its add method, instead of rebuilding the whole set.


#5

I suspected that I was mutating the state of the Plugin but I haven’t found any method to do so, so I thought that was the way. What would be the inmutable way to update errors?

Regarding the errors and the length of the text they both will be short, the final UI would have multiple single paragraph editors, so I’m not very concerned over there.


#6

Don’t put stateful things in your plugin spec. You can put them in a field in your plugin state (make sure you update decorations to return only the decorations field), and make the state something like {decorations, errors}, creating a new object and using concat to append errors every time you update it.

(If you only have a few errors at any time, you don’t even need to cache the decoration set—you could just compute the decoration set in the decorations prop. ProseMirror compares these structurally so it won’t needlessly redraw the DOM for decorations that are the same.)


#7

I think I finally got it. Thank you very much! :grinning:

I guess that I have been very confused. So I will be leaving the explanation here just in case someone struggles though the same mistakes I committed:

For what I see I’ve been interpreting all this time that the state should go PluginSpec’s state which I was mixing with Plugin’s state. Reason being I don’t see any way to set a state for a Plugin outside PluginSpecs, no matter how much I look at the docs.

By looking at what getState returns then it finally made sense, the state is what init and apply returns. With that in mind (and removing the caching) the code ends like this:

function lintState(doc, errors) {
  let decorations = [];

  lint(doc, errors).forEach(mistake => {
    decorations.push(Decoration.inline(mistake.from, mistake.to, { class: "spellcheck_mistake" }));
  });

  return {decorations: DecorationSet.create(doc, decorations), errors };
}

let lintPlugin = new Plugin({
  state: {
    init: function(_, state) { return lintState(state.doc, ["hola"]) },
    apply: function(tr, old, oldState, newState) {
      let errors = this.props.errors(oldState);

      if (tr.meta.newError) errors = errors.concat(tr.meta.newError);

      return lintState(newState.doc, errors);
    }
  },
  props: {
    decorations: function(state) {
      return this.getState(state).decorations;
    },
    errors: function (state) {
      return this.getState(state).errors;
    },
  }
});

#8

Our plugin shows you how you can implement spelling and grammar checking in ProseMirror. The code for the plugin is Open Source so you can use it for your own grammar checker.


#9

Thank you Chris!