UNPKG

@blocknote/core

Version:

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

462 lines (394 loc) 12.4 kB
import { Node, mergeAttributes } from "@tiptap/core"; import { DOMParser, Fragment, Node as PMNode, Schema } from "prosemirror-model"; import { CellSelection, TableView } from "prosemirror-tables"; import { NodeView } from "prosemirror-view"; import { createExtension } from "../../editor/BlockNoteExtension.js"; import { BlockConfig, createBlockSpecFromTiptapNode, TableContent, } from "../../schema/index.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; import { EMPTY_CELL_WIDTH, TableExtension } from "./TableExtension.js"; export const tablePropSchema = { textColor: defaultProps.textColor, }; const TiptapTableHeader = Node.create<{ HTMLAttributes: Record<string, any>; }>({ name: "tableHeader", addOptions() { return { HTMLAttributes: {}, }; }, /** * We allow table headers and cells to have multiple tableContent nodes because * when merging cells, prosemirror-tables will concat the contents of the cells naively. * This would cause that content to overflow into other cells when prosemirror tries to enforce the cell structure. * * So, we manually fix this up when reading back in the `nodeToBlock` and only ever place a single tableContent back into the cell. */ content: "tableContent+", addAttributes() { return { colspan: { default: 1, }, rowspan: { default: 1, }, colwidth: { default: null, parseHTML: (element) => { const colwidth = element.getAttribute("colwidth"); const value = colwidth ? colwidth.split(",").map((width) => parseInt(width, 10)) : null; return value; }, }, }; }, tableRole: "header_cell", isolating: true, parseHTML() { return [ { tag: "th", // As `th` elements can contain multiple paragraphs, we need to merge their contents // into a single one so that ProseMirror can parse everything correctly. getContent: (node, schema) => parseTableContent(node as HTMLElement, schema), }, ]; }, renderHTML({ HTMLAttributes }) { return [ "th", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0, ]; }, }); const TiptapTableCell = Node.create<{ HTMLAttributes: Record<string, any>; }>({ name: "tableCell", addOptions() { return { HTMLAttributes: {}, }; }, content: "tableContent+", addAttributes() { return { colspan: { default: 1, }, rowspan: { default: 1, }, colwidth: { default: null, parseHTML: (element) => { const colwidth = element.getAttribute("colwidth"); const value = colwidth ? colwidth.split(",").map((width) => parseInt(width, 10)) : null; return value; }, }, }; }, tableRole: "cell", isolating: true, parseHTML() { return [ { tag: "td", // As `td` elements can contain multiple paragraphs, we need to merge their contents // into a single one so that ProseMirror can parse everything correctly. getContent: (node, schema) => parseTableContent(node as HTMLElement, schema), }, ]; }, renderHTML({ HTMLAttributes }) { return [ "td", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0, ]; }, }); const TiptapTableNode = Node.create({ name: "table", content: "tableRow+", group: "blockContent", tableRole: "table", marks: "deletion insertion modification", isolating: true, parseHTML() { return [ { tag: "table", }, ]; }, renderHTML({ node, HTMLAttributes }) { const domOutputSpec = createDefaultBlockDOMOutputSpec( this.name, "table", { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, }, this.options.domAttributes?.inlineContent || {}, ); // Need to manually add colgroup element const colGroup = document.createElement("colgroup"); for (const tableCell of node.children[0].children) { const colWidths: null | (number | undefined)[] = tableCell.attrs["colwidth"]; if (colWidths) { for (const colWidth of tableCell.attrs["colwidth"]) { const col = document.createElement("col"); if (colWidth) { col.style = `width: ${colWidth}px`; } colGroup.appendChild(col); } } else { colGroup.appendChild(document.createElement("col")); } } domOutputSpec.dom.firstChild?.appendChild(colGroup); return domOutputSpec; }, // This node view is needed for the `columnResizing` plugin. By default, the // plugin adds its own node view, which overrides how the node is rendered vs // `renderHTML`. This means that the wrapping `blockContent` HTML element is // no longer rendered. The `columnResizing` plugin uses the `TableView` as its // default node view. `BlockNoteTableView` extends it by wrapping it in a // `blockContent` element, so the DOM structure is consistent with other block // types. addNodeView() { return ({ node, HTMLAttributes }) => { class BlockNoteTableView extends TableView { constructor( public node: PMNode, public cellMinWidth: number, public blockContentHTMLAttributes: Record<string, string>, ) { super(node, cellMinWidth); const blockContent = document.createElement("div"); blockContent.className = mergeCSSClasses( "bn-block-content", blockContentHTMLAttributes.class, ); blockContent.setAttribute("data-content-type", "table"); for (const [attribute, value] of Object.entries( blockContentHTMLAttributes, )) { if (attribute !== "class") { blockContent.setAttribute(attribute, value); } } const tableWrapper = this.dom; const tableWrapperInner = document.createElement("div"); tableWrapperInner.className = "tableWrapper-inner"; tableWrapperInner.appendChild(tableWrapper.firstChild!); tableWrapper.appendChild(tableWrapperInner); blockContent.appendChild(tableWrapper); const floatingContainer = document.createElement("div"); floatingContainer.className = "table-widgets-container"; floatingContainer.style.position = "relative"; tableWrapper.appendChild(floatingContainer); this.dom = blockContent; } ignoreMutation(record: MutationRecord): boolean { return ( !(record.target as HTMLElement).closest(".tableWrapper-inner") || super.ignoreMutation(record) ); } } return new BlockNoteTableView(node, EMPTY_CELL_WIDTH, { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, }) as NodeView; // needs cast, tiptap types (wrongly) doesn't support return tableview here }; }, }); const TiptapTableParagraph = Node.create({ name: "tableParagraph", group: "tableContent", content: "inline*", parseHTML() { return [ { tag: "p", getAttrs: (element) => { if (typeof element === "string" || !element.textContent) { return false; } // Only parse in internal HTML. if (!element.closest("[data-content-type]")) { return false; } const parent = element.parentElement; if (parent === null) { return false; } if (parent.tagName === "TD" || parent.tagName === "TH") { return {}; } return false; }, node: "tableParagraph", }, ]; }, renderHTML({ HTMLAttributes }) { return ["p", HTMLAttributes, 0]; }, }); /** * This extension allows you to create table rows. * @see https://www.tiptap.dev/api/nodes/table-row */ const TiptapTableRow = Node.create<{ HTMLAttributes: Record<string, any>; }>({ name: "tableRow", addOptions() { return { HTMLAttributes: {}, }; }, content: "(tableCell | tableHeader)+", tableRole: "row", marks: "deletion insertion modification", parseHTML() { return [{ tag: "tr" }]; }, renderHTML({ HTMLAttributes }) { return [ "tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0, ]; }, }); /* * This will flatten a node's content to fit into a table cell's paragraph. */ function parseTableContent(node: HTMLElement, schema: Schema) { const parser = DOMParser.fromSchema(schema); // This will parse the content of the table paragraph as though it were a blockGroup. // Resulting in a structure like: // <blockGroup> // <blockContainer> // <p>Hello</p> // </blockContainer> // <blockContainer> // <p>Hello</p> // </blockContainer> // </blockGroup> const parsedContent = parser.parse(node, { topNode: schema.nodes.blockGroup.create(), }); const extractedContent: PMNode[] = []; // Try to extract any content within the blockContainer. parsedContent.content.descendants((child) => { // As long as the child is an inline node, we can append it to the fragment. if (child.isInline) { // And append it to the fragment extractedContent.push(child); return false; } return undefined; }); return Fragment.fromArray(extractedContent); } export type TableBlockConfig = BlockConfig< "table", { textColor: { default: "default"; }; }, "table" >; export const createTableBlockSpec = () => createBlockSpecFromTiptapNode( { node: TiptapTableNode, type: "table", content: "table" }, tablePropSchema, [ createExtension({ key: "table-extensions", tiptapExtensions: [ TableExtension, TiptapTableParagraph, TiptapTableHeader, TiptapTableCell, TiptapTableRow, ], }), // Extension for keyboard shortcut which deletes the table if it's empty // and all cells are selected. Uses a separate extension as it needs // priority over keyboard handlers in the `TableExtension`'s // `tableEditing` plugin. createExtension({ key: "table-keyboard-delete", keyboardShortcuts: { Backspace: ({ editor }) => { if (!(editor.prosemirrorState.selection instanceof CellSelection)) { return false; } const block = editor.getTextCursorPosition().block; const content = block.content as TableContent<any, any>; let numCells = 0; for (const row of content.rows) { for (const cell of row.cells) { // Returns `false` if any cell isn't empty. if ( ("type" in cell && cell.content.length > 0) || (!("type" in cell) && cell.length > 0) ) { return false; } numCells++; } } // Need to use ProseMirror API to check number of selected cells. let selectionNumCells = 0; editor.prosemirrorState.selection.forEachCell(() => { selectionNumCells++; }); if (selectionNumCells < numCells) { return false; } editor.transact(() => { const selectionBlock = editor.getPrevBlock(block) || editor.getNextBlock(block); if (selectionBlock) { editor.setTextCursorPosition(block); } editor.removeBlocks([block]); }); return true; }, }, }), ], ); // We need to declare this here because we aren't using the table extensions from tiptap, so the types are not automatically inferred. declare module "@tiptap/core" { interface NodeConfig { tableRole?: string; } }