Select Items that are not next to each other

Hi there, I have been trying to find something about this around, but I did not manage (Multiple selections of text)

I would like to allow the user to select non-contiguos text by using the mouse and the keyboard (ctrl) in order to apply same style to different parts of the document. Do you guys know if there is a way to implement such feature? I would really appreciate any help.

Thanks a lot in advance. Best Regards

This does not exist in the core library, but it should be possible to implement your own Selection subclass and wire up the event handlers to create and use them.

Hi @marijn, thank you very much for your answer :slight_smile: I will investigate further how to implement the Selection subclass and let you know if I figure it out. Cheers

Some more discussion here @moesheet but just some preliminary thoughts - I don’t know if anyone has an open source demo, but maybe read this thread - I think someone there has implemented privately that maybe you could ask questions if need be.

Thank you very much for your tip. I’ll try next days to create a Selection subclass to handle this.

Out of curiosity I wrote next text in chatGPT:

Prosemirror extend Selection subclass to allow multiple selection to non contiguous text

And this was the answer:

To extend the ProseMirror Selection class to support multiple, non-contiguous text selections, you can create a new subclass of Selection and override the relevant methods. Here’s an outline of the steps you might follow:

  1. Create a new class that extends the ProseMirror Selection class.
  2. Override the eq method to compare multiple selections for equality.
  3. Override the map method to handle multiple selections.
  4. Override the content method to return the content of all selected ranges.
  5. Use your new subclass in your ProseMirror code to create and manipulate multiple selections.

You will likely also need to make changes to other parts of your code, such as the way you render selections or handle user interactions, to support multiple selections.

Keep in mind that implementing this feature will likely require a significant amount of custom code and a good understanding of the ProseMirror API, so it may be challenging for those who are not already familiar with the library

I’m not sure, but have the feeling the approach seems correct… What do you think? Doubting between being scared or fascinated XD

Just an idea of how to handle it: Create an onClick function on the document. Then you can find the position of the selected element like this:

const auxPos = editor.view.posAtCoords({
  left: event.clientX,
  top: event.clientY,
});

You can then store auxPos on a global variable as an array or whatever you see fit

Then loop all positions after selecting styles and apply the new attrs like this:

const { tr } = editor.view.state;
  tr.setNodeMarkup(auxPos.inside, undefined, {
    ...currentAttributes,
    newAttribute1,
    newAttribute2
  });

Hi @MakarovCode, thanks a lot for your suggestion. Indeed, I’m working on something similar but I would like to use actual Selection (not sure yet if I’ll manage, but I’m trying :wink: )

The idea is to do something like this (code does not work yet, just a PoC) (Angular 14)

class MultiRangeSelection extends Selection {
  ranges: SelectionRange[];

  constructor(ranges: SelectionRange[], doc: Node) {
   super(ranges[0].$from, ranges[ranges.length - 1].$to, ranges);
    this.ranges = ranges;
  }

  // I will need to override other methods
}

Event Handlers

this.editor.view.dom.addEventListener("mousedown", (event) => {
  if (!event.ctrlKey) {
    this.selectedRanges = [];
  }
});

this.editor.view.dom.addEventListener("mouseup", (event) => {
  const { dispatch, state } = this.editor.view;
  const { doc, tr, selection } = state;
  if (event.ctrlKey) {
    /** We add the new selected range */
    this.selectedRanges.push(selection.ranges[0]);

    /** We set our multi range selection passing the selected ranges */
    tr.setSelection(MultiRangeSelection.create(this.selectedRanges));

    /** We dispatch the transaction */
    dispatch?.(tr);

  } else {
    this.selectedRanges = [this.editor.view.state.selection.ranges[0]];
    /** We set back the Text Selection */
    tr.setSelection(TextSelection.create(doc, selection.anchor, selection.head));
    /** We dispatch the transaction */
    dispatch?.(tr);
  }
});

I’m just playing around for now, getting ideas and putting them together. I’ll come back to you if I manage to get something to work :slight_smile:

Thank you very much

Hi @marijn , @bZichett , @MakarovCode ,

I really appreciate your comments.

I’ve finally managed to have this feature working :smiley:

Multi Range Selection class

The custom MultiRangeSelection class extends the Selection class and stores the multiple ranges as an array in the ranges property.

import { Node } from "prosemirror-model"
import { Mappable } from "prosemirror-transform"
import { Selection, SelectionRange } from 'prosemirror-state';

/** A text Multi Range Selection represents a multiple selection */
export class MultiRangeSelection extends Selection {
  ranges: SelectionRange[];

  /** Construct a Multi Range Selection including the given Ranges. */
  constructor(ranges: SelectionRange[]) {
    super(ranges[0].$from, ranges[ranges.length - 1].$to, ranges);
    this.ranges = ranges;
  }

  map(doc: Node, mapping: Mappable): Selection {
    return new MultiRangeSelection(this.ranges);
  }

  eq(other: Selection): boolean {
    if (!(other instanceof MultiRangeSelection)) return false;
    if (this.ranges.length !== other.ranges.length) return false;
    for (let i = 0; i < this.ranges.length; i++) {
      if (this.ranges[i].$from !== other.ranges[i].$from ||
        this.ranges[i].$to !== other.ranges[i].$to) {
        return false;
      }
    }
    return true;
  }

  toJSON(): any {
    return { type: "multiRangeSelection", ranges: this.ranges }
  }

  /** Create a Multi Range Selection for the selected Ranges. */
  static create(ranges: SelectionRange[]) {
    return new MultiRangeSelection(ranges);
  }
}

Multiple Selection Handlers

The EditorView is set up to listen for keydown events, and when the Ctrl key is pressed, a new MultiRangeSelection instance is created and dispatched to the editor.

  /**
   * Adds the required Listeners to handle the multiple text selection: ctrl + selection
   */
  multipleSelectionHandlers() {
    this.editor.view.dom.addEventListener("mousedown", (event) => {
      if (!event.ctrlKey) {
        this.selectedRanges = [];
      }
    });

    this.editor.view.dom.addEventListener("mouseup", (event) => {
      const { dispatch, state } = this.editor.view;
      const { tr, selection } = state;

      /** If we are pressing ctrl and there is already a selected range */
      if (event.ctrlKey) {

        /** We add the new selected range */
        this.selectedRanges.push(selection.ranges[0]);

        /** We must sort the selected ranges by position */
        this.selectedRanges = this.selectedRanges.sort((n1, n2) => n1.$from.pos - n2.$from.pos);

        /** We set our multi range selection passing the selected ranges */
        tr.setSelection(MultiRangeSelection.create(this.selectedRanges));

        /** We dispatch the transaction */
        dispatch?.(tr);

      } else {
        /** We store the first selection */
        this.selectedRanges = [selection.ranges[0]];
      }
    });
  }

Multiple Selection Decoration

I have created a plugin where we can access the multiple ranges stored in the MultiRangeSelection instance and accessing the ranges property, giving the desired decoration.

To visualize the current selection in ProseMirror, we add a custom decorator to the state that will be responsible for rendering the selected text with a visual indicator such as a background color.

const multipleSelectionDecoration = (): Plugin => {
  return new Plugin({
    props: {
      decorations(state) {
        /** We just need this decorator when the selection is multiple */
        if (state.selection instanceof MultiRangeSelection) {
          const { doc } = state;
          const decorations = [];

          /** Goes through all the nodes and add the class multiple-selection in order to make the ::selection background transparent */
          state.doc.nodesBetween(0, doc.nodeSize - 2, (node, position) => {
            decorations.push(Decoration.inline(position, position + node.nodeSize, { class: "multiple-selection" }));
          });
          const { ranges } = state.selection;

          /** 
           * The code first accesses the ranges of the current selection, and then loops over each range, creating a new Decoration instance for each range.
           * The Decoration class is used to define a range of text that should be decorated with a specific style. 
           * In this case, we're setting the class property to "selected-text", which we can then use in our CSS stylesheet to style the selected text
           */
          for (let i = 0; i < ranges.length; i++) {
            const { $from, $to } = ranges[i];
            if ($from && $to) {
              decorations.push(Decoration.inline($from.pos, $to.pos, { class: "selected-text" }));
            }
          }

          /** 
           * We use the setMeta method on the transaction to associate the decorations with the current state. 
           * This will cause the decorations to be rendered whenever the state is updated. 
           */
          state.tr.setMeta("decorations", decorations);
          return DecorationSet.create(doc, decorations);
        }

      }
    }
  })
}

Multipe Selection Style

To highlight just the text contained in the selection ranges in ProseMirror, we use the CSS property “background-color” on the selected text nodes. In order to avoid having the whole block selected, we remove the background-color for the ::selection of the other nodes.

.multiple-selection::selection {
  background-color: transparent;
}

.selected-text::selection {
  background-color: #3297FD;
  color: white;
}
1 Like