fix: Emojis and embeds cannot be copied to plain text clipboard (#3561)
This commit is contained in:
43
shared/editor/lib/textBetween.ts
Normal file
43
shared/editor/lib/textBetween.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ export default class Attachment extends Node {
|
|||||||
node.attrs.title,
|
node.attrs.title,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
toPlainText: (node) => node.attrs.title,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default class Embed extends Node {
|
|||||||
{ class: "embed", src: node.attrs.href, contentEditable: "false" },
|
{ class: "embed", src: node.attrs.href, contentEditable: "false" },
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
|
toPlainText: (node) => node.attrs.href,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default class Emoji extends Node {
|
|||||||
const text = document.createTextNode(`:${node.attrs["data-name"]}:`);
|
const text = document.createTextNode(`:${node.attrs["data-name"]}:`);
|
||||||
return ["span", { class: "emoji" }, text];
|
return ["span", { class: "emoji" }, text];
|
||||||
},
|
},
|
||||||
|
toPlainText: (node) => nameToEmoji[node.attrs["data-name"]],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ export default class HardBreak extends Node {
|
|||||||
group: "inline",
|
group: "inline",
|
||||||
selectable: false,
|
selectable: false,
|
||||||
parseDOM: [{ tag: "br" }],
|
parseDOM: [{ tag: "br" }],
|
||||||
toDOM() {
|
toDOM: () => ["br"],
|
||||||
return ["br"];
|
toPlainText: () => "\n",
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { InputRule } from "prosemirror-inputrules";
|
import { InputRule } from "prosemirror-inputrules";
|
||||||
import { TokenConfig } from "prosemirror-markdown";
|
import { TokenConfig } from "prosemirror-markdown";
|
||||||
import {
|
import {
|
||||||
Node as ProsemirrorNode,
|
|
||||||
NodeSpec,
|
NodeSpec,
|
||||||
|
Node as ProsemirrorNode,
|
||||||
NodeType,
|
NodeType,
|
||||||
Schema,
|
Schema,
|
||||||
} from "prosemirror-model";
|
} from "prosemirror-model";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Image from "../nodes/Image";
|
|||||||
import Node from "../nodes/Node";
|
import Node from "../nodes/Node";
|
||||||
import Paragraph from "../nodes/Paragraph";
|
import Paragraph from "../nodes/Paragraph";
|
||||||
import Text from "../nodes/Text";
|
import Text from "../nodes/Text";
|
||||||
|
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
|
||||||
import DateTime from "../plugins/DateTime";
|
import DateTime from "../plugins/DateTime";
|
||||||
import History from "../plugins/History";
|
import History from "../plugins/History";
|
||||||
import MaxLength from "../plugins/MaxLength";
|
import MaxLength from "../plugins/MaxLength";
|
||||||
@@ -41,6 +42,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
|||||||
Placeholder,
|
Placeholder,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
ClipboardTextSerializer,
|
||||||
];
|
];
|
||||||
|
|
||||||
export default basicPackage;
|
export default basicPackage;
|
||||||
|
|||||||
38
shared/editor/plugins/ClipboardTextSerializer.ts
Normal file
38
shared/editor/plugins/ClipboardTextSerializer.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { EditorState, Transaction } from "prosemirror-state";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultTheme } from "styled-components";
|
import { DefaultTheme } from "styled-components";
|
||||||
|
|
||||||
|
export type PlainTextSerializer = (node: ProsemirrorNode) => string;
|
||||||
|
|
||||||
export enum EventType {
|
export enum EventType {
|
||||||
blockMenuOpen = "blockMenuOpen",
|
blockMenuOpen = "blockMenuOpen",
|
||||||
blockMenuClose = "blockMenuClose",
|
blockMenuClose = "blockMenuClose",
|
||||||
|
|||||||
8
shared/typings/prosemirror-model.d.ts
vendored
8
shared/typings/prosemirror-model.d.ts
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
import { PlainTextSerializer } from "../editor/types";
|
||||||
import "prosemirror-model";
|
import "prosemirror-model";
|
||||||
|
|
||||||
declare module "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
|
// https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51
|
||||||
removeBetween(from: number, to: number): Slice;
|
removeBetween(from: number, to: number): Slice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NodeSpec {
|
||||||
|
/**
|
||||||
|
* Defines the text representation of the node when copying to clipboard.
|
||||||
|
*/
|
||||||
|
toPlainText?: PlainTextSerializer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user