How do I copy editor content as plain text?

I have three editors on my page. They have different marks, for different font styling.

If I copy content from one to another, they keep the marks from the original editor. However, if I copy that same content into a tool like View non-printable unicode characters, I can see there are no formatting characters.

How does this work? How does the pasted content magically keep the marks from the previous editor?

More importantly, how do I stop it? I want the content to be copied out simply as plain text (separated by new lines, if any should be present).

Here is a reference image of what I mean. I copied the “b” from the editor at the bottom of my screenshot into both of the other ones. I need the formatting of the pasted content to match the content where it was pasted, but it seems to keep its formatting from the original editor at the bottom.

I know I can do this with a plugin that watches for pastes, but I am hoping that I don’t have to do that.

Thanks in advance.

Content copied to the clipboard is serialized to HTML, and then parsed again (using the editor’s schema and its DOM parse rules) when pasted into an editor. So if your editor doesn’t have a certain mark in its schema, it should not be possible to paste such a mark into it—if that happens, that’s a bug, and I’d be interested in an example script that demonstrates it.

Thanks for that info, @marijn. All of the editors use the same schema, but the attributes on the marks are different. For example, the bottom editor in that image has a font-size mark with an attribute of {'font-size': 15}, while the top editor has a font-size mark with an attribute of {‘font-size’: 10}.

Is it possible to make the text be pasted without the marks? If not, it sounds like I will need to handle it manually by watching for pastes and applying the marks I actually want to pasted content?

I’m trying to wrap my head around this. I have the following paste handler:

      transformPastedHTML(html) {
        try {
          // Strip HTML from user-pasted content
          const tempEl = document.createElement('div');
          tempEl.innerHTML = escapeHtml(html);

          return tempEl.textContent || tempEl.innerText || '';
        } catch (error) {
          handleError(
            error,
            `Error raised while attempting to paste content: ${html}`
          );

          alert(
            'There was a problem processing the pasted content, we have been notified about the issue.'
          );

          return '';
        }
      }

Based on this, it seems like the pasted content (which you said is pasted as HTML, then parsed) shouldn’t even be getting to the parsing step that is finding/applying the old marks?

Here is the plugin I ended up writing. I don’t know if it is the optimal way to do it, but it seems to work :grinning_face_with_smiling_eyes:

// @flow
import { EditorState, Plugin } from 'prosemirror-state';
import { Mark} from 'prosemirror-model';
import { Transform } from 'prosemirror-transform';

type TMarkDictionary = { [string]: number | string };

function applyMarksAcrossPositions(
  marks: Array<Mark>,
  newState: EditorState,
  newTransaction: Transform,
  from: number,
  to: number
) {
  marks.forEach((mark) => {
    newTransaction.addMark(from, to, mark);
  });
}

function defaultMarksAtPosition(state: EditorState, position: number) {
  const resolvedPosition = state.doc.resolve(position);
  const paragraphSurroundingPosition = resolvedPosition.node(1);
  const markValues = paragraphSurroundingPosition.attrs.marks;

  return markValues || {};
}

function dictionaryToMarks(state: EditorState, dictionary: TMarkDictionary) {
  const marks = [];

  Object.keys(dictionary).forEach((markName) => {
    const schemaMark = state.schema.marks[markName];
    marks.push(schemaMark.create({ [markName]: dictionary[markName] }));
  });

  return Mark.setFrom(marks);
}

function marksAtPosition(state, position, defaultMarks: TMarkDictionary = {}) {
  const selectionMarks = marksToDictionary(state.doc.resolve(position).marks());
  const storedMarks = marksToDictionary(state.storedMarks || []);

  return dictionaryToMarks(state, {
    ...defaultMarks,
    ...selectionMarks,
    ...storedMarks
  });
}

function marksToDictionary(marks: Array<Mark>) {
  const dictionary = {};

  marks.forEach((mark) => {
    const markName = mark.type.name;
    dictionary[markName] = mark.attrs[markName];
  });

  return dictionary;
}

export function applyMarksToPastedContent() {
  return new Plugin({
    appendTransaction(transactions, oldState, newState) {
      const pasting = transactions.some(
        (tr) => tr.getMeta('uiEvent') === 'paste'
      );
      const oldSelection = oldState.selection;

      if (pasting) {
        let diffStart = oldState.doc.content.findDiffStart(
          newState.doc.content
        );

        if (diffStart === null) return null;

        const diffEnd = oldState.doc.content.findDiffEnd(newState.doc.content);

        const marks = marksAtPosition(
          oldState,
          oldSelection.from,
          defaultMarksAtPosition(oldState, oldSelection.from)
        );

        const newTransaction = newState.tr;
        applyMarksAcrossPositions(
          marks,
          newState,
          newTransaction,
          diffStart,
          diffEnd.b
        );

        return newTransaction;
      }
      return null;
    }
  });
}