How to force plain test paste

I’m working on a plain text editor, functionally very similar to a textarea.

I’d like to force the plain text pasting behaviour in the clipboard handler. As I see there are many handlers I can use, but the main behaviour is hard-coded in input.ts.

I come up with a few solutions. 1 goes into an infinite loop. 2 simply doesn’t paste anything. 3 seems to work well, the moment I press a key, it removes all line breaks and makes it into a single line string.

How would you recommend me properly implementing this functionality?

export const forcePlainTextPaste = new Plugin({
  props: {
    handlePaste(view, event) {
      const text = event.clipboardData?.getData('text/plain')
      if (text) {
        view.pasteText(text, event)
        return true
      }
      return false
    },
  },
})

export const forcePlainTextPaste2 = new Plugin({
  props: {
    transformPastedHTML() {
      // Return empty string to force plain text parsing
      return ''
    },
  },
})

export const forcePlainTextPaste3 = new Plugin({
  props: {
    handlePaste(view, event) {
      const text = event.clipboardData?.getData('text/plain')
      if (text) {
        const tr = view.state.tr.insertText(text)
        view.dispatch(tr)
        return true
      }
      return false
    },
  },
})



export const forcePlainTextPaste = new Plugin({
  props: {
    handlePaste(view, event) {
      const text = event.clipboardData?.getData('text/plain')
      if (text) {
        view.pasteText(text, event)
        return true
      }
      return false
    },
  },
})

export const forcePlainTextPaste2 = new Plugin({
  props: {
    transformPastedHTML() {
      // Return empty string to force plain text parsing
      return ''
    },
  },
})

export const forcePlainTextPaste3 = new Plugin({
  props: {
    handlePaste(view, event) {
      const text = event.clipboardData?.getData('text/plain')
      if (text) {
        const tr = view.state.tr.insertText(text)
        view.dispatch(tr)
        return true
      }
      return false
    },
  },
})

This is my schema:

import { type DOMOutputSpec, type NodeSpec, Schema } from 'prosemirror-model'

const pDOM: DOMOutputSpec = ['p', 0]
const brDOM: DOMOutputSpec = ['br']

export const pmSchema = new Schema({
  nodes: {
    doc: {
      content: 'block+',
    } as NodeSpec,

    paragraph: {
      content: 'inline*',
      group: 'block',
      parseDOM: [
        {
          tag: 'p',
          preserveWhitespace: 'full',
        },
      ],
      toDOM() {
        return pDOM
      },
    } as NodeSpec,

    text: {
      group: 'inline',
    } as NodeSpec,

    hard_break: {
      inline: true,
      group: 'inline',
      selectable: false,
      parseDOM: [{ tag: 'br' }],
      toDOM() {
        return brDOM
      },
    } as NodeSpec,
  },
  marks: {},
})

It sounds like a text editor (such as CodeMirror) might make that easier. But if you configure your ProseMirror schema to only allow the plain constructs you intend to allow, pasting should automatically do the right thing.

Yes, in theory everything works, but I’m running into cases which do not.

For example here is this: plain

  blocks.push({
    type: 'text',
    content: multiLineText,
  })

html:

<meta charset='utf-8'><pre class="CodeToken__styles.base backgroundColor-x1qv17b8 paddingBlock-xp59q4u paddingInline-xaope02 borderRadius-xm5kryp borderColor-x7kbyhx borderWidth-xmkeg23 fontSize-x1s8wshw overflowX-xw2csxc" style="margin: 0px 0px 1rem; padding: 0px; box-sizing: border-box; border-width: 1px; border-style: solid; border-color: rgb(221, 221, 221); font-family: var(--font-family-code); font-feature-settings: normal; font-variation-settings: normal; font-size: 0.875em; border-radius: var(--xlxplcu); padding-block: 10px; padding-inline: 12px; background-color: rgb(249, 249, 249); overflow-x: auto; color: rgb(0, 0, 0); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><code style="margin: 0px; padding: 0px; box-sizing: border-box; border-width: 0px; border-style: solid; border-color: currentcolor; font-family: var(--font-family-code); font-feature-settings: normal; font-variation-settings: normal; font-size: 1em;">  blocks.<span class="hljs-title function_" style="margin: 0px; padding: 0px; box-sizing: border-box; border-width: 0px; border-style: solid; border-color: currentcolor; color: rgb(111, 66, 193);">push</span>({
    <span class="hljs-attr" style="margin: 0px; padding: 0px; box-sizing: border-box; border-width: 0px; border-style: solid; border-color: currentcolor; color: rgb(0, 92, 197);">type</span>: <span class="hljs-string" style="margin: 0px; padding: 0px; box-sizing: border-box; border-width: 0px; border-style: solid; border-color: currentcolor; color: rgb(3, 47, 98);">'text'</span>,
    <span class="hljs-attr" style="margin: 0px; padding: 0px; box-sizing: border-box; border-width: 0px; border-style: solid; border-color: currentcolor; color: rgb(0, 92, 197);">content</span>: multiLineText,
  })</code></pre>

This can only be pasted if I use the Shift version, otherwise line breaks disappear. It works correctly on https://prosemirror.net/ though. With my schema it removes the newlines.

Why does it happen?

I ran into the same issue as your forcePlainTextPaste3

As for why, if you call insertText with plain text with line breaks, the actual editor html looks like…

<p>a
b
c</p>

when we expected

<p>a</p>
<p>b</p>
<p>c</p>

insertText docs say this, so the behavior makes some sense, but I expected it to handle the line breaks.

Replace the given range, or the selection if no range is given, with a text node containing the given string.


I found a solution to prefer plain text when pasting, that turns lines into paragraphs and merges with the surrounding blocks. But it feels like a lot of ceremony.

function handlePaste(view: EditorView, event: ClipboardEvent) {
  const text = event.clipboardData?.getData("text/plain");
  if (!text) return false;

  // Create paragraph nodes using the schema
  const lines = text.split("\n");
  const paragraphs = lines.map((line) => {
    const content = line ? [view.state.schema.text(line)] : [];
    return view.state.schema.nodes.paragraph.create(null, content);
  });

  const fragment = Fragment.fromArray(paragraphs);
  // maxOpen so content merges properly
  const slice = Slice.maxOpen(fragment);

  view.dispatch(view.state.tr.replaceSelection(slice));

  // We handled the paste, so don't continue with the event.
  return true;
}

OK, of course rubber ducking here would lead me to view.pasteText(text);

function handlePaste(view: EditorView, event: ClipboardEvent) {
  const text = event.clipboardData?.getData("text/plain");
  if (!text) return false;

  view.pasteText(text);
  return true;
}

I think that’s what I actually needed.


Edit: compared to my previous verbose version, this loses blank lines. So

a

b

turns into

<p>a</p><p>b</p>

without the blank paragraph in the middle… is this expected? It does match the prosemirror.net demo.