diff --git a/shared/editor/lib/textBetween.ts b/shared/editor/lib/textBetween.ts new file mode 100644 index 0000000000..4a3fda3011 --- /dev/null +++ b/shared/editor/lib/textBetween.ts @@ -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 { + 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; +} diff --git a/shared/editor/nodes/Attachment.tsx b/shared/editor/nodes/Attachment.tsx index 66c5a17cf6..00cff0563e 100644 --- a/shared/editor/nodes/Attachment.tsx +++ b/shared/editor/nodes/Attachment.tsx @@ -63,6 +63,7 @@ export default class Attachment extends Node { node.attrs.title, ]; }, + toPlainText: (node) => node.attrs.title, }; } diff --git a/shared/editor/nodes/Embed.tsx b/shared/editor/nodes/Embed.tsx index 87b74a173f..a6d4de59da 100644 --- a/shared/editor/nodes/Embed.tsx +++ b/shared/editor/nodes/Embed.tsx @@ -50,6 +50,7 @@ export default class Embed extends Node { { class: "embed", src: node.attrs.href, contentEditable: "false" }, 0, ], + toPlainText: (node) => node.attrs.href, }; } diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx index a93c95601d..f07c46a5ff 100644 --- a/shared/editor/nodes/Emoji.tsx +++ b/shared/editor/nodes/Emoji.tsx @@ -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"]], }; } diff --git a/shared/editor/nodes/HardBreak.ts b/shared/editor/nodes/HardBreak.ts index 3ad7297e0b..ecd3248ea1 100644 --- a/shared/editor/nodes/HardBreak.ts +++ b/shared/editor/nodes/HardBreak.ts @@ -17,9 +17,8 @@ export default class HardBreak extends Node { group: "inline", selectable: false, parseDOM: [{ tag: "br" }], - toDOM() { - return ["br"]; - }, + toDOM: () => ["br"], + toPlainText: () => "\n", }; } diff --git a/shared/editor/nodes/Node.ts b/shared/editor/nodes/Node.ts index 7657e658a0..cc0e49b1e4 100644 --- a/shared/editor/nodes/Node.ts +++ b/shared/editor/nodes/Node.ts @@ -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"; diff --git a/shared/editor/packages/basic.ts b/shared/editor/packages/basic.ts index 70c05b330a..b6876dc55f 100644 --- a/shared/editor/packages/basic.ts +++ b/shared/editor/packages/basic.ts @@ -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; diff --git a/shared/editor/plugins/ClipboardTextSerializer.ts b/shared/editor/plugins/ClipboardTextSerializer.ts new file mode 100644 index 0000000000..7f4dfc9c14 --- /dev/null +++ b/shared/editor/plugins/ClipboardTextSerializer.ts @@ -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); + }, + }, + }), + ]; + } +} diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 069d66348a..88f1743ee1 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -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", diff --git a/shared/typings/prosemirror-model.d.ts b/shared/typings/prosemirror-model.d.ts index faeab867da..99750021b2 100644 --- a/shared/typings/prosemirror-model.d.ts +++ b/shared/typings/prosemirror-model.d.ts @@ -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; + } }