Modify specific node on copy and paste (in clipboard)

Thought I will share some details of how I made it eventually. The general concept is that on copy we update attribute with external data and remove it on paste when it’s not needed. This approach may be helpful to support some similar workflows.

First, I added new attrs to my custom Node called copyPasteData which expects a stringified object with external data structure.

...
toDOM: (node: ProsemirrorNode): DOMOutputSpecArray => {
    return [
      "span",
      {
        ...,
        "data-copy-paste-data": JSON.stringify(node.attrs.copyPasteData),
      },
    ];
  },

  parseDOM: [
    {
      tag: "span[data-custom-field]",
      getAttrs: (dom: string | Node) => {
        const { ..., copyPasteData } = (dom as HTMLElement).dataset;

        return {
          ...,
          copyPasteData: copyPasteData && JSON.parse(copyPasteData),
        };
      },
    },

Then, I created reusable mapSlice and mapFragement functions that will traverse the Slice and with callback function update Nodes we need (remember to not mutate the Fragment directly)

import { Slice, Fragment, Node } from "prosemirror-model";

export const mapFragment = (
  fragment: Fragment,
  callback: (node: Node) => Node | Node[] | Fragment | null
): Fragment =>
  Fragment.fromArray(
    (fragment as any).content.map((node: Node) => {
      if (node.content.childCount > 0) {
        return node.type.create(
          node.attrs,
          mapFragment(node.content, callback)
        );
      }

      return callback(node);
    })
  );

export const mapSlice = (
  slice: Slice,
  callback: (node: Node) => Node | Node[] | Fragment | null
): Slice => {
  const fragment = mapFragment(slice.content, callback);
  return new Slice(fragment, slice.openStart, slice.openEnd);
};

Then I have a single plugin with transformCopied and transformPasted that accepts external context

export const copyPastePlugin = ({ editorContext }: Args) =>
  new Plugin({
    key: new PluginKey("copyPastePlugin"),
    props: {
      transformCopied: (slice: Slice) => {
        /* Add temporary dataForCopyPaste to live in the clipboard. */
        const addCopyPasteDataToDataFields = (node: Node) => {
          if (node.type === schema.nodes.custom_field) {
            // do something with external context

            return node.type.create(
              {
                ...node.attrs,
                ...(someData && {
                  copyPasteData: { ...someData },
                }),
              },
              node.content
            );
          }

          return node.copy(node.content);
        };

        return mapSlice(slice, addCopyPasteDataToDataFields);
      },
      transformPasted: (slice: Slice) => {
        // get dataForCopyPaste and do whatever with data + clean it up
        const fetchAndClearCopyPasteData = (node: Node) => {
          if (
            node.type === schema.nodes.custom_field &&
            node.attrs.copyPasteData
          ) {
            // do something with copyPasteData and clean it up
            return node.type.create(
              { ...node.attrs, copyPasteData: undefined },
              node.content
            );
          }
          return node.copy(node.content);
        };

        const newSlice = mapSlice(slice, fetchAndClearCopyPasteData);

        return newSlice;
      },
    },
  });

Also, a side note, if your external context can change during Editor updates you’ll need to call state.reconfigure() on each change to initialize new set of plugins with fresh context (or at least that’s the only working solution I found).

In my case, I crated a helper function which updates plugins whenever I need to:

const newState = editorView.state.reconfigure({
    plugins: [...otherPlugins, copyPastePlugin(editorContext)],
  });

  editorView.updateState(newState);
2 Likes