fix: Emojis and embeds cannot be copied to plain text clipboard (#3561)

This commit is contained in:
Tom Moor
2022-05-20 09:47:13 -07:00
committed by GitHub
parent 90ca8655af
commit ba7b3fff05
10 changed files with 99 additions and 4 deletions

View File

@@ -0,0 +1,43 @@
import { Node as ProseMirrorNode } from "prosemirror-model";
import { PlainTextSerializer } from "../types";
/**
* Returns the text content between two positions.
*
* @param doc The Prosemirror document to use
* @param from A start point
* @param to An end point
* @param plainTextSerializers A map of node names to PlainTextSerializers which convert a node to plain text
* @returns A string of plain text
*/
export default function textBetween(
doc: ProseMirrorNode,
from: number,
to: number,
plainTextSerializers: Record<string, PlainTextSerializer | undefined>
): string {
const blockSeparator = "\n\n";
let text = "";
let separated = true;
doc.nodesBetween(from, to, (node, pos) => {
const toPlainText = plainTextSerializers[node.type.name];
if (toPlainText) {
if (node.isBlock && !separated) {
text += blockSeparator;
separated = true;
}
text += toPlainText(node);
} else if (node.isText) {
text += node.text?.slice(Math.max(from, pos) - pos, to - pos);
separated = false;
} else if (node.isBlock && !separated) {
text += blockSeparator;
separated = true;
}
});
return text;
}

View File

@@ -63,6 +63,7 @@ export default class Attachment extends Node {
node.attrs.title,
];
},
toPlainText: (node) => node.attrs.title,
};
}

View File

@@ -50,6 +50,7 @@ export default class Embed extends Node {
{ class: "embed", src: node.attrs.href, contentEditable: "false" },
0,
],
toPlainText: (node) => node.attrs.href,
};
}

View File

@@ -59,6 +59,7 @@ export default class Emoji extends Node {
const text = document.createTextNode(`:${node.attrs["data-name"]}:`);
return ["span", { class: "emoji" }, text];
},
toPlainText: (node) => nameToEmoji[node.attrs["data-name"]],
};
}

View File

@@ -17,9 +17,8 @@ export default class HardBreak extends Node {
group: "inline",
selectable: false,
parseDOM: [{ tag: "br" }],
toDOM() {
return ["br"];
},
toDOM: () => ["br"],
toPlainText: () => "\n",
};
}

View File

@@ -1,8 +1,8 @@
import { InputRule } from "prosemirror-inputrules";
import { TokenConfig } from "prosemirror-markdown";
import {
Node as ProsemirrorNode,
NodeSpec,
Node as ProsemirrorNode,
NodeType,
Schema,
} from "prosemirror-model";

View File

@@ -13,6 +13,7 @@ import Image from "../nodes/Image";
import Node from "../nodes/Node";
import Paragraph from "../nodes/Paragraph";
import Text from "../nodes/Text";
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
import DateTime from "../plugins/DateTime";
import History from "../plugins/History";
import MaxLength from "../plugins/MaxLength";
@@ -41,6 +42,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Placeholder,
MaxLength,
DateTime,
ClipboardTextSerializer,
];
export default basicPackage;

View File

@@ -0,0 +1,38 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "../lib/Extension";
import textBetween from "../lib/textBetween";
/**
* A plugin that allows overriding the default behavior of the editor to allow
* copying text for nodes that do not inherently have text children by defining
* a `toPlainText` method in the node spec.
*/
export default class ClipboardTextSerializer extends Extension {
get name() {
return "clipboardTextSerializer";
}
get plugins() {
const textSerializers = Object.fromEntries(
Object.entries(this.editor.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: () => {
const { doc, selection } = this.editor.view.state;
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
return textBetween(doc, from, to, textSerializers);
},
},
}),
];
}
}

View File

@@ -3,6 +3,8 @@ import { EditorState, Transaction } from "prosemirror-state";
import * as React from "react";
import { DefaultTheme } from "styled-components";
export type PlainTextSerializer = (node: ProsemirrorNode) => string;
export enum EventType {
blockMenuOpen = "blockMenuOpen",
blockMenuClose = "blockMenuClose",

View File

@@ -1,3 +1,4 @@
import { PlainTextSerializer } from "../editor/types";
import "prosemirror-model";
declare module "prosemirror-model" {
@@ -7,4 +8,11 @@ declare module "prosemirror-model" {
// https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51
removeBetween(from: number, to: number): Slice;
}
interface NodeSpec {
/**
* Defines the text representation of the node when copying to clipboard.
*/
toPlainText?: PlainTextSerializer;
}
}