import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { isUrl } from "../../utils/urls";
import Extension from "../lib/Extension";
import isMarkdown from "../lib/isMarkdown";
import normalizePastedMarkdown from "../lib/markdown/normalize";
import isInCode from "../queries/isInCode";
import isInList from "../queries/isInList";
import { LANGUAGES } from "./Prism";
function isDropboxPaper(html: string): boolean {
// The best we have to detect if a paste is likely coming from Paper
// In this case it's actually better to use the text version
return html?.includes("usually-unique-id");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
export default class PasteHandler extends Extension {
get name() {
return "paste-handler";
}
get plugins() {
return [
new Plugin({
props: {
transformPastedHTML(html: string) {
if (isDropboxPaper(html)) {
// Fixes double paragraphs when pasting from Dropbox Paper
html = html.replace(/
<\/div>/gi, "
");
}
return html;
},
handleDOMEvents: {
keydown: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = true;
}
return false;
},
keyup: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = false;
}
return false;
},
},
handlePaste: (view, event: ClipboardEvent) => {
// Do nothing if the document isn't currently editable
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
// Default behavior if there is nothing on the clipboard or were
// special pasting with no formatting (Shift held)
if (!event.clipboardData || this.shiftKey) {
return false;
}
const text = event.clipboardData.getData("text/plain");
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
const { state, dispatch } = view;
// first check if the clipboard contents can be parsed as a single
// url, this is mainly for allowing pasted urls to become embeds
if (isUrl(text)) {
// just paste the link mark directly onto the selected text
if (!state.selection.empty) {
toggleMark(this.editor.schema.marks.link, { href: text })(
state,
dispatch
);
return true;
}
// Is this link embeddable? Create an embed!
const { embeds } = this.editor.props;
if (
embeds &&
this.editor.commands.embed &&
!isInCode(state) &&
!isInList(state)
) {
for (const embed of embeds) {
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({
href: text,
});
return true;
}
}
}
// well, it's not an embed and there is no text selected – so just
// go ahead and insert the link directly
const transaction = view.state.tr
.insertText(text, state.selection.from, state.selection.to)
.addMark(
state.selection.from,
state.selection.to + text.length,
state.schema.marks.link.create({ href: text })
);
view.dispatch(transaction);
return true;
}
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(view.state)) {
event.preventDefault();
view.dispatch(view.state.tr.insertText(text));
return true;
}
// Because VSCode is an especially popular editor that places metadata
// on the clipboard, we can parse it to find out what kind of content
// was pasted.
const vscodeMeta = vscode ? JSON.parse(vscode) : undefined;
const pasteCodeLanguage = vscodeMeta?.mode;
if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
event.preventDefault();
view.dispatch(
view.state.tr
.replaceSelectionWith(
view.state.schema.nodes.code_fence.create({
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
? vscodeMeta.mode
: null,
})
)
.insertText(text)
);
return true;
}
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
// If the text on the clipboard looks like Markdown OR there is no
// html on the clipboard then try to parse content as Markdown
if (
(isMarkdown(text) && !isDropboxPaper(html)) ||
pasteCodeLanguage === "markdown"
) {
event.preventDefault();
// get pasted content as slice
const paste = this.editor.pasteParser.parse(
normalizePastedMarkdown(text)
);
if (!paste) {
return false;
}
const slice = paste.slice(0);
const tr = view.state.tr;
let currentPos = view.state.selection.from;
// If the pasted content is a single paragraph then we loop over
// it's content and insert each node one at a time to allow it to
// be pasted inline with surrounding content.
const singleNode = sliceSingleNode(slice);
if (singleNode?.type === this.editor.schema.nodes.paragraph) {
singleNode.forEach((node) => {
tr.insert(currentPos, node);
currentPos += node.nodeSize;
});
} else {
singleNode
? tr.replaceSelectionWith(singleNode, this.shiftKey)
: tr.replaceSelection(slice);
}
view.dispatch(
tr
.scrollIntoView()
.setMeta("paste", true)
.setMeta("uiEvent", "paste")
);
return true;
}
// otherwise use the default HTML parser which will handle all paste
// "from the web" events
return false;
},
},
}),
];
}
/** Tracks whether the Shift key is currently held down */
private shiftKey = false;
}