Files
outline/shared/editor/extensions/PasteHandler.ts
2023-05-24 19:24:05 -07:00

212 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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><br><\/div>/gi, "<p></p>");
}
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;
}