@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
text/typescript
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;
},
},
},
}),
];
},
});