import { action, observable } from "mobx"; import { toggleMark } from "prosemirror-commands"; import { Node, Slice } from "prosemirror-model"; import { EditorState, Plugin, PluginKey, TextSelection, } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { v4 } from "uuid"; import Extension, { WidgetProps } from "@shared/editor/lib/Extension"; import { codeLanguages } from "@shared/editor/lib/code"; import isMarkdown from "@shared/editor/lib/isMarkdown"; import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; import { isRemoteTransaction } from "@shared/editor/lib/multiplayer"; import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform"; import { isInCode } from "@shared/editor/queries/isInCode"; import { isList } from "@shared/editor/queries/isList"; import { MenuItem } from "@shared/editor/types"; import { IconType, MentionType } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; import parseCollectionSlug from "@shared/utils/parseCollectionSlug"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls"; import stores from "~/stores"; import { PasteMenu } from "../components/PasteMenu"; export default class PasteHandler extends Extension { state: { open: boolean; query: string; pastedText: string | string[]; } = observable({ open: false, query: "", pastedText: "", }); get name() { return "paste-handler"; } private key = new PluginKey(this.name); get plugins() { return [ new Plugin({ key: this.key, 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 or there is no clipboard data if (!view.editable || !event.clipboardData) { return false; } const { state, dispatch } = view; const iframeSrc = parseSingleIframeSrc( event.clipboardData.getData("text/plain") ); const text = iframeSrc && !isInCode(state) ? iframeSrc : event.clipboardData.getData("text/plain"); const html = event.clipboardData.getData("text/html"); const vscode = event.clipboardData.getData("vscode-editor-data"); // If the users selection is currently in a code block then paste // as plain text, ignore all formatting and HTML content. if (isInCode(state, { inclusive: true })) { event.preventDefault(); view.dispatch(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; const supportsCodeBlock = !!state.schema.nodes.code_block; const supportsCodeMark = !!state.schema.marks.code_inline; if (!this.shiftKey) { // 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; } // Check if the clipboard contents can be parsed as a single url if (isUrl(text)) { // If there is selected text then we want to wrap it in a link to the url if (!state.selection.empty) { toggleMark(this.editor.schema.marks.link, { href: text })( state, dispatch ); return true; } // Is the link a link to a document? If so, we can grab the title and insert it. const containsHash = text.includes("#"); if (isDocumentUrl(text)) { const slug = parseDocumentSlug(text); if (slug) { void stores.documents .fetch(slug) .then((document) => { if (view.isDestroyed) { return; } if (document) { if (state.schema.nodes.mention && !containsHash) { view.dispatch( view.state.tr.replaceWith( state.selection.from, state.selection.to, state.schema.nodes.mention.create({ type: MentionType.Document, modelId: document.id, label: document.titleWithDefault, id: v4(), }) ) ); } else { const { hash } = new URL(text); const hasEmoji = determineIconType(document.icon) === IconType.Emoji; const title = `${ hasEmoji ? document.icon + " " : "" }${document.titleWithDefault}`; this.insertLink(`${document.path}${hash}`, title); } } }) .catch(() => { if (view.isDestroyed) { return; } this.insertLink(text); }); } } else if (isCollectionUrl(text)) { const slug = parseCollectionSlug(text); if (slug) { stores.collections .fetch(slug) .then((collection) => { if (view.isDestroyed) { return; } if (collection) { if (state.schema.nodes.mention && !containsHash) { view.dispatch( view.state.tr.replaceWith( state.selection.from, state.selection.to, state.schema.nodes.mention.create({ type: MentionType.Collection, modelId: collection.id, label: collection.name, id: v4(), }) ) ); } else { const { hash } = new URL(text); const hasEmoji = determineIconType(collection.icon) === IconType.Emoji; const title = `${ hasEmoji ? collection.icon + " " : "" }${collection.name}`; this.insertLink(`${collection.path}${hash}`, title); } } }) .catch(() => { if (view.isDestroyed) { return; } this.insertLink(text); }); } } else { this.insertLink(text); } return true; } if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") { if (text.includes("\n") && supportsCodeBlock) { event.preventDefault(); view.dispatch( state.tr .replaceSelectionWith( state.schema.nodes.code_block.create({ language: Object.keys(codeLanguages).includes( vscodeMeta.mode ) ? vscodeMeta.mode : null, }) ) .insertText(text) ); return true; } if (supportsCodeMark) { event.preventDefault(); view.dispatch( state.tr .insertText( text, state.selection.from, state.selection.to ) .addMark( state.selection.from, state.selection.to + text.length, state.schema.marks.code_inline.create() ) ); return true; } } } // 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) && !isContainingImage(html)) || pasteCodeLanguage === "markdown" || this.shiftKey || !html ) { 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 { if (singleNode) { if (isList(singleNode, this.editor.schema)) { this.handleList(singleNode); return true; } else { tr.replaceSelectionWith(singleNode, this.shiftKey); } } else { 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; }, }, state: { init: () => DecorationSet.empty, apply: (tr, set) => { let mapping = tr.mapping; // See if the transaction adds or removes any placeholders const meta = tr.getMeta(this.key); const hasDecorations = set.find().length; // We only want a single paste placeholder at a time, so if we're adding a new // placeholder we can just return a new DecorationSet and avoid mapping logic. if (meta?.add) { const { from, to, id } = meta.add; const decorations = [ Decoration.inline( from, to, { class: "paste-placeholder", }, { id, } ), ]; return DecorationSet.create(tr.doc, decorations); } if (hasDecorations && (isRemoteTransaction(tr) || meta)) { try { mapping = recreateTransform(tr.before, tr.doc, { complexSteps: true, wordDiffs: false, simplifyDiff: true, }).mapping; } catch (err) { // oxlint-disable-next-line no-console console.warn("Failed to recreate transform: ", err); } } set = set.map(mapping, tr.doc); if (meta?.remove) { const { id } = meta.remove; const decorations = set.find( undefined, undefined, (spec) => spec.id === id ); return set.remove(decorations); } return set; }, }, }), ]; } private shiftKey = false; private showPasteMenu = action((text: string | string[]) => { this.state.pastedText = text; this.state.open = true; }); private hidePasteMenu = action(() => { this.state.open = false; }); private insertLink(href: string, title?: string) { const { view } = this.editor; const { state } = view; const { from } = state.selection; const to = from + (title ?? href).length; const transaction = view.state.tr .insertText(title ?? href, state.selection.from, state.selection.to) .addMark(from, to, state.schema.marks.link.create({ href })) .setMeta(this.key, { add: { from, to, id: href } }); view.dispatch(transaction); this.showPasteMenu(href); } private insertEmbed = () => { const { view } = this.editor; const { state } = view; const result = this.findPlaceholder(state, this.placeholderId()); if (result) { const tr = state.tr.deleteRange(result[0], result[1]); view.dispatch( tr.setSelection(TextSelection.near(tr.doc.resolve(result[0]))) ); } this.editor.commands.embed({ href: this.state.pastedText as string, }); }; private insertMention = () => { const { view } = this.editor; const { state } = view; const result = this.findPlaceholder(state, this.placeholderId()); // Remove just the placeholder here. // Mention node will be created by SuggestionsMenu. if (result) { const tr = state.tr.deleteRange(result[0], result[1]); view.dispatch( tr.setSelection(TextSelection.near(tr.doc.resolve(result[0]))) ); } }; private insertMentionList = () => { const { view } = this.editor; const { state } = view; const result = this.findPlaceholder(state, this.placeholderId()); // Remove just the placeholder here. // Mention list will be created by SuggestionsMenu. if (result) { const tr = state.tr.setMeta(this.key, { remove: { id: this.placeholderId() }, }); view.dispatch( tr.setSelection(TextSelection.near(tr.doc.resolve(result[0]))) ); } }; private handleList(listNode: Node) { const { view, schema } = this.editor; const { state } = view; const { from } = state.selection; const links: string[] = []; let allLinks = true; listNode.descendants((node) => { if (!allLinks) { return false; } if (isList(node, schema) || node.type.name === "list_item") { return true; } if (node.type.name === "paragraph" && isUrl(node.textContent)) { links.push(node.textContent); return false; } allLinks = false; return false; }); if (!allLinks || !links.length) { return; } const placeholderId = links[0]; const to = from + listNode.nodeSize; const transaction = state.tr .replaceSelectionWith(listNode) .setMeta(this.key, { add: { from, to, id: placeholderId }, }); view.dispatch(transaction); this.showPasteMenu(links); } private placeholderId = () => typeof this.state.pastedText === "string" ? this.state.pastedText : this.state.pastedText[0]; private removePlaceholder = () => { const { view } = this.editor; const { state } = view; const placeholderId = this.placeholderId(); const result = this.findPlaceholder(state, placeholderId); if (result) { view.dispatch( state.tr.setMeta(this.key, { remove: { id: placeholderId }, }) ); } }; private findPlaceholder = ( state: EditorState, id: string ): [number, number] | null => { const decos = this.key.getState(state) as DecorationSet; const found = decos?.find(undefined, undefined, (spec) => spec.id === id); return found?.length ? [found[0].from, found[0].to] : null; }; private handleSelect = (item: MenuItem) => { switch (item.name) { case "noop": { this.hidePasteMenu(); this.removePlaceholder(); break; } case "embed": { this.hidePasteMenu(); this.insertEmbed(); break; } case "mention": { this.hidePasteMenu(); this.insertMention(); break; } case "mention_list": { this.hidePasteMenu(); this.insertMentionList(); break; } default: break; } }; keys() { return { Backspace: () => { this.hidePasteMenu(); return false; }, "Mod-z": () => { this.hidePasteMenu(); return false; }, }; } widget = ({ rtl }: WidgetProps) => ( ); } /** * Checks if the HTML string is likely coming from Dropbox Paper. * * @param html The HTML string to check. * @returns True if the HTML string is likely coming from Dropbox Paper. */ function isDropboxPaper(html: string): boolean { return html?.includes("usually-unique-id"); } /** * Checks if the HTML string contains an image. * * @param html The HTML string to check. * @returns True if the HTML string contains an image. */ function isContainingImage(html: string): boolean { return html?.includes("