UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

286 lines (253 loc) 9.18 kB
import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; import { NodeSelection, Plugin } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; import type { EditorView } from "prosemirror-view"; import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { BlockSchema, InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; import { createExternalHTMLExporter } from "../../exporters/html/externalHTMLExporter.js"; import { cleanHTMLToMarkdown } from "../../exporters/markdown/markdownExporter.js"; import { fragmentToBlocks } from "../../nodeConversions/fragmentToBlocks.js"; import { contentNodeToInlineContent, contentNodeToTableContent, } from "../../nodeConversions/nodeToBlock.js"; import { initializeESMDependencies } from "../../../util/esmDependencies.js"; function fragmentToExternalHTML< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( view: EditorView, selectedFragment: Fragment, editor: BlockNoteEditor<BSchema, I, S>, ) { let isWithinBlockContent = false; const isWithinTable = view.state.selection instanceof CellSelection; if (!isWithinTable) { // Checks whether block ancestry should be included when creating external // HTML. If the selection is within a block content node, the block ancestry // is excluded as we only care about the inline content. const fragmentWithoutParents = view.state.doc.slice( view.state.selection.from, view.state.selection.to, false, ).content; const children = []; for (let i = 0; i < fragmentWithoutParents.childCount; i++) { children.push(fragmentWithoutParents.child(i)); } isWithinBlockContent = children.find( (child) => child.type.isInGroup("bnBlock") || child.type.name === "blockGroup" || child.type.spec.group === "blockContent", ) === undefined; if (isWithinBlockContent) { selectedFragment = fragmentWithoutParents; } } let externalHTML: string; const externalHTMLExporter = createExternalHTMLExporter( view.state.schema, editor, ); if (isWithinTable) { if (selectedFragment.firstChild?.type.name === "table") { // contentNodeToTableContent expects the fragment of the content of a table, not the table node itself // but cellselection.content() returns the table node itself if all cells and columns are selected selectedFragment = selectedFragment.firstChild.content; } // first convert selection to blocknote-style table content, and then // pass this to the exporter const ic = contentNodeToTableContent( selectedFragment as any, editor.schema.inlineContentSchema, editor.schema.styleSchema, ); // Wrap in table to ensure correct parsing by spreadsheet applications externalHTML = `<table>${externalHTMLExporter.exportInlineContent( ic as any, {}, )}</table>`; } else if (isWithinBlockContent) { // first convert selection to blocknote-style inline content, and then // pass this to the exporter const ic = contentNodeToInlineContent( selectedFragment as any, editor.schema.inlineContentSchema, editor.schema.styleSchema, ); externalHTML = externalHTMLExporter.exportInlineContent(ic, {}); } else { const blocks = fragmentToBlocks(selectedFragment); externalHTML = externalHTMLExporter.exportBlocks(blocks, {}); } return externalHTML; } export function selectedFragmentToHTML< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( view: EditorView, editor: BlockNoteEditor<BSchema, I, S>, ): { clipboardHTML: string; externalHTML: string; markdown: string; } { // Checks if a `blockContent` node is being copied and expands // the selection to the parent `blockContainer` node. This is // for the use-case in which only a block without content is // selected, e.g. an image block. if ( "node" in view.state.selection && (view.state.selection.node as Node).type.spec.group === "blockContent" ) { editor.transact((tr) => tr.setSelection( new NodeSelection(tr.doc.resolve(view.state.selection.from - 1)), ), ); } // Uses default ProseMirror clipboard serialization. const clipboardHTML: string = view.serializeForClipboard( view.state.selection.content(), ).dom.innerHTML; const selectedFragment = view.state.selection.content().content; const externalHTML = fragmentToExternalHTML<BSchema, I, S>( view, selectedFragment, editor, ); const markdown = cleanHTMLToMarkdown(externalHTML); return { clipboardHTML, externalHTML, markdown }; } const checkIfSelectionInNonEditableBlock = () => { // Let browser handle event if selection is empty (nothing // happens). const selection = window.getSelection(); if (!selection || selection.isCollapsed) { return true; } // Let browser handle event if it's within a non-editable // "island". This means it's in selectable content within a // non-editable block. We only need to check one node as it's // not possible for the browser selection to start in an // editable block and end in a non-editable one. let node = selection.focusNode; while (node) { if ( node instanceof HTMLElement && node.getAttribute("contenteditable") === "false" ) { return true; } node = node.parentElement; } return false; }; const copyToClipboard = < BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( editor: BlockNoteEditor<BSchema, I, S>, view: EditorView, event: ClipboardEvent, ) => { // Stops the default browser copy behaviour. event.preventDefault(); event.clipboardData!.clearData(); const { clipboardHTML, externalHTML, markdown } = selectedFragmentToHTML( view, editor, ); // TODO: Writing to other MIME types not working in Safari for // some reason. event.clipboardData!.setData("blocknote/html", clipboardHTML); event.clipboardData!.setData("text/html", externalHTML); event.clipboardData!.setData("text/plain", markdown); }; export const createCopyToClipboardExtension = < BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( editor: BlockNoteEditor<BSchema, I, S>, ) => Extension.create<{ editor: BlockNoteEditor<BSchema, I, S> }, undefined>({ name: "copyToClipboard", addProseMirrorPlugins() { initializeESMDependencies(); return [ new Plugin({ props: { handleDOMEvents: { copy(view, event) { if (checkIfSelectionInNonEditableBlock()) { return true; } copyToClipboard(editor, view, event); // Prevent default PM handler to be called return true; }, cut(view, event) { if (checkIfSelectionInNonEditableBlock()) { return true; } copyToClipboard(editor, view, event); if (view.editable) { view.dispatch(view.state.tr.deleteSelection()); } // Prevent default PM handler to be called return true; }, // This is for the use-case in which only a block without content // is selected, e.g. an image block, and dragged (not using the // drag handle). dragstart(view, event) { // Checks if a `NodeSelection` is active. if (!("node" in view.state.selection)) { return; } // Checks if a `blockContent` node is being dragged. if ( (view.state.selection.node as Node).type.spec.group !== "blockContent" ) { return; } // Expands the selection to the parent `blockContainer` node. editor.transact((tr) => tr.setSelection( new NodeSelection( tr.doc.resolve(view.state.selection.from - 1), ), ), ); // Stops the default browser drag start behaviour. event.preventDefault(); event.dataTransfer!.clearData(); const { clipboardHTML, externalHTML, markdown } = selectedFragmentToHTML(view, editor); // TODO: Writing to other MIME types not working in Safari for // some reason. event.dataTransfer!.setData("blocknote/html", clipboardHTML); event.dataTransfer!.setData("text/html", externalHTML); event.dataTransfer!.setData("text/plain", markdown); // Prevent default PM handler to be called return true; }, }, }, }), ]; }, });