Modify specific node on copy and paste (in clipboard)

Hi there :wave:

My use case is as follows: I have a custom node, which can be copy-pasted between different documents but it requires external data to work correct (not everything is kept in its attrs). In turn, new document would recreate this node without all data.

My idea to fix this problem was to add temporary attribute like copyPasteData which should live only in the clipboard. I wanted to update this specific node for Clipboard and then handle it on paste event (removing the additional attribute and storing the data externally again).

I got stuck, however. I started with clipboardSerializer where I’d create a custom serializer which adds new attr and then go to transformPasted where using custom DOMParser I’d parse it back without the attribute and pull data out of it.

Is this even a valid approach to use DOMSerializer and DOMParser, or I didn’t understand the concepts here? Is there a simpler way I missed?

Thanks

2 Likes

transformPasted/transformCopied (requires prosemirror-view 1.28.0) might be an easier way to do this.

Thanks for a quick reply! I’m, on 1.20.3 with prosemirror-view and I see there has been some bigger changes. If I decide to bump is there something in particular I should like double check?

Also, if I fail to bump pm-view for now, can my approach even work because I’m not sure anymore :thinking:

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