Command: Insert characters around selection

For some context, my editor is heavily relying on a modified version of the “Linting” example, where certain regexes (markdown regexes: ※text※) are matched and trigger decorations on the wrapping markdown characters (*) and the text inside (bold). In short, the markdown is preserved and offers a preview at the same time.

Goal: I would like to make some commands that insert or wrap these markdown chars into the document at the cursor’s location.

When cmd + b is pressed:

  • if nothing is selected (cursor is just at a location x), an asterick is inserted before (x-1) and after (x+1) the cursor. This allows a user to begin typing in bold because the cursor is inside the astericks which the “linter” matches to decorate. Starting with an empty document, the result of this command would be: ** and the cursor would be located inside of those two astericks.

  • if the user has a range of text selected, wrap the selected text with astericks and preserve the selection. Starting with a document with a word (“hello”), and the user having the word selected, the result of the command would be ※hello※ and ideally the entirety of the ※hello※ selected.

My current approach is this, which is kind of working but has some issues:

  • I get Uncaught RangeError: Position -1 out of range when I’m at the start of the document. This is a great indicator that I’m doing something wrong. I assume this is because if the from is 0 already, subtracting 1 from it is an issue.

  • I have a feeling this isn’t a robust strategy. For this to be more robust (and to be able to implement an “undo” like behavior that removes the astericks, I’d think I’d want to use something like selection.replace or replaceWith, but I’m unclear how to get the text of the selection. Frankly I’m confused on the relationship between Nodes, Slices, and Fragments and how I use them to manipulate a document, and I’ve struggled to find examples close enough to what I’m trying to do.

      const setDecoration = (token) => (state, dispatch) => {
        const { from, to } = state.selection;
        const { tr } = state;
        /* No selection */
        if (from === to) {
          tr.insertText(token, from - 1, from);
          tr.insertText(token, to + 1, to + 2);
          dispatch(tr);
          return true;
        }
        /* user has range selected */
        tr.insertText(token, from - 1, from);
        tr.insertText(token, to + 1, to + 2);
        // tr.setSelection(); // preserve selection of new decorated value?
        dispatch(tr);
        return true;
      };
      bind('Mod-b', setDecoration('*'));
    

Any pointing in the right direction is very appreciated. I have a feeling I might be way off strategy-wise.

PS: prosemirror is really cool and it’s been a pleasure frazzling my brain with it.

from - 1 puts you one token before the start of the selection, which is probably causing the errors you mention. Putting two characters around the selection, whether the selection is empty or not, involves putting the first one at selection.from and the second at selection.to, but because the first change moves the content after it forward, you’ll want selection.to + 1 for the second character if you insert it after the first one.

Your calls to insertText pass different values from from and to, which means they overwrite existing content—that’s probably not right.

You should probably also check whether the selection endpoints are in inline positions (selection.$from.parent.inlineContent) to avoid trying to insert text at places where no text is allowed (for example when there’s a node selection selecting an entire paragraph).

@marijn Thanks for the response. I updated my command based off your advice

const setDecoration = (token) => (state, dispatch) => {
  const { parent } = state.selection.$from;
  const isInline = parent.inlineContent;

  if (!isInline) return false;

  const { from, to } = state.selection;
  const { tr } = state;

  tr.insertText(token, from);
  tr.insertText(token, to + 1);
  dispatch(tr);
  return true;
}; 

There’s a couple edge cases that I’ll humor you with:

  1. Empty selection: The command works perfectly when the selection has a range > 0, but when from == to (cursor is just blinking at a certain position), this command outputs two asterisks before the cursor. I’m trying to insert an asterisk before and after the cursor. Ex: tha|nks (pipe represents a cursor in the middle of the word) would become tha/|/nks. My solution in my original post kind of accomplishes this but fails in places like in between text and at end of list items (see if (from === to) for original logic)

  2. Selection is not preserved: When the command is run on a selection of hello, the command works and spits out *hello*. But the selection is not preserved: the result is actually *hello*| (pipe represents the cursor). What strategy would you propose for preserving or setting the selection so that it wraps the entirety of the result: |*hello*|. I have tried variations of tr.setSelection(new TextSelection(from, to)); but have run into various errors (this tr results in TypeError: $anchor.min is not a function)

  3. Select-all returns parent that is not inline: If the doc is empty and you simply type one word into it (you have a paragraph node with text in it), and you press mod-a to select-all. The command does not work, presumably because the selection’s parent !isInline. Do you have a suggestion for how to handle this? I can see why mod-a returns a parent that is not inline, but unsure how to proceed.

I have one more question if you’ll take it. How would you recommend I go about implementing the reverse of this type or command? (not quite an undo)

I think I want to get the text of the selection (range > 0) and check if it matches the markdown regex. If so, remove the first and last characters (which would be the asterisks). In the case of an empty selection (from == to), I would check if characters before and after cursor position are asterisks and if so, remove them. Main thing holding me back is I’ve really struggled to actually just get the text of the selection. If you can show or link to an example of simply getting the raw text of a selection, I think I could accomplish the rest, but open to any guidance you have.

Thanks for you help!

You should reset up the selection in the command function (using tr.setSelection) after the changes, to cover the new position of the text (or the empty position, but that’s the same code).

To work around the select-all issue, you could have a first step that moves the selection endpoints to the nearest text position (see TextSelection.between).

To get the text at a given point in the document, textBetween might be useful. Or the nodeBefore (and nodeAfter) getters on resolved positions.