Using external buttons/actions to modify content

I am wondering if it is possible to clear the content, reset to a default content and highlight certain words inside the prose-mirror editor using external buttons. I couldn’t come across any useful examples demonstrating how to apply those actions from external buttons.

Sometimes you have to combine pieces of information — i.e. how to programmatically apply changes and how to bind event handlers to buttons — to build what you want. Resetting content is best done by creating a new editor state and setting your view to use it. Highlighting words involves creating a plugin that scans the content for matches and adds decorations for them.

@marijn Forgive my ignorance, I am very new to prosemirror. When it comes to highlighting programmatically selected words that recur through the document, what is preferable between decorations and marks?

Marks are part of the document, and since this sounds like a UI element, decorations seem like they would be preferable.

using external buttons

I use transactions to trigger a “re-render” of the editor state and plugins (which listen to transactions happening) to add decorations to the UI.

But how do you trigger a transaction if the doc didn’t change?

Also you don’t want to change you doc (via transaction) just to re-render it.

One key takeaway is: A transaction doesn’t need to change anything!

So you:

  • (on button press) manually create an (empty) transaction
  • add some meta data to it <— money maker
  • apply that transaction to the state
  • let your plugin do stuff based on that meta data.

What I do (on e.g. button press) (this.view = editor instance):

      const tr = this.view.state.tr
      tr.setMeta('relint', true)

      const newState =
        this.view
          .state
          .apply(tr)

      this.view.updateState(newState)

in the plugin:

  return new Plugin({
    state: {
      init (_, { doc }) { return createDecorations(doc) },
      apply (tr, old) {
        const reLint = tr.getMeta('relint')
        if (tr.docChanged || reLint) {
          return createDecorations(tr.doc)
        }
        return old
      }
    },

To give some context of what I wanted to do:

Outside of the editor, I have a list of radio buttons each with a specific label. The editor is pre-populated with paragraphs of information. When a radio button is selected, all words, inside the editor, which match the selected radio button’s label should be highlighted in yellow. It has not been easy finding examples of such interactions. But I will see if what you all suggested here will help me accomplish the goal.

where does that plugin instance go?

where does that plugin instance go?

It’s a normal plugin e. g.:

MyPlugin.js

import { Plugin } from 'prosemirror-state'

function create () {
  return new Plugin({
    state: {
      init (_, tr) { return createDecorations(tr) },
      apply (tr, old) {
        const reLint = tr.getMeta('relint')
        if (tr.docChanged || reLint) {
          return createDecorations(tr)
        }
        return old
      }
    },
  })
}

function createDecorations (tr) {
  // const selectedRadio = tr.getMeta('radio')
  // const doc = tr.doc

  // search doc for selectedRadio type
  // and create decoration for each

  // return decorations
}

export default { create }

I suggest you add your selected radio to the tr.meta as well.

The plugin can be used as usual:

MyEditor.js

import MyPlugin from'./MyPlugin.js'

const view = new EditorView(
  myEditorDOM,
  {
    state: EditorState.create({
      doc: myDoc,
      plugins: [
        history(),
        keymap(baseKeymap),
        MyPlugin.create() < -----
      ]
    }),
    .
    .
    .
  }
)

You’ll want to follow the general pattern of the linter example for the plugin. It actually shows how to apply the highlight as an inline decoration using a plugin. The key library calls are:

  1. doc.descendants((node,pos) => {})
  2. node.isText Those two will give the content enumeration and position data you need. As for pattern matching and determining word offsets within the paragraph, that part is up to you. But the linter example again gives you a great head start (see the ‘bad words’ checker).

To actually pass the external radio-button keyword to the plugin, @MarMun’s help has you covered.

Thank you for all the suggestions. This is what I came up with using the lint plugin example. But on every new radio button selection (outside of the editor) the decorations should be reset and the words matching the new selection should be highlighted. What is the suggested way to clear decorations?

function highlight(doc, word) {
  let result = []
  const highlightWord = new RegExp(`\\b${word}\\b`, 'gi');

  function record(msg, from, to, fix) {
    result.push({msg, from, to, fix})
  }

  // For each node in the document
  doc.descendants((node, pos) => {
    if (node.isText) {
      // Scan text nodes for suspicious patterns
      let m;
      while (m = highlightWord.exec(node.text)) {
        record(`${m[0]}`,
          pos + m.index, pos + m.index + m[0].length)
      }
    }
  });

  return result
}

function highlightDeco(doc, word) {
  let decos = []

  highlight(doc, word).forEach(prob => {
    decos.push(Decoration.inline(prob.from, prob.to, {class: "highlight"}))
  });

  return DecorationSet.create(doc, decos);
}

const wordHighLightPlugin = (word) => {
  return new Plugin({
    state: {
      init(_, {doc}) {
        return highlightDeco(doc, word);
      },
      apply(tr, old) {
        return tr.docChanged ? highlightDeco(tr.doc, word) : old;
      }
    },
    props: {
      decorations(state) {
        return this.getState(state);
      },
    }
  });
};

In the linter example, it was interested in only re-linting when the doc was modified. In your case, you are not interested in that. You want to re-set the plugin when the radio selection changes. Therefore, you just need to change the logic in the apply() method. PM will take care of removing the old highlights when you provide it a fresh set of highlights. @MarMun’s post from yesterday shows what you need to do when your radio selection changes. The key-value sent to tr.setMeta(key,any) can be anything. In your case, you could send an object like {highlight: ‘Shazaam’}. In the apply() method, sniff the transaction via getMeta() and see if it contains an {highlight:…} object. You are in the home stretch!

Thank you, that really helped. I wanted to give the plugin a custom key so that I can remove it later (especially when the user exits the editing page). But, when I add they key, the highlighting behavior stops. It only highlights on page load and does not react to radio button selections.

Instead of a key, I am employing the following tactic (adding a pluginName attribute to the spec object)

export const wordHighLightPlugin = (word) => {
  return new Plugin({
    state: {
      init(_, {doc}) {
        return highlightDeco(doc, word);
      },
      apply(tr, old) {
        const reLint = tr.getMeta('relint');

        if (tr.docChanged || reLint) {
          return highlightDeco(tr.doc, word);
        }
        return old;
      }
    },
    props: {
      decorations(state) {
        return this.getState(state);
      },
    },
    pluginName: 'wordHighLightPlugin',
  });
};

I am doing the following to remove the plugin later

  let pluginIdx = -1;

  view.state.plugins.forEach((plugin, index) => {
    if (plugin.spec && plugin.spec.pluginName && plugin.spec.pluginName.includes('wordHighLightPlugin')) {
      pluginIdx = index;
    }
  });

  if (pluginIdx > -1) {
    view.state.plugins.splice(pluginIdx,1);
  }

I’m not sure plugins can be installed after the initial mount of the editor. But, assuming they can, there is still a more straighforward way:

a. Don’t use a factory to create the plugin. Just create a global instance of the plugin:

export const wordHighlightPlugin = new Plugin(...)

b. Instead of sending the string ‘relint’, send the payload like this:

...in your radio event handler...
view.dispatch(view.state.tr.setMeta(wordHighlightPlugin, {hilight: 'the word}));

c. In your plugin, you’ll need to opt for slightly more elaborate plugin shape due to the change from the factory method to a single global. It no longer has ‘word’ in scope. Instead of that shape being a simple decoration set, you’ll want something like:

{
   highlight: string | null;
   decorations: DecorationSet;
}

You’ll need the initial state to be { highlight: null, decorations: DecorationSet.empty}. Getting your editor in sync with your radio button UI at load time is done the same was as in the onChange—send it the default highlight, if any.

In your apply, it actually gets a bit messy. You need to deal with: 1) document changes, 2) new highlights, but no document change, 3) removal of all highlights. Don’t forget to return props.decorations() using your new state shape.

apply (tr, old) {
    const highlightMeta = tr.getMeta(wordHighlightPlugin); <-- undefined if not set on the radio change, else is { highlight: 'some word' };
    if (highlightMeta) {
          if (hilightMeta.highlight === null) {
             return { highlight: null, DecorationSet.empty }
         } else { 
             return { highlight: highlightMeta.highlight, decorations: createDecorations(tr.doc, highlightMeta.highlight) }
         }
   }
   if (tr.docChanged) {
      if (old.highlight !== null) {
         return {...old, decorations: createDecorations(tr.doc, old.highlight) }
      } else {
        return {...old, decorations: DecorationSet.empty }
        }
   }
   return old
}