From 9aa66576a86c21b8a424dc97e16ff5b896d1d05c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 22 Jun 2023 19:03:27 +0200 Subject: [PATCH 01/44] Added serialization for vanilla custom blocks --- packages/core/src/BlockNoteEditor.ts | 5 +- packages/core/src/BlockNoteExtensions.ts | 6 +- .../core/src/extensions/Blocks/api/block.ts | 178 +++++++++--------- .../src/extensions/Blocks/api/blockTypes.ts | 24 ++- .../extensions/Blocks/api/defaultBlocks.ts | 4 +- .../extensions/Blocks/api/serialization.ts | 137 +++++++++++--- tests/utils/customblocks/Image.tsx | 10 +- 7 files changed, 228 insertions(+), 136 deletions(-) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 5b86a5aaa4..366d81e877 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -58,7 +58,7 @@ export type BlockNoteEditorOptions = { * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashCommands: BaseSlashMenuItem[]; + slashCommands: BaseSlashMenuItem[]; /** * The HTML element that should be used as the parent element for the editor. @@ -183,7 +183,8 @@ export class BlockNoteEditor { const extensions = getBlockNoteExtensions({ editor: this, uiFactories: newOptions.uiFactories || {}, - slashCommands: newOptions.slashCommands || defaultSlashMenuItems, + // TODO: Fix typing + slashCommands: newOptions.slashCommands || (defaultSlashMenuItems as any), blockSchema: newOptions.blockSchema, collaboration: newOptions.collaboration, }); diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index f26deb9774..87c2f8b724 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -21,7 +21,7 @@ import { BackgroundColorExtension } from "./extensions/BackgroundColor/Backgroun import { BackgroundColorMark } from "./extensions/BackgroundColor/BackgroundColorMark"; import { blocks } from "./extensions/Blocks"; import { BlockSchema } from "./extensions/Blocks/api/blockTypes"; -import { CustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization"; +import { createCustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization"; import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; import { BlockSideMenuFactory } from "./extensions/DraggableBlocks/BlockSideMenuFactoryTypes"; import { createDraggableBlocksExtension } from "./extensions/DraggableBlocks/DraggableBlocksExtension"; @@ -54,7 +54,7 @@ export type UiFactories = Partial<{ export const getBlockNoteExtensions = (opts: { editor: BlockNoteEditor; uiFactories: UiFactories; - slashCommands: BaseSlashMenuItem[]; // couldn't fix type, see https://github.com/TypeCellOS/BlockNote/pull/191#discussion_r1210708771 + slashCommands: BaseSlashMenuItem[]; // couldn't fix type, see https://github.com/TypeCellOS/BlockNote/pull/191#discussion_r1210708771 blockSchema: BSchema; collaboration?: { fragment: Y.XmlFragment; @@ -110,7 +110,7 @@ export const getBlockNoteExtensions = (opts: { ...Object.values(opts.blockSchema).map((blockSpec) => blockSpec.node.configure({ editor: opts.editor }) ), - CustomBlockSerializerExtension, + createCustomBlockSerializerExtension(opts.editor), Dropcursor.configure({ width: 5, color: "#ddeeff" }), // This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command), diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index 77604299a9..18604d6f98 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -1,5 +1,5 @@ -import { Attribute, Node } from "@tiptap/core"; -import { BlockNoteEditor } from "../../.."; +import { Attribute, Editor, Node } from "@tiptap/core"; +import { BlockNoteEditor, SpecificBlock } from "../../.."; import styles from "../nodes/Block.module.css"; import { BlockConfig, @@ -20,7 +20,7 @@ export function propsToAttributes< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { [k in BType]: BlockSpec } >( blockConfig: Omit< BlockConfig, @@ -56,7 +56,7 @@ export function parse< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { [k in BType]: BlockSpec } >( blockConfig: Omit< BlockConfig, @@ -70,57 +70,94 @@ export function parse< ]; } -// Function that uses the 'render' function of a blockConfig to create a -// TipTap node's `renderHTML` property. Since custom blocks use node views, -// this is only used for serializing content to the clipboard. -export function render< +// Function that wraps the `dom` element returned from 'blockConfig.render' in a +// `blockContent` div, which contains the block type and props as HTML +// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds +// an `inlineContent` class to it. +export function renderWithBlockStructure< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { [k in BType]: BlockSpec } >( - blockConfig: Omit< - BlockConfig, - "render" - >, - HTMLAttributes: Record + render: BlockConfig["render"], + block: SpecificBlock, + editor: BlockNoteEditor ) { // Create blockContent element const blockContent = document.createElement("div"); + // Sets blockContent class + blockContent.className = styles.blockContent; // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", blockConfig.type); + blockContent.setAttribute("data-content-type", block.type); // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - blockContent.setAttribute(attribute, value); + for (const [prop, value] of Object.entries(block.props)) { + blockContent.setAttribute(camelToDataKebab(prop), value); } - // TODO: This only works for content copied within BlockNote. - // Creates contentDOM element to serialize inline content into. - let contentDOM: HTMLDivElement | undefined; - if (blockConfig.containsInlineContent) { - contentDOM = document.createElement("div"); - blockContent.appendChild(contentDOM); - } else { - contentDOM = undefined; + // Renders elements + const rendered = render(block, editor); + // Add inlineContent class to inline content + if ("contentDOM" in rendered) { + rendered.contentDOM.className = `${ + rendered.contentDOM.className + ? rendered.contentDOM.className + " " + styles.inlineContent + : styles.inlineContent + }`; } + // Adds elements to blockContent + blockContent.appendChild(rendered.dom); - return contentDOM !== undefined + return "contentDOM" in rendered ? { dom: blockContent, - contentDOM: contentDOM, + contentDOM: rendered.contentDOM, } : { dom: blockContent, }; } +export function getBlockFromPos< + BType extends string, + PSchema extends PropSchema, + BSchema extends BlockSchema & { [k in BType]: BlockSpec } +>( + getPos: (() => number) | boolean, + editor: BlockNoteEditor, + tipTapEditor: Editor, + type: BType +) { + // Gets position of the node + if (typeof getPos === "boolean") { + throw new Error( + "Cannot find node position as getPos is a boolean, not a function." + ); + } + const pos = getPos(); + // Gets parent blockContainer node + const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); + // Gets block identifier + const blockIdentifier = blockContainer.attrs.id; + // Gets the block + const block = editor.getBlock(blockIdentifier)! as SpecificBlock< + BSchema, + BType + >; + if (block.type !== type) { + throw new Error("Block type does not match"); + } + + return block; +} + // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createBlockSpec< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { [k in BType]: BlockSpec } >( blockConfig: BlockConfig ): BlockSpec { @@ -143,68 +180,24 @@ export function createBlockSpec< return parse(blockConfig); }, - renderHTML({ HTMLAttributes }) { - return render(blockConfig, HTMLAttributes); - }, - addNodeView() { - return ({ HTMLAttributes, getPos }) => { - // Create blockContent element - const blockContent = document.createElement("div"); - // Sets blockContent class - blockContent.className = styles.blockContent; - // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", blockConfig.type); - // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - blockContent.setAttribute(attribute, value); - } - - // Gets BlockNote editor instance - const editor = this.options.editor! as BlockNoteEditor< - BSchema & { [k in BType]: BlockSpec } - >; - // Gets position of the node - if (typeof getPos === "boolean") { - throw new Error( - "Cannot find node position as getPos is a boolean, not a function." - ); - } - const pos = getPos(); - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - - // Get the block - const block = editor.getBlock(blockIdentifier)!; - if (block.type !== blockConfig.type) { - throw new Error("Block type does not match"); - } - - // Render elements - const rendered = blockConfig.render(block as any, editor); - // Add inlineContent class to inline content - if ("contentDOM" in rendered) { - rendered.contentDOM.className = `${ - rendered.contentDOM.className - ? rendered.contentDOM.className + " " - : "" - }${styles.inlineContent}`; - } - // Add elements to blockContent - blockContent.appendChild(rendered.dom); - - return "contentDOM" in rendered - ? { - dom: blockContent, - contentDOM: rendered.contentDOM, - } - : { - dom: blockContent, - }; + return ({ getPos }) => { + // Gets the BlockNote editor instance + const editor = this.options.editor as BlockNoteEditor; + // Gets the block + const block = getBlockFromPos( + getPos, + editor, + this.editor, + blockConfig.type + ); + + return renderWithBlockStructure< + BType, + PSchema, + ContainsInlineContent, + BSchema + >(blockConfig.render, block, editor); }; }, }); @@ -212,6 +205,13 @@ export function createBlockSpec< return { node: node, propSchema: blockConfig.propSchema, + serialize: (block, editor) => + renderWithBlockStructure( + blockConfig.render, + block, + // TODO: Fix typing + editor as any + ), }; } diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index ee49d9921d..39f091ebe4 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -63,7 +63,7 @@ export type BlockConfig< Type extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { [k in Type]: BlockSpec } > = { // Attributes to define block in the API as well as a TipTap node. type: Type; @@ -75,16 +75,13 @@ export type BlockConfig< /** * The custom block to render */ - block: SpecificBlock< - BSchema & { [k in Type]: BlockSpec }, - Type - >, + block: SpecificBlock, /** * The BlockNote editor instance * This is typed generically. If you want an editor with your custom schema, you need to * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` */ - editor: BlockNoteEditor }> + editor: BlockNoteEditor // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics ) => ContainsInlineContent extends true @@ -101,9 +98,16 @@ export type BlockConfig< // the TipTap node used to implement it. Usually created using `createBlockSpec` // though it can also be defined from scratch by providing your own TipTap node, // allowing for more advanced custom blocks. -export type BlockSpec = { +export type BlockSpec = { readonly propSchema: PSchema; - node: TipTapNode; + node: TipTapNode; + // TODO: Improve schema typing + serialize?: BlockConfig< + BType, + PSchema, + boolean, + BlockSchema & { [k in BType]: BlockSpec } + >["render"]; }; // Utility type. For a given object block schema, ensures that the key of each @@ -150,8 +154,8 @@ export type Block = export type SpecificBlock< BSchema extends BlockSchema, - BlockType extends keyof BSchema -> = BlocksWithoutChildren[BlockType] & { + BType extends keyof BSchema +> = BlocksWithoutChildren[BType] & { children: Block[]; }; diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index d60d716cce..f5412f1cc5 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -2,7 +2,7 @@ import { HeadingBlockContent } from "../nodes/BlockContent/HeadingBlockContent/H import { BulletListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { ParagraphBlockContent } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -import { PropSchema, TypesMatch } from "./blockTypes"; +import { BlockSchema, PropSchema, TypesMatch } from "./blockTypes"; export const defaultProps = { backgroundColor: { @@ -39,6 +39,6 @@ export const defaultBlockSchema = { propSchema: defaultProps, node: NumberedListItemBlockContent, }, -} as const; +} satisfies BlockSchema; export type DefaultBlockSchema = TypesMatch; diff --git a/packages/core/src/extensions/Blocks/api/serialization.ts b/packages/core/src/extensions/Blocks/api/serialization.ts index 58557853c3..7eed3f7fc1 100644 --- a/packages/core/src/extensions/Blocks/api/serialization.ts +++ b/packages/core/src/extensions/Blocks/api/serialization.ts @@ -1,29 +1,114 @@ import { Extension } from "@tiptap/core"; import { Plugin } from "prosemirror-state"; -import { DOMSerializer, Schema } from "prosemirror-model"; - -const customBlockSerializer = (schema: Schema) => { - const defaultSerializer = DOMSerializer.fromSchema(schema); - - return new DOMSerializer( - { - ...defaultSerializer.nodes, - // TODO: If a serializer is defined in the config for a custom block, it - // should be added here. We still need to figure out how the serializer - // should be defined in the custom blocks API though, and implement that, - // before we can do this. - }, - defaultSerializer.marks - ); +import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; +import { nodeToBlock } from "../../../api/nodeConversions/nodeConversions"; +import { BlockNoteEditor } from "../../../BlockNoteEditor"; +import { BlockSchema, SpecificBlock } from "./blockTypes"; + +function doc(options: { document?: Document }) { + return options.document || window.document; +} + +export const customBlockSerializer = ( + schema: Schema, + editor: BlockNoteEditor +) => { + const customSerializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + serializeNodeInner: ( + node: Node, + options: { document?: Document } + ) => HTMLElement; + }; + + customSerializer.serializeNodeInner = ( + node: Node, + options: { document?: Document } + ) => { + const { dom, contentDOM } = DOMSerializer.renderSpec( + doc(options), + customSerializer.nodes[node.type.name](node) + ); + + if (contentDOM) { + if (node.isLeaf) { + throw new RangeError("Content hole not allowed in a leaf node spec"); + } + + // Checks if the block type is custom. Custom blocks don't implement a + // `renderHTML` function in their TipTap node type, so `toDOM` also isn't + // implemented in their ProseMirror node type. + if ( + node.type.name === "blockContainer" && + node.firstChild!.type.spec.toDOM === undefined + ) { + // Renders block content using the custom `blockSpec`'s `serialize` + // function. + const blockContent = DOMSerializer.renderSpec( + doc(options), + editor.schema[node.firstChild!.type.name as keyof BSchema].serialize!( + nodeToBlock( + node, + editor.schema, + editor.blockCache + ) as SpecificBlock, + editor as BlockNoteEditor + ) + ); + + // Renders inline content. + if (blockContent.contentDOM) { + if (node.isLeaf) { + throw new RangeError( + "Content hole not allowed in a leaf node spec" + ); + } + + blockContent.contentDOM.appendChild( + customSerializer.serializeFragment( + node.firstChild!.content, + options + ) + ); + } + + contentDOM.appendChild(blockContent.dom); + + // Renders nested blocks. + if (node.childCount === 2) { + customSerializer.serializeFragment( + Fragment.from(node.content.lastChild), + options, + contentDOM + ); + } + } else { + // Renders the block normally, i.e. using `toDOM`. + customSerializer.serializeFragment(node.content, options, contentDOM); + } + } + + return dom as HTMLElement; + }; + + return customSerializer; }; -export const CustomBlockSerializerExtension = Extension.create({ - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - clipboardSerializer: customBlockSerializer(this.editor.schema), - }, - }), - ]; - }, -}); \ No newline at end of file + +export const createCustomBlockSerializerExtension = < + BSchema extends BlockSchema +>( + editor: BlockNoteEditor +) => + Extension.create<{ editor: BlockNoteEditor }, undefined>({ + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + clipboardSerializer: customBlockSerializer( + this.editor.schema, + editor + ), + }, + }), + ]; + }, + }); diff --git a/tests/utils/customblocks/Image.tsx b/tests/utils/customblocks/Image.tsx index 9b03dc0770..58b9279635 100644 --- a/tests/utils/customblocks/Image.tsx +++ b/tests/utils/customblocks/Image.tsx @@ -1,4 +1,4 @@ -import { createBlockSpec, defaultProps } from "@blocknote/core"; +import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiImage2Fill } from "react-icons/ri"; @@ -32,9 +32,11 @@ export const Image = createBlockSpec({ }, }); -export const insertImage = new ReactSlashMenuItem<{ - image: typeof Image; -}>( +export const insertImage = new ReactSlashMenuItem< + BlockSchema & { + image: typeof Image; + } +>( "Insert Image", (editor) => { const src = prompt("Enter image URL"); From bc30dbd22132ae5c890649e020b678f11d3ff4d8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 22 Jun 2023 22:38:52 +0200 Subject: [PATCH 02/44] Added serialization for React custom blocks --- packages/react/src/ReactBlockSpec.tsx | 162 +++++++++++++++--------- tests/utils/customblocks/Image.tsx | 9 +- tests/utils/customblocks/ReactImage.tsx | 12 +- 3 files changed, 118 insertions(+), 65 deletions(-) diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 66a48cf7c5..7eefdbf2f7 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -1,14 +1,16 @@ import { BlockConfig, + BlockNoteEditor, BlockSchema, BlockSpec, blockStyles, camelToDataKebab, createTipTapBlock, + getBlockFromPos, parse, PropSchema, propsToAttributes, - render, + SpecificBlock, } from "@blocknote/core"; import { NodeViewContent, @@ -17,35 +19,77 @@ import { ReactNodeViewRenderer, } from "@tiptap/react"; import { FC, HTMLAttributes } from "react"; +import { renderToString } from "react-dom/server"; -// extend BlockConfig but use a react render function +// `BlockConfig` which returns a React component from its `render` function +// instead of a `dom` and optional `contentDOM` element. export type ReactBlockConfig< - Type extends string, + BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { [k in BType]: BlockSpec } > = Omit< - BlockConfig, + BlockConfig, "render" > & { render: FC<{ block: Parameters< - BlockConfig["render"] + BlockConfig["render"] >[0]; editor: Parameters< - BlockConfig["render"] + BlockConfig["render"] >[1]; }>; }; -export const InlineContent = (props: HTMLAttributes) => ( - -); +// React component that's used instead of declaring a `contentDOM` for React +// custom blocks. +export const InlineContent = (props: HTMLAttributes) => { + return ( + + ); +}; + +// Function that wraps the React component returned from 'blockConfig.render' in +// a `NodeViewWrapper` which also acts as a `blockContent` div. It contains the +// block type and props as HTML attributes. +export function reactRenderWithBlockStructure< + BType extends string, + PSchema extends PropSchema, + ContainsInlineContent extends boolean, + BSchema extends BlockSchema & { [k in BType]: BlockSpec } +>( + render: ReactBlockConfig< + BType, + PSchema, + ContainsInlineContent, + BSchema + >["render"], + block: SpecificBlock, + editor: BlockNoteEditor +) { + const Content = render; + // Add props as HTML attributes in kebab-case with "data-" prefix + const htmlAttributes: Record = {}; + // Add props as HTML attributes in kebab-case with "data-" prefix + for (const [prop, value] of Object.entries(block.props)) { + htmlAttributes[camelToDataKebab(prop)] = value; + } + + return () => ( + + + + ); +} // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead @@ -53,7 +97,7 @@ export function createReactBlockSpec< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { [k in BType]: BlockSpec } >( blockConfig: ReactBlockConfig ): BlockSpec { @@ -76,57 +120,57 @@ export function createReactBlockSpec< return parse(blockConfig); }, - renderHTML({ HTMLAttributes }) { - return render(blockConfig, HTMLAttributes); - }, - addNodeView() { - const BlockContent: FC = (props: NodeViewProps) => { - const Content = blockConfig.render; - - // Add props as HTML attributes in kebab-case with "data-" prefix - const htmlAttributes: Record = {}; - for (const [attribute, value] of Object.entries(props.node.attrs)) { - if (attribute in blockConfig.propSchema) { - htmlAttributes[camelToDataKebab(attribute)] = value; - } - } + return (props) => + ReactNodeViewRenderer( + (props: NodeViewProps) => { + // Gets the BlockNote editor instance + const editor = this.options.editor as BlockNoteEditor; + // Gets the block + const block = getBlockFromPos( + props.getPos, + editor, + this.editor, + blockConfig.type + ); - // Gets BlockNote editor instance - const editor = this.options.editor!; - // Gets position of the node - const pos = - typeof props.getPos === "function" ? props.getPos() : undefined; - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - // Get the block - const block = editor.getBlock(blockIdentifier)!; - if (block.type !== blockConfig.type) { - throw new Error("Block type does not match"); - } - - return ( - - - - ); - }; + const BlockContent = reactRenderWithBlockStructure< + BType, + PSchema, + ContainsInlineContent, + BSchema + >(blockConfig.render, block, this.options.editor); - return ReactNodeViewRenderer(BlockContent, { - className: blockStyles.reactNodeViewRenderer, - }); + return ; + }, + { + className: blockStyles.reactNodeViewRenderer, + } + )(props); }, }); return { node: node, propSchema: blockConfig.propSchema, + serialize: (block, editor) => { + // TODO: This doesn't seem like a great way of doing things - any better + // method for converting a component to HTML? + const blockContentWrapper = document.createElement("div"); + const BlockContent = reactRenderWithBlockStructure< + BType, + PSchema, + ContainsInlineContent, + BSchema + >(blockConfig.render, block, editor as any); + blockContentWrapper.innerHTML = renderToString(); + + return { + dom: blockContentWrapper.firstChild! as HTMLElement, + contentDOM: blockContentWrapper.getElementsByClassName( + blockStyles.inlineContent + )[0], + }; + }, }; } diff --git a/tests/utils/customblocks/Image.tsx b/tests/utils/customblocks/Image.tsx index 58b9279635..2e4488fc92 100644 --- a/tests/utils/customblocks/Image.tsx +++ b/tests/utils/customblocks/Image.tsx @@ -1,4 +1,10 @@ -import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core"; +import { + BlockSchema, + BlockSpec, + createBlockSpec, + defaultProps, + PropSchema, +} from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiImage2Fill } from "react-icons/ri"; @@ -16,6 +22,7 @@ export const Image = createBlockSpec({ image.setAttribute("src", block.props.src); image.setAttribute("contenteditable", "false"); image.setAttribute("style", "width: 100%"); + image.setAttribute("alt", "Image"); const caption = document.createElement("div"); caption.setAttribute("style", "flex-grow: 1"); diff --git a/tests/utils/customblocks/ReactImage.tsx b/tests/utils/customblocks/ReactImage.tsx index f410fbd13a..fac9b57778 100644 --- a/tests/utils/customblocks/ReactImage.tsx +++ b/tests/utils/customblocks/ReactImage.tsx @@ -3,7 +3,7 @@ import { createReactBlockSpec, ReactSlashMenuItem, } from "@blocknote/react"; -import { defaultProps } from "@blocknote/core"; +import { BlockSchema, defaultProps } from "@blocknote/core"; import { RiImage2Fill } from "react-icons/ri"; export const ReactImage = createReactBlockSpec({ @@ -30,15 +30,17 @@ export const ReactImage = createReactBlockSpec({ alt={"Image"} contentEditable={false} /> - + ); }, }); -export const insertReactImage = new ReactSlashMenuItem<{ - reactImage: typeof ReactImage; -}>( +export const insertReactImage = new ReactSlashMenuItem< + BlockSchema & { + reactImage: typeof ReactImage; + } +>( "Insert React Image", (editor) => { const src = prompt("Enter image URL"); From b4f3fb6433c6a9e00b90a4f3a6f7d1ff87bc1758 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 23 Jun 2023 18:22:44 +0200 Subject: [PATCH 03/44] Cleaned up serializer implementation - no longer uses function override --- .../extensions/Blocks/api/serialization.ts | 129 ++++++++---------- packages/react/src/ReactBlockSpec.tsx | 2 - 2 files changed, 57 insertions(+), 74 deletions(-) diff --git a/packages/core/src/extensions/Blocks/api/serialization.ts b/packages/core/src/extensions/Blocks/api/serialization.ts index 7eed3f7fc1..a70d146417 100644 --- a/packages/core/src/extensions/Blocks/api/serialization.ts +++ b/packages/core/src/extensions/Blocks/api/serialization.ts @@ -1,94 +1,79 @@ import { Extension } from "@tiptap/core"; import { Plugin } from "prosemirror-state"; -import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; +import { DOMSerializer, Schema } from "prosemirror-model"; import { nodeToBlock } from "../../../api/nodeConversions/nodeConversions"; import { BlockNoteEditor } from "../../../BlockNoteEditor"; import { BlockSchema, SpecificBlock } from "./blockTypes"; -function doc(options: { document?: Document }) { - return options.document || window.document; -} - export const customBlockSerializer = ( schema: Schema, editor: BlockNoteEditor ) => { - const customSerializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { - serializeNodeInner: ( - node: Node, - options: { document?: Document } - ) => HTMLElement; - }; - - customSerializer.serializeNodeInner = ( - node: Node, - options: { document?: Document } - ) => { - const { dom, contentDOM } = DOMSerializer.renderSpec( - doc(options), - customSerializer.nodes[node.type.name](node) - ); + const defaultSerializer = DOMSerializer.fromSchema(schema); - if (contentDOM) { - if (node.isLeaf) { - throw new RangeError("Content hole not allowed in a leaf node spec"); - } + // Finds all custom nodes (i.e. those that don't implement + // `renderHTML`/`toDOM` as they use `serialize` instead) and assigns them a + // function to serialize them to an empty string. This is because we need to + // access the outer `blockContainer` node to render them, therefore we also + // have to serialize them as part of `blockContainer` nodes, so we shouldn't + // serialize them on their own. + const customNodes = Object.fromEntries( + Object.entries(schema.nodes) + .filter( + ([name, type]) => + name !== "doc" && name !== "text" && type.spec.toDOM === undefined + ) + .map(([name, _type]) => [name, () => ""]) + ); + console.log(customNodes); - // Checks if the block type is custom. Custom blocks don't implement a - // `renderHTML` function in their TipTap node type, so `toDOM` also isn't - // implemented in their ProseMirror node type. - if ( - node.type.name === "blockContainer" && - node.firstChild!.type.spec.toDOM === undefined - ) { - // Renders block content using the custom `blockSpec`'s `serialize` - // function. - const blockContent = DOMSerializer.renderSpec( - doc(options), - editor.schema[node.firstChild!.type.name as keyof BSchema].serialize!( - nodeToBlock( - node, - editor.schema, - editor.blockCache - ) as SpecificBlock, - editor as BlockNoteEditor - ) + const customSerializer = new DOMSerializer( + { + ...defaultSerializer.nodes, + ...customNodes, + blockContainer: (node) => { + // Serializes the `blockContainer` node itself. + const blockContainerElement = DOMSerializer.renderSpec( + document, + node.type.spec.toDOM!(node) ); - // Renders inline content. - if (blockContent.contentDOM) { - if (node.isLeaf) { - throw new RangeError( - "Content hole not allowed in a leaf node spec" - ); - } - - blockContent.contentDOM.appendChild( - customSerializer.serializeFragment( - node.firstChild!.content, - options + // Checks if the `blockContent is custom and the node has to be + // serialized manually, or it can be serialized normally using `toDOM`. + if (node.firstChild!.type.name in customNodes) { + // Serializes the `blockContent` node using the custom `blockSpec`'s + // `serialize` function. + const blockContentElement = DOMSerializer.renderSpec( + document, + editor.schema[node.firstChild!.type.name as keyof BSchema] + .serialize!( + nodeToBlock( + node, + editor.schema, + editor.blockCache + ) as SpecificBlock, + editor as BlockNoteEditor ) ); - } - - contentDOM.appendChild(blockContent.dom); - - // Renders nested blocks. - if (node.childCount === 2) { - customSerializer.serializeFragment( - Fragment.from(node.content.lastChild), - options, - contentDOM + blockContainerElement.contentDOM!.appendChild( + blockContentElement.dom ); + + // Serializes inline content inside the `blockContent` node. + if (blockContentElement.contentDOM) { + customSerializer.serializeFragment( + node.firstChild!.content, + {}, + blockContentElement.contentDOM + ); + } } - } else { - // Renders the block normally, i.e. using `toDOM`. - customSerializer.serializeFragment(node.content, options, contentDOM); - } - } - return dom as HTMLElement; - }; + return blockContainerElement; + }, + }, + defaultSerializer.marks + ); return customSerializer; }; diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 7eefdbf2f7..12f4e184c3 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -154,8 +154,6 @@ export function createReactBlockSpec< node: node, propSchema: blockConfig.propSchema, serialize: (block, editor) => { - // TODO: This doesn't seem like a great way of doing things - any better - // method for converting a component to HTML? const blockContentWrapper = document.createElement("div"); const BlockContent = reactRenderWithBlockStructure< BType, From a9baab24e40003f43ee6243fc8206a606193d5c6 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 23 Jun 2023 18:31:57 +0200 Subject: [PATCH 04/44] Revert "Cleaned up serializer implementation - no longer uses function override" This reverts commit b4f3fb6433c6a9e00b90a4f3a6f7d1ff87bc1758. --- .../extensions/Blocks/api/serialization.ts | 129 ++++++++++-------- packages/react/src/ReactBlockSpec.tsx | 2 + 2 files changed, 74 insertions(+), 57 deletions(-) diff --git a/packages/core/src/extensions/Blocks/api/serialization.ts b/packages/core/src/extensions/Blocks/api/serialization.ts index a70d146417..7eed3f7fc1 100644 --- a/packages/core/src/extensions/Blocks/api/serialization.ts +++ b/packages/core/src/extensions/Blocks/api/serialization.ts @@ -1,79 +1,94 @@ import { Extension } from "@tiptap/core"; import { Plugin } from "prosemirror-state"; -import { DOMSerializer, Schema } from "prosemirror-model"; +import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; import { nodeToBlock } from "../../../api/nodeConversions/nodeConversions"; import { BlockNoteEditor } from "../../../BlockNoteEditor"; import { BlockSchema, SpecificBlock } from "./blockTypes"; +function doc(options: { document?: Document }) { + return options.document || window.document; +} + export const customBlockSerializer = ( schema: Schema, editor: BlockNoteEditor ) => { - const defaultSerializer = DOMSerializer.fromSchema(schema); + const customSerializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + serializeNodeInner: ( + node: Node, + options: { document?: Document } + ) => HTMLElement; + }; + + customSerializer.serializeNodeInner = ( + node: Node, + options: { document?: Document } + ) => { + const { dom, contentDOM } = DOMSerializer.renderSpec( + doc(options), + customSerializer.nodes[node.type.name](node) + ); - // Finds all custom nodes (i.e. those that don't implement - // `renderHTML`/`toDOM` as they use `serialize` instead) and assigns them a - // function to serialize them to an empty string. This is because we need to - // access the outer `blockContainer` node to render them, therefore we also - // have to serialize them as part of `blockContainer` nodes, so we shouldn't - // serialize them on their own. - const customNodes = Object.fromEntries( - Object.entries(schema.nodes) - .filter( - ([name, type]) => - name !== "doc" && name !== "text" && type.spec.toDOM === undefined - ) - .map(([name, _type]) => [name, () => ""]) - ); - console.log(customNodes); + if (contentDOM) { + if (node.isLeaf) { + throw new RangeError("Content hole not allowed in a leaf node spec"); + } - const customSerializer = new DOMSerializer( - { - ...defaultSerializer.nodes, - ...customNodes, - blockContainer: (node) => { - // Serializes the `blockContainer` node itself. - const blockContainerElement = DOMSerializer.renderSpec( - document, - node.type.spec.toDOM!(node) + // Checks if the block type is custom. Custom blocks don't implement a + // `renderHTML` function in their TipTap node type, so `toDOM` also isn't + // implemented in their ProseMirror node type. + if ( + node.type.name === "blockContainer" && + node.firstChild!.type.spec.toDOM === undefined + ) { + // Renders block content using the custom `blockSpec`'s `serialize` + // function. + const blockContent = DOMSerializer.renderSpec( + doc(options), + editor.schema[node.firstChild!.type.name as keyof BSchema].serialize!( + nodeToBlock( + node, + editor.schema, + editor.blockCache + ) as SpecificBlock, + editor as BlockNoteEditor + ) ); - // Checks if the `blockContent is custom and the node has to be - // serialized manually, or it can be serialized normally using `toDOM`. - if (node.firstChild!.type.name in customNodes) { - // Serializes the `blockContent` node using the custom `blockSpec`'s - // `serialize` function. - const blockContentElement = DOMSerializer.renderSpec( - document, - editor.schema[node.firstChild!.type.name as keyof BSchema] - .serialize!( - nodeToBlock( - node, - editor.schema, - editor.blockCache - ) as SpecificBlock, - editor as BlockNoteEditor - ) - ); - blockContainerElement.contentDOM!.appendChild( - blockContentElement.dom - ); + // Renders inline content. + if (blockContent.contentDOM) { + if (node.isLeaf) { + throw new RangeError( + "Content hole not allowed in a leaf node spec" + ); + } - // Serializes inline content inside the `blockContent` node. - if (blockContentElement.contentDOM) { + blockContent.contentDOM.appendChild( customSerializer.serializeFragment( node.firstChild!.content, - {}, - blockContentElement.contentDOM - ); - } + options + ) + ); } - return blockContainerElement; - }, - }, - defaultSerializer.marks - ); + contentDOM.appendChild(blockContent.dom); + + // Renders nested blocks. + if (node.childCount === 2) { + customSerializer.serializeFragment( + Fragment.from(node.content.lastChild), + options, + contentDOM + ); + } + } else { + // Renders the block normally, i.e. using `toDOM`. + customSerializer.serializeFragment(node.content, options, contentDOM); + } + } + + return dom as HTMLElement; + }; return customSerializer; }; diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 12f4e184c3..7eefdbf2f7 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -154,6 +154,8 @@ export function createReactBlockSpec< node: node, propSchema: blockConfig.propSchema, serialize: (block, editor) => { + // TODO: This doesn't seem like a great way of doing things - any better + // method for converting a component to HTML? const blockContentWrapper = document.createElement("div"); const BlockContent = reactRenderWithBlockStructure< BType, From 736d4d98bc395d0eda77c7dea2dae687b0725aa0 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 23 Jun 2023 19:27:29 +0200 Subject: [PATCH 05/44] Removed comment --- packages/react/src/ReactBlockSpec.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 7eefdbf2f7..12f4e184c3 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -154,8 +154,6 @@ export function createReactBlockSpec< node: node, propSchema: blockConfig.propSchema, serialize: (block, editor) => { - // TODO: This doesn't seem like a great way of doing things - any better - // method for converting a component to HTML? const blockContentWrapper = document.createElement("div"); const BlockContent = reactRenderWithBlockStructure< BType, From 21bce0202a18f40a055cadf5adbf3caf8c819c81 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 23 Jun 2023 22:31:35 +0200 Subject: [PATCH 06/44] Added ability to set custom serialization and parse functions for custom blocks (parse still WIP) --- .../core/src/extensions/Blocks/api/block.ts | 45 ++++++++++++++++--- .../src/extensions/Blocks/api/blockTypes.ts | 14 ++++++ packages/react/src/ReactBlockSpec.tsx | 10 ++++- tests/utils/customblocks/Image.tsx | 20 ++++++--- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index 18604d6f98..38c0f52b5e 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -1,5 +1,7 @@ import { Attribute, Editor, Node } from "@tiptap/core"; +import { Fragment, ParseRule } from "prosemirror-model"; import { BlockNoteEditor, SpecificBlock } from "../../.."; +import { inlineContentToNodes } from "../../../api/nodeConversions/nodeConversions"; import styles from "../nodes/Block.module.css"; import { BlockConfig, @@ -24,7 +26,7 @@ export function propsToAttributes< >( blockConfig: Omit< BlockConfig, - "render" + "render" | "serialize" > ) { const tiptapAttributes: Record = {}; @@ -60,14 +62,45 @@ export function parse< >( blockConfig: Omit< BlockConfig, - "render" + "render" | "serialize" > ) { - return [ + const rules: ParseRule[] = [ { tag: "div[data-content-type=" + blockConfig.type + "]", }, ]; + + if (blockConfig.parse) { + rules.push({ + getAttrs(node: string | HTMLElement) { + console.log("parse"); + if (typeof node === "string") { + return false; + } + + const block = blockConfig.parse!(node); + + return block ? block.props || {} : false; + }, + getContent(node, schema) { + console.log("content"); + const block = blockConfig.parse!(node as HTMLElement); + + if (block && block.content) { + return Fragment.from( + typeof block.content === "string" + ? schema.text(block.content) + : inlineContentToNodes(block.content, schema) + ); + } + + return Fragment.empty; + }, + }); + } + + return rules; } // Function that wraps the `dom` element returned from 'blockConfig.render' in a @@ -206,8 +239,10 @@ export function createBlockSpec< node: node, propSchema: blockConfig.propSchema, serialize: (block, editor) => - renderWithBlockStructure( - blockConfig.render, + renderWithBlockStructure( + blockConfig.serialize + ? (block, editor) => ({ dom: blockConfig.serialize!(block, editor) }) + : blockConfig.render, block, // TODO: Fix typing editor as any diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 39f091ebe4..c14d2312d8 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -92,6 +92,13 @@ export type BlockConfig< : { dom: HTMLElement; }; + serialize?: ( + block: SpecificBlock, + editor: BlockNoteEditor + ) => HTMLElement; + parse?: ( + element: HTMLElement + ) => SpecificPartialBlock | undefined; }; // Defines a single block spec, which includes the props that the block has and @@ -159,6 +166,13 @@ export type SpecificBlock< children: Block[]; }; +export type SpecificPartialBlock< + BSchema extends BlockSchema, + BType extends keyof BSchema +> = PartialBlocksWithoutChildren[BType] & { + children?: Block[]; +}; + // Same as BlockWithoutChildren, but as a partial type with some changes to make // it easier to create/update blocks in the editor. type PartialBlocksWithoutChildren = { diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 12f4e184c3..65feeb043d 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -30,7 +30,7 @@ export type ReactBlockConfig< BSchema extends BlockSchema & { [k in BType]: BlockSpec } > = Omit< BlockConfig, - "render" + "render" | "serialize" > & { render: FC<{ block: Parameters< @@ -40,6 +40,12 @@ export type ReactBlockConfig< BlockConfig["render"] >[1]; }>; + serialize: ReactBlockConfig< + BType, + PSchema, + ContainsInlineContent, + BSchema + >["render"]; }; // React component that's used instead of declaring a `contentDOM` for React @@ -160,7 +166,7 @@ export function createReactBlockSpec< PSchema, ContainsInlineContent, BSchema - >(blockConfig.render, block, editor as any); + >(blockConfig.serialize || blockConfig.render, block, editor as any); blockContentWrapper.innerHTML = renderToString(); return { diff --git a/tests/utils/customblocks/Image.tsx b/tests/utils/customblocks/Image.tsx index 2e4488fc92..f69aab4d89 100644 --- a/tests/utils/customblocks/Image.tsx +++ b/tests/utils/customblocks/Image.tsx @@ -1,10 +1,4 @@ -import { - BlockSchema, - BlockSpec, - createBlockSpec, - defaultProps, - PropSchema, -} from "@blocknote/core"; +import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiImage2Fill } from "react-icons/ri"; @@ -37,6 +31,18 @@ export const Image = createBlockSpec({ contentDOM: caption, }; }, + parse: (element) => { + if (element.hasAttribute("src")) { + return { + type: "image", + props: { + src: element.getAttribute("src")!, + }, + }; + } + + return; + }, }); export const insertImage = new ReactSlashMenuItem< From 84463bf54c16edcc90de462c9b699144856ea1e8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 11 Oct 2023 18:18:45 +0200 Subject: [PATCH 07/44] Fixed build and most runtime issues --- packages/core/src/BlockNoteEditor.ts | 2 +- packages/core/src/BlockNoteExtensions.ts | 2 +- .../core/src/extensions/Blocks/api/block.ts | 206 +++++++++++------- .../src/extensions/Blocks/api/blockTypes.ts | 42 ++-- .../ImageBlockContent/ImageBlockContent.ts | 18 ++ packages/react/src/ReactBlockSpec.tsx | 180 ++++++--------- 6 files changed, 232 insertions(+), 218 deletions(-) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 7943667874..343c4d631a 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -376,7 +376,7 @@ export class BlockNoteEditor { */ public forEachBlock( callback: (block: Block) => boolean, - reverse: boolean = false + reverse = false ): void { const blocks = this.topLevelBlocks.slice(); diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index 9bb8d3f354..74870d854e 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -24,7 +24,7 @@ import { BlockNoteDOMAttributes, BlockSchema, } from "./extensions/Blocks/api/blockTypes"; -import { CustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization"; +import { createCustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization"; import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension"; diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index e9c4727ba3..51a938a668 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -1,18 +1,19 @@ import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; import { Fragment, ParseRule } from "prosemirror-model"; -import { BlockNoteDOMAttributes, BlockNoteEditor, SpecificBlock } from "../../.."; +import { BlockNoteEditor } from "../../.."; import { inlineContentToNodes } from "../../../api/nodeConversions/nodeConversions"; import styles from "../nodes/Block.module.css"; import { BlockConfig, + BlockNoteDOMAttributes, BlockSchema, BlockSpec, PropSchema, + SpecificBlock, TipTapNode, TipTapNodeConfig, } from "./blockTypes"; import { mergeCSSClasses } from "../../../shared/utils"; -import { ParseRule } from "prosemirror-model"; export function camelToDataKebab(str: string): string { return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); @@ -24,11 +25,13 @@ export function propsToAttributes< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; + } >( blockConfig: Omit< BlockConfig, - "render" | "serialize" + "render" > ): Attributes { const tiptapAttributes: Record = {}; @@ -60,11 +63,13 @@ export function parse< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; + } >( blockConfig: Omit< BlockConfig, - "render" | "serialize" + "render" > ) { const rules: ParseRule[] = [ @@ -75,21 +80,24 @@ export function parse< if (blockConfig.parse) { rules.push({ + tag: "*", getAttrs(node: string | HTMLElement) { - console.log("parse"); if (typeof node === "string") { return false; } - const block = blockConfig.parse!(node); + const block = blockConfig.parse?.(node); + + if (block === undefined) { + return false; + } - return block ? block.props || {} : false; + return block.props || {}; }, getContent(node, schema) { - console.log("content"); - const block = blockConfig.parse!(node as HTMLElement); + const block = blockConfig.parse?.(node as HTMLElement); - if (block && block.content) { + if (block !== undefined && block.content !== undefined) { return Fragment.from( typeof block.content === "string" ? schema.text(block.content) @@ -105,57 +113,15 @@ export function parse< return rules; } -// Function that wraps the `dom` element returned from 'blockConfig.render' in a -// `blockContent` div, which contains the block type and props as HTML -// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds -// an `inlineContent` class to it. -export function renderWithBlockStructure< +// Used to figure out which block should be rendered. This block is then used to +// create the node view. +export function getBlockFromPos< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - render: BlockConfig["render"], - block: SpecificBlock, - editor: BlockNoteEditor -) { - // Create blockContent element - const blockContent = document.createElement("div"); - // Sets blockContent class - blockContent.className = styles.blockContent; - // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", block.type); - // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [prop, value] of Object.entries(block.props)) { - blockContent.setAttribute(camelToDataKebab(prop), value); + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; } - - // TODO: This only works for content copied within BlockNote. - // Creates contentDOM element to serialize inline content into. - let contentDOM: HTMLDivElement | undefined; - if (blockConfig.containsInlineContent) { - contentDOM = document.createElement("div"); - blockContent.appendChild(contentDOM); - } else { - contentDOM = undefined; - } - // Adds elements to blockContent - blockContent.appendChild(rendered.dom); - - return contentDOM !== undefined - ? { - dom: blockContent, - contentDOM: contentDOM, - } - : { - dom: blockContent, - }; -} - -export function getBlockFromPos< - BType extends string, - PSchema extends PropSchema, - BSchema extends BlockSchema & { [k in BType]: BlockSpec } >( getPos: (() => number) | boolean, editor: BlockNoteEditor, @@ -185,13 +151,76 @@ export function getBlockFromPos< return block; } +// Function that wraps the `dom` element returned from 'blockConfig.render' in a +// `blockContent` div, which contains the block type and props as HTML +// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds +// an `inlineContent` class to it. +export function wrapInBlockStructure< + BType extends string, + PSchema extends PropSchema, + ContainsInlineContent extends boolean, + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; + } +>( + element: { + dom: HTMLElement; + contentDOM?: HTMLElement; + }, + block: SpecificBlock, + domAttributes?: Record +) { + // Creates `blockContent` element + const blockContent = document.createElement("div"); + + // Adds custom HTML attributes + if (domAttributes !== undefined) { + for (const [attr, value] of Object.entries(domAttributes)) { + if (attr !== "class") { + blockContent.setAttribute(attr, value); + } + } + } + // Sets blockContent class + blockContent.className = mergeCSSClasses( + styles.blockContent, + domAttributes?.class || "" + ); + // Sets content type attribute + blockContent.setAttribute("data-content-type", block.type); + // Add props as HTML attributes in kebab-case with "data-" prefix + for (const [prop, value] of Object.entries(block.props)) { + blockContent.setAttribute(camelToDataKebab(prop), value); + } + + blockContent.appendChild(element.dom); + + if (element.contentDOM !== undefined) { + element.contentDOM.className = mergeCSSClasses( + styles.inlineContent, + element.contentDOM.className + ); + + return { + dom: blockContent, + contentDOM: element.contentDOM, + }; + } + + return { + dom: blockContent, + }; +} + // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createBlockSpec< BType extends string, PSchema extends PropSchema, - ContainsInlineContent extends false, - BSchema extends BlockSchema + ContainsInlineContent extends boolean, + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; + } >( blockConfig: BlockConfig ): BlockSpec { @@ -220,21 +249,26 @@ export function createBlockSpec< addNodeView() { return ({ getPos }) => { // Gets the BlockNote editor instance - const editor = this.options.editor as BlockNoteEditor; + const editor = this.options.editor; // Gets the block - const block = getBlockFromPos( - getPos, - editor, - this.editor, - blockConfig.type - ); + const block = getBlockFromPos< + BType, + PSchema, + ContainsInlineContent, + BSchema + >(getPos, editor, this.editor, blockConfig.type); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; - return renderWithBlockStructure< + const content = blockConfig.render(block, editor); + + return wrapInBlockStructure< BType, PSchema, ContainsInlineContent, BSchema - >(blockConfig.render, block, editor); + >(content, block as any, blockContentDOMAttributes); }; }, }); @@ -242,15 +276,29 @@ export function createBlockSpec< return { node: node as TipTapNode, propSchema: blockConfig.propSchema, - serialize: (block, editor) => - renderWithBlockStructure( - blockConfig.serialize - ? (block, editor) => ({ dom: blockConfig.serialize!(block, editor) }) - : blockConfig.render, - block, - // TODO: Fix typing - editor as any - ), + serialize: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + let element: { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + if (blockConfig.serialize !== undefined) { + element = { + dom: blockConfig.serialize(block as any, editor as any), + }; + } else { + element = blockConfig.render(block as any, editor as any); + } + + return wrapInBlockStructure< + BType, + PSchema, + ContainsInlineContent, + BSchema + >(element, block as any, blockContentDOMAttributes).dom; + }, }; } diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 2a620966f2..18c78e80c7 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -121,7 +121,9 @@ export type BlockConfig< Type extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { + [k in Type]: BlockSpec; + } > = { // Attributes to define block in the API as well as a TipTap node. type: Type; @@ -133,32 +135,20 @@ export type BlockConfig< /** * The custom block to render */ - block: SpecificBlock< - BSchema & { - [k in Type]: BlockSpec; - }, - Type - >, + block: SpecificBlock, /** * The BlockNote editor instance * This is typed generically. If you want an editor with your custom schema, you need to * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` */ - editor: BlockNoteEditor< - BSchema & { [k in Type]: BlockSpec } - > + editor: BlockNoteEditor // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics - ) => ContainsInlineContent extends true - ? { - dom: HTMLElement; - contentDOM: HTMLElement; - destroy?: () => void; - } - : { - dom: HTMLElement; - destroy?: () => void; - }; + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + destroy?: () => void; + }; serialize?: ( block: SpecificBlock, editor: BlockNoteEditor @@ -179,13 +169,11 @@ export type BlockSpec< > = { node: TipTapNode; readonly propSchema: PSchema; - // TODO: Improve schema typing - serialize?: BlockConfig< - BType, - PSchema, - boolean, - BlockSchema & { [k in BType]: BlockSpec } - >["render"]; + // TODO: Typing + serialize?: ( + block: SpecificBlock, + editor: BlockNoteEditor + ) => HTMLElement; }; // Utility type. For a given object block schema, ensures that the key of each diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index 9dcf6e27a5..8a29cdb77e 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -302,4 +302,22 @@ export const Image = createBlockSpec({ propSchema: imagePropSchema, containsInlineContent: false, render: renderImage, + serialize: (block) => { + const img = document.createElement("img"); + img.src = block.props.url; + + return img; + }, + parse: (element) => { + if (element.tagName === "IMG") { + return { + type: "image", + props: { + url: element.getAttribute("src") || "", + }, + }; + } + + return; + }, }); diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 98b1e6600d..baa6314686 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -7,11 +7,13 @@ import { blockStyles, camelToDataKebab, createTipTapBlock, + getBlockFromPos, mergeCSSClasses, parse, PropSchema, propsToAttributes, SpecificBlock, + wrapInBlockStructure, } from "@blocknote/core"; import { NodeViewContent, @@ -19,7 +21,6 @@ import { NodeViewWrapper, ReactNodeViewRenderer, } from "@tiptap/react"; -import { FC, HTMLAttributes } from "react"; import { renderToString } from "react-dom/server"; import { createContext, ElementType, FC, HTMLProps, useContext } from "react"; @@ -28,10 +29,12 @@ export type ReactBlockConfig< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; + } > = Omit< BlockConfig, - "render" | "serialize" + "render" > & { render: FC<{ block: Parameters< @@ -41,12 +44,6 @@ export type ReactBlockConfig< BlockConfig["render"] >[1]; }>; - serialize: ReactBlockConfig< - BType, - PSchema, - ContainsInlineContent, - BSchema - >["render"]; }; const BlockNoteDOMAttributesContext = createContext({}); @@ -79,35 +76,39 @@ export const InlineContent = ( // Function that wraps the React component returned from 'blockConfig.render' in // a `NodeViewWrapper` which also acts as a `blockContent` div. It contains the // block type and props as HTML attributes. -export function reactRenderWithBlockStructure< +export function reactWrapInBlockStructure< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema & { [k in BType]: BlockSpec } + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; + } >( - render: ReactBlockConfig< - BType, - PSchema, - ContainsInlineContent, - BSchema - >["render"], + element: JSX.Element, block: SpecificBlock, - editor: BlockNoteEditor + domAttributes?: Record ) { - const Content = render; - // Add props as HTML attributes in kebab-case with "data-" prefix - const htmlAttributes: Record = {}; - // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [prop, value] of Object.entries(block.props)) { - htmlAttributes[camelToDataKebab(prop)] = value; - } - return () => ( + // Creates `blockContent` element key !== "class") + )} + // Sets blockContent class + className={mergeCSSClasses( + blockStyles.blockContent, + domAttributes?.class || "" + )} + // Sets content type attribute data-content-type={block.type} - {...htmlAttributes}> - + // Add props as HTML attributes in kebab-case with "data-" prefix + {...Object.fromEntries( + Object.entries(block.props).map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + )}> + {element} ); } @@ -118,7 +119,9 @@ export function createReactBlockSpec< BType extends string, PSchema extends PropSchema, ContainsInlineContent extends boolean, - BSchema extends BlockSchema + BSchema extends BlockSchema & { + [k in BType]: BlockSpec; + } >( blockConfig: ReactBlockConfig ): BlockSpec { @@ -145,84 +148,32 @@ export function createReactBlockSpec< }, addNodeView() { - const BlockContent: FC = (props: NodeViewProps) => { - const Content = blockConfig.render; - - // Add custom HTML attributes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - - // Add props as HTML attributes in kebab-case with "data-" prefix - const htmlAttributes: Record = {}; - for (const [attribute, value] of Object.entries(props.node.attrs)) { - if ( - attribute in blockConfig.propSchema && - value !== blockConfig.propSchema[attribute].default - ) { - htmlAttributes[camelToDataKebab(attribute)] = value; - } - } - - // Gets BlockNote editor instance - const editor = this.options.editor! as BlockNoteEditor< - BSchema & { - [k in BType]: BlockSpec; - } - >; - // Gets position of the node - const pos = - typeof props.getPos === "function" ? props.getPos() : undefined; - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - // Get the block - const block = editor.getBlock(blockIdentifier)!; - if (block.type !== blockConfig.type) { - throw new Error("Block type does not match"); - } - - return ( - key !== "class" - ) - )} - className={mergeCSSClasses( - blockStyles.blockContent, - blockContentDOMAttributes.class - )} - data-content-type={blockConfig.type} - {...htmlAttributes}> - - - - - ); - }; return (props) => ReactNodeViewRenderer( (props: NodeViewProps) => { // Gets the BlockNote editor instance - const editor = this.options.editor as BlockNoteEditor; + const editor = this.options.editor! as BlockNoteEditor< + BSchema & { + [k in BType]: BlockSpec; + } + >; // Gets the block - const block = getBlockFromPos( - props.getPos, - editor, - this.editor, - blockConfig.type - ); - - const BlockContent = reactRenderWithBlockStructure< + const block = getBlockFromPos< BType, PSchema, ContainsInlineContent, BSchema - >(blockConfig.render, block, this.options.editor); + >(props.getPos, editor, this.editor, blockConfig.type); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + const Content = blockConfig.render; + const BlockContent = reactWrapInBlockStructure( + , + block, + blockContentDOMAttributes + ); return ; }, @@ -237,21 +188,30 @@ export function createReactBlockSpec< node: node, propSchema: blockConfig.propSchema, serialize: (block, editor) => { - const blockContentWrapper = document.createElement("div"); - const BlockContent = reactRenderWithBlockStructure< + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + let element: HTMLElement; + + if (blockConfig.serialize !== undefined) { + element = blockConfig.serialize(block as any, editor as any); + } else { + const Content = blockConfig.render; + const BlockContent = reactWrapInBlockStructure( + , + block, + blockContentDOMAttributes + ); + element = document.createElement("div"); + element.innerHTML = renderToString(); + } + + return wrapInBlockStructure< BType, PSchema, ContainsInlineContent, BSchema - >(blockConfig.serialize || blockConfig.render, block, editor as any); - blockContentWrapper.innerHTML = renderToString(); - - return { - dom: blockContentWrapper.firstChild! as HTMLElement, - contentDOM: blockContentWrapper.getElementsByClassName( - blockStyles.inlineContent - )[0], - }; + >({ dom: element }, block as any, blockContentDOMAttributes).dom; }, }; } From 16a2948105e7d6dd8bca8f1b8613a37320552731 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 11 Oct 2023 20:59:58 +0200 Subject: [PATCH 08/44] Added `react-dom` dependency --- package-lock.json | 7 ++++--- packages/react/package.json | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3ca107398..fb82fc4674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16777,7 +16777,8 @@ }, "node_modules/react-dom": { "version": "18.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -19979,6 +19980,7 @@ "@tiptap/react": "^2.0.3", "lodash": "^4.17.21", "react": "^18.2.0", + "react-dom": "^18.2.0", "react-icons": "^4.3.1", "tippy.js": "^6.3.7", "use-prefers-color-scheme": "^1.1.3" @@ -19995,8 +19997,7 @@ "vite-plugin-externalize-deps": "^0.7.0" }, "peerDependencies": { - "react": "^18", - "react-dom": "^18" + "react": "^18" } }, "packages/react/node_modules/@babel/code-frame": { diff --git a/packages/react/package.json b/packages/react/package.json index be314c540a..8826779596 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -56,7 +56,8 @@ "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", "lodash": "^4.17.21", - "react": "^18.2.0", + "react": "^18", + "react-dom": "^18.2.0", "react-icons": "^4.3.1", "tippy.js": "^6.3.7", "use-prefers-color-scheme": "^1.1.3" From 1a38a605678aee9d2b5354721348e07d79345e64 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 30 Oct 2023 18:41:39 +0100 Subject: [PATCH 09/44] Added PoC copy/paste handling --- packages/core/src/BlockNoteEditor.ts | 6 +- .../formatConversions/formatConversions.ts | 16 ++-- .../extensions/Blocks/api/serialization.ts | 73 +++++++++++++++++-- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 343c4d631a..390077900c 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -514,6 +514,8 @@ export class BlockNoteEditor { const blocks: Block[] = []; + // TODO: This adds all child blocks to the same array. Needs to find min + // depth and only add blocks at that depth. this._tiptapEditor.state.doc.descendants((node, pos) => { if (node.type.spec.group !== "blockContent") { return true; @@ -784,7 +786,7 @@ export class BlockNoteEditor { * @returns The blocks, serialized as an HTML string. */ public async blocksToHTML(blocks: Block[]): Promise { - return blocksToHTML(blocks, this._tiptapEditor.schema); + return blocksToHTML(blocks, this._tiptapEditor.schema, this); } /** @@ -805,7 +807,7 @@ export class BlockNoteEditor { * @returns The blocks, serialized as a Markdown string. */ public async blocksToMarkdown(blocks: Block[]): Promise { - return blocksToMarkdown(blocks, this._tiptapEditor.schema); + return blocksToMarkdown(blocks, this._tiptapEditor.schema, this); } /** diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts index 5e64dfa6b5..1cd61c4eed 100644 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ b/packages/core/src/api/formatConversions/formatConversions.ts @@ -1,4 +1,4 @@ -import { DOMParser, DOMSerializer, Schema } from "prosemirror-model"; +import { DOMParser, Schema } from "prosemirror-model"; import rehypeParse from "rehype-parse"; import rehypeRemark from "rehype-remark"; import rehypeStringify from "rehype-stringify"; @@ -12,16 +12,19 @@ import { Block, BlockSchema } from "../../extensions/Blocks/api/blockTypes"; import { blockToNode, nodeToBlock } from "../nodeConversions/nodeConversions"; import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; import { simplifyBlocks } from "./simplifyBlocksRehypePlugin"; +import { customBlockSerializer } from "../../extensions/Blocks/api/serialization"; +import { BlockNoteEditor } from "../../BlockNoteEditor"; export async function blocksToHTML( blocks: Block[], - schema: Schema + schema: Schema, + editor: BlockNoteEditor ): Promise { const htmlParentElement = document.createElement("div"); - const serializer = DOMSerializer.fromSchema(schema); + const serializer = customBlockSerializer(schema, editor); for (const block of blocks) { - const node = blockToNode(block, schema); + const node = blockToNode(block, editor._tiptapEditor.schema); const htmlNode = serializer.serializeNode(node); htmlParentElement.appendChild(htmlNode); } @@ -60,7 +63,8 @@ export async function HTMLToBlocks( export async function blocksToMarkdown( blocks: Block[], - schema: Schema + schema: Schema, + editor: BlockNoteEditor ): Promise { const markdownString = await unified() .use(rehypeParse, { fragment: true }) @@ -68,7 +72,7 @@ export async function blocksToMarkdown( .use(rehypeRemark) .use(remarkGfm) .use(remarkStringify) - .process(await blocksToHTML(blocks, schema)); + .process(await blocksToHTML(blocks, schema, editor)); return markdownString.value as string; } diff --git a/packages/core/src/extensions/Blocks/api/serialization.ts b/packages/core/src/extensions/Blocks/api/serialization.ts index 7eed3f7fc1..d0e8a84a95 100644 --- a/packages/core/src/extensions/Blocks/api/serialization.ts +++ b/packages/core/src/extensions/Blocks/api/serialization.ts @@ -3,7 +3,7 @@ import { Plugin } from "prosemirror-state"; import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; import { nodeToBlock } from "../../../api/nodeConversions/nodeConversions"; import { BlockNoteEditor } from "../../../BlockNoteEditor"; -import { BlockSchema, SpecificBlock } from "./blockTypes"; +import { Block, BlockSchema, SpecificBlock } from "./blockTypes"; function doc(options: { document?: Document }) { return options.document || window.document; @@ -103,10 +103,73 @@ export const createCustomBlockSerializerExtension = < return [ new Plugin({ props: { - clipboardSerializer: customBlockSerializer( - this.editor.schema, - editor - ), + handleDOMEvents: { + copy() { + // TODO: Fix editor.getSelection().blocks returning child blocks + // const blocks = editor.getSelection()?.blocks; + const blocks = editor.topLevelBlocks; + + if (blocks === undefined || blocks.length === 1) { + return; + } + + async function copyToClipboard(blocks: Block[]) { + const html = await editor.blocksToHTML(blocks); + const markdown = await editor.blocksToMarkdown(blocks); + const blockNoteHTML = await ( + await ( + await navigator.clipboard.read() + )[0].getType("text/html") + ).text(); + + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([html], { + type: "text/html", + }), + "text/plain": new Blob([markdown], { + type: "text/plain", + }), + "web blocknote/html": new Blob([blockNoteHTML], { + type: "blocknote/html", + }), + }), + ]); + + const formats = [ + "text/html", + "text/plain", + "web blocknote/html", + ] as const; + const items = await navigator.clipboard.read(); + for (const format of formats) { + const blob = await items[0].getType(format); + const text = await blob.text(); + console.log(format); + console.log(text); + } + } + + copyToClipboard(blocks); + }, + paste(_view, event) { + event.preventDefault(); + + async function pasteFromClipboard() { + const items = await navigator.clipboard.read(); + const format = items[0].types.includes("web blocknote/html") + ? "web blocknote/html" + : "text/html"; + + const blob = await items[0].getType(format); + const text = await blob.text(); + + editor._tiptapEditor.view.pasteHTML(text); + } + + pasteFromClipboard(); + }, + }, }, }), ]; From 9cd58f6871765a0388a8bf5ccb7ddc95c18ea873 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 2 Nov 2023 23:53:36 +0100 Subject: [PATCH 10/44] Small changes & fixes --- .../simplifyBlocksRehypePlugin.ts | 13 ++++++ .../core/src/extensions/Blocks/api/block.ts | 46 ++++++++++--------- .../src/extensions/Blocks/api/blockTypes.ts | 1 + .../ImageBlockContent/ImageBlockContent.ts | 20 +++++++- .../DefaultButtons/ImageCaptionButton.tsx | 10 ++-- 5 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts b/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts index 13fa69e783..ba025dc5b0 100644 --- a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts +++ b/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts @@ -22,6 +22,19 @@ export function simplifyBlocks(options: SimplifyBlocksOptions) { ]); const simplifyBlocksHelper = (tree: HASTParent) => { + // Checks whether blocks in the tree are wrapped by a parent `blockGroup` + // element, in which case the `blockGroup`'s children are lifted out, and it + // is removed. + if ( + tree.children.length === 1 && + (tree.children[0] as HASTElement).properties?.["dataNodeType"] === + "blockGroup" + ) { + const blockGroup = tree.children[0] as HASTElement; + tree.children.pop(); + tree.children.push(...blockGroup.children); + } + let numChildElements = tree.children.length; let activeList: HASTElement | undefined; diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index 51a938a668..fcb763949e 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -1,6 +1,6 @@ import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; import { Fragment, ParseRule } from "prosemirror-model"; -import { BlockNoteEditor } from "../../.."; +import { BlockNoteEditor, Props } from "../../.."; import { inlineContentToNodes } from "../../../api/nodeConversions/nodeConversions"; import styles from "../nodes/Block.module.css"; import { @@ -157,17 +157,15 @@ export function getBlockFromPos< // an `inlineContent` class to it. export function wrapInBlockStructure< BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema & { - [k in BType]: BlockSpec; - } + PSchema extends PropSchema >( element: { dom: HTMLElement; contentDOM?: HTMLElement; }, - block: SpecificBlock, + blockType: BType, + blockProps: Props, + propSchema: PSchema, domAttributes?: Record ) { // Creates `blockContent` element @@ -187,10 +185,12 @@ export function wrapInBlockStructure< domAttributes?.class || "" ); // Sets content type attribute - blockContent.setAttribute("data-content-type", block.type); + blockContent.setAttribute("data-content-type", blockType); // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [prop, value] of Object.entries(block.props)) { - blockContent.setAttribute(camelToDataKebab(prop), value); + for (const [prop, value] of Object.entries(blockProps)) { + if (value !== propSchema[prop].default) { + blockContent.setAttribute(camelToDataKebab(prop), value); + } } blockContent.appendChild(element.dom); @@ -263,12 +263,13 @@ export function createBlockSpec< const content = blockConfig.render(block, editor); - return wrapInBlockStructure< - BType, - PSchema, - ContainsInlineContent, - BSchema - >(content, block as any, blockContentDOMAttributes); + return wrapInBlockStructure( + content, + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); }; }, }); @@ -292,12 +293,13 @@ export function createBlockSpec< element = blockConfig.render(block as any, editor as any); } - return wrapInBlockStructure< - BType, - PSchema, - ContainsInlineContent, - BSchema - >(element, block as any, blockContentDOMAttributes).dom; + return wrapInBlockStructure( + element, + block.type as BType, + block.props as Props, + blockConfig.propSchema, + blockContentDOMAttributes + ).dom; }, }; } diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 18c78e80c7..dc321f9921 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -149,6 +149,7 @@ export type BlockConfig< contentDOM?: HTMLElement; destroy?: () => void; }; + // TODO: Maybe can return undefined to ignore when serializing? serialize?: ( block: SpecificBlock, editor: BlockNoteEditor diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index 8a29cdb77e..d4546264b5 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -41,7 +41,7 @@ const textAlignmentToAlignItems = ( // Min image width in px. const minWidth = 64; -const renderImage = ( +export const renderImage = ( block: SpecificBlock< { image: BlockSpec<"image", typeof imagePropSchema, false> }, "image" @@ -303,10 +303,26 @@ export const Image = createBlockSpec({ containsInlineContent: false, render: renderImage, serialize: (block) => { + if (block.props.url === "") { + const div = document.createElement("p"); + div.innerHTML = "Add Image"; + + return div; + } + + const figure = document.createElement("figure"); + const img = document.createElement("img"); img.src = block.props.url; + figure.appendChild(img); + + if (block.props.caption !== "") { + const figcaption = document.createElement("figcaption"); + figcaption.innerHTML = block.props.caption; + figure.appendChild(figcaption); + } - return img; + return figure; }, parse: (element) => { if (element.tagName === "IMG") { diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx index b0a7e7d840..6871c87067 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx @@ -32,12 +32,12 @@ export const ImageCaptionButton = (props: { typeof props.editor.schema["image"].propSchema.caption.default === "string" && props.editor.schema["image"].propSchema.caption.values === undefined && - // Checks if the block has a `src` prop which can take any string value. - "src" in props.editor.schema["image"].propSchema && - typeof props.editor.schema["image"].propSchema.src.default === "string" && - props.editor.schema["image"].propSchema.src.values === undefined && + // Checks if the block has a `url` prop which can take any string value. + "url" in props.editor.schema["image"].propSchema && + typeof props.editor.schema["image"].propSchema.url.default === "string" && + props.editor.schema["image"].propSchema.url.values === undefined && // Checks if the `src` prop is not set to an empty string. - selectedBlocks[0].props.src !== "", + selectedBlocks[0].props.url !== "", [props.editor.schema, selectedBlocks] ); From 4c055f7e6485b36553495ae7ee3c69f99a38b591 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 2 Nov 2023 23:54:19 +0100 Subject: [PATCH 11/44] Added serialization tests --- packages/core/src/BlockNoteExtensions.ts | 2 +- .../formatConversions/formatConversions.ts | 18 +- .../__snapshots__/serialization.test.ts.snap | 73 +++++ .../api/serialization/serialization.test.ts | 296 ++++++++++++++++++ packages/core/src/index.ts | 2 +- 5 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap create mode 100644 packages/core/src/api/serialization/serialization.test.ts diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index 74870d854e..532bb6f1ab 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -24,7 +24,7 @@ import { BlockNoteDOMAttributes, BlockSchema, } from "./extensions/Blocks/api/blockTypes"; -import { createCustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization"; +import { createCustomBlockSerializerExtension } from "./api/serialization/serialization"; import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension"; diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts index 1cd61c4eed..ba20c001c5 100644 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ b/packages/core/src/api/formatConversions/formatConversions.ts @@ -12,7 +12,7 @@ import { Block, BlockSchema } from "../../extensions/Blocks/api/blockTypes"; import { blockToNode, nodeToBlock } from "../nodeConversions/nodeConversions"; import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; import { simplifyBlocks } from "./simplifyBlocksRehypePlugin"; -import { customBlockSerializer } from "../../extensions/Blocks/api/serialization"; +import { customBlockSerializer } from "../serialization/serialization"; import { BlockNoteEditor } from "../../BlockNoteEditor"; export async function blocksToHTML( @@ -135,3 +135,19 @@ export async function markdownToBlocks( return HTMLToBlocks(htmlString.value as string, blockSchema, schema); } + +// Takes structured HTML and makes it comply with the HTML spec using the +// `simplifyBlocks` plugin. +// TODO: Remove classes? +export async function cleanHTML(html: string) { + const htmlString = await unified() + .use(rehypeParse, { fragment: true }) + .use(simplifyBlocks, { + orderedListItemBlockTypes: new Set(["numberedListItem"]), + unorderedListItemBlockTypes: new Set(["bulletListItem"]), + }) + .use(rehypeStringify) + .process(html); + + return htmlString.value as string; +} diff --git a/packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap b/packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap new file mode 100644 index 0000000000..aa9f5b98a0 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap @@ -0,0 +1,73 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Convert images to structured HTML > Convert add image button to structured HTML 1`] = `"

Add Image

"`; + +exports[`Convert images to structured HTML > Convert add image button to structured HTML 2`] = `"

Add Image

"`; + +exports[`Convert images to structured HTML > Convert add image button to structured HTML 3`] = `"

Add Image

"`; + +exports[`Convert images to structured HTML > Convert add image button to structured HTML 4`] = `"

Add Image

"`; + +exports[`Convert images to structured HTML > Convert image to structured HTML 1`] = `"
Caption
"`; + +exports[`Convert images to structured HTML > Convert image to structured HTML 2`] = `"
Caption
"`; + +exports[`Convert images to structured HTML > Convert image to structured HTML 3`] = `"
Caption
"`; + +exports[`Convert images to structured HTML > Convert image to structured HTML 4`] = `"
Caption
"`; + +exports[`Convert images to structured HTML > Convert nested image to structured HTML 1`] = `"
Caption
Caption
"`; + +exports[`Convert images to structured HTML > Convert nested image to structured HTML 2`] = `"
Caption
Caption
"`; + +exports[`Convert images to structured HTML > Convert nested image to structured HTML 3`] = `"
Caption
Caption
"`; + +exports[`Convert images to structured HTML > Convert nested image to structured HTML 4`] = `"
Caption
Caption
"`; + +exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 1`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; + +exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 2`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; + +exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 3`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; + +exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 4`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; + +exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 1`] = `"

Paragraph

"`; + +exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 2`] = `"

Paragraph

"`; + +exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 3`] = `"

Paragraph

"`; + +exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 4`] = `"

Paragraph

"`; + +exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 1`] = `"

Plain Red Text Blue Background Mixed Colors

"`; + +exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 2`] = `"

Plain Red Text Blue Background Mixed Colors

"`; + +exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 3`] = `"

Plain Red Text Blue Background Mixed Colors

"`; + +exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 4`] = `"

Plain Red Text Blue Background Mixed Colors

"`; + +exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 1`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 2`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 3`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 4`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 1`] = `"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 2`] = `"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 3`] = `"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 4`] = `"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 1`] = `"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 2`] = `"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 3`] = `"

\\"placeholder\\"

"`; + +exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 4`] = `"

\\"placeholder\\"

"`; diff --git a/packages/core/src/api/serialization/serialization.test.ts b/packages/core/src/api/serialization/serialization.test.ts new file mode 100644 index 0000000000..cd0bfc17d7 --- /dev/null +++ b/packages/core/src/api/serialization/serialization.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { Editor } from "@tiptap/core"; +import { customBlockSerializer } from "./serialization"; +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY"; +import { createBlockSpec } from "../../extensions/Blocks/api/block"; +import { + imagePropSchema, + renderImage, +} from "../../extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent"; +import { defaultBlockSchema } from "../../extensions/Blocks/api/defaultBlocks"; +import { cleanHTML } from "../formatConversions/formatConversions"; +import { DOMSerializer, Fragment, Node } from "prosemirror-model"; + +// This is a modified version of the default image block that does not implement +// a `serialize` function. It's used to test if the custom serializer by default +// serializes custom blocks using their `render` function. +const SimpleImage = createBlockSpec({ + type: "simpleImage", + propSchema: imagePropSchema, + containsInlineContent: false, + render: renderImage as any, +}); + +const customSchema = { + ...defaultBlockSchema, + simpleImage: SimpleImage, +}; + +let editor: BlockNoteEditor; +let tt: Editor; + +beforeEach(() => { + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; + + editor = new BlockNoteEditor({ + blockSchema: customSchema, + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + }); + tt = editor._tiptapEditor; +}); + +afterEach(() => { + tt.destroy(); + editor = undefined as any; + tt = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); + +// Tests `serializeNode`, `serializeFragment`, and `cleanHTML` methods. +async function serializeAndCompareSnapshots( + serializer: DOMSerializer, + blockGroup: Node +) { + const serializeNodeHTML = ( + serializer.serializeNode(blockGroup) as HTMLElement + ).outerHTML; + const serializeFragmentHTML = ( + serializer.serializeFragment(Fragment.from(blockGroup)) as HTMLElement + ).firstElementChild!.outerHTML; + + expect(serializeNodeHTML).toMatchSnapshot(); + expect(serializeFragmentHTML).toMatchSnapshot(); + + const serializeNodeCleanHTML = await cleanHTML(serializeNodeHTML); + const serializeFragmentCleanHTML = await cleanHTML(serializeFragmentHTML); + + expect(serializeNodeCleanHTML).toMatchSnapshot(); + expect(serializeFragmentCleanHTML).toMatchSnapshot(); +} + +describe("Convert paragraphs to structured HTML", () => { + it("Convert paragraph to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const text = tt.schema.text("Paragraph"); + const paragraph = tt.schema.nodes["paragraph"].create(null, text); + const blockContainer = tt.schema.nodes["blockContainer"].create( + null, + paragraph + ); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); + + it("Convert styled paragraph to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const text = [ + tt.schema.text("Plain "), + tt.schema.text("Red Text ", [ + tt.schema.marks["textColor"].create({ color: "red" }), + ]), + tt.schema.text("Blue Background ", [ + tt.schema.marks["backgroundColor"].create({ color: "blue" }), + ]), + tt.schema.text("Mixed Colors", [ + tt.schema.marks["textColor"].create({ color: "red" }), + tt.schema.marks["backgroundColor"].create({ color: "blue" }), + ]), + ]; + const paragraph = tt.schema.nodes["paragraph"].create( + { textAlignment: "center" }, + text + ); + const blockContainer = tt.schema.nodes["blockContainer"].create( + { textColor: "red", backgroundColor: "blue" }, + paragraph + ); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); + + it("Convert nested paragraph to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const nestedText1 = tt.schema.text("Nested Paragraph 1"); + const nestedParagraph1 = tt.schema.nodes["paragraph"].create( + null, + nestedText1 + ); + const nestedBlockContainer1 = tt.schema.nodes["blockContainer"].create( + null, + nestedParagraph1 + ); + + const nestedText2 = tt.schema.text("Nested Paragraph 2"); + const nestedParagraph2 = tt.schema.nodes["paragraph"].create( + null, + nestedText2 + ); + const nestedBlockContainer2 = tt.schema.nodes["blockContainer"].create( + null, + nestedParagraph2 + ); + + const text = tt.schema.text("Paragraph"); + const paragraph = tt.schema.nodes["paragraph"].create(null, text); + const nestedBlockGroup = tt.schema.nodes["blockGroup"].create(null, [ + nestedBlockContainer1, + nestedBlockContainer2, + ]); + const blockContainer = tt.schema.nodes["blockContainer"].create(null, [ + paragraph, + nestedBlockGroup, + ]); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); +}); + +describe("Convert images to structured HTML", () => { + it("Convert add image button to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const image = tt.schema.nodes["image"].create(null); + const blockContainer = tt.schema.nodes["blockContainer"].create( + null, + image + ); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); + + it("Convert image to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const image = tt.schema.nodes["image"].create({ + url: "exampleURL", + caption: "Caption", + width: 256, + }); + const blockContainer = tt.schema.nodes["blockContainer"].create( + null, + image + ); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); + + it("Convert nested image to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const nestedImage = tt.schema.nodes["image"].create({ + url: "exampleURL", + caption: "Caption", + width: 256, + }); + const nestedBlockContainer = tt.schema.nodes["blockContainer"].create( + null, + nestedImage + ); + + const image = tt.schema.nodes["image"].create({ + url: "exampleURL", + caption: "Caption", + width: 256, + }); + const nestedBlockGroup = tt.schema.nodes["blockGroup"].create(null, [ + nestedBlockContainer, + ]); + const blockContainer = tt.schema.nodes["blockContainer"].create(null, [ + image, + nestedBlockGroup, + ]); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); +}); + +describe("Convert simple images to structured HTML", () => { + it("Convert simple add image button to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const image = tt.schema.nodes["simpleImage"].create(null); + const blockContainer = tt.schema.nodes["blockContainer"].create( + null, + image + ); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); + + it("Convert simple image to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const image = tt.schema.nodes["simpleImage"].create({ + url: "exampleURL", + caption: "Caption", + width: 256, + }); + const blockContainer = tt.schema.nodes["blockContainer"].create( + null, + image + ); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); + + it("Convert nested image to structured HTML", async () => { + const serializer = customBlockSerializer(tt.schema, editor); + + const nestedImage = tt.schema.nodes["simpleImage"].create({ + url: "exampleURL", + caption: "Caption", + width: 256, + }); + const nestedBlockContainer = tt.schema.nodes["blockContainer"].create( + null, + nestedImage + ); + + const image = tt.schema.nodes["simpleImage"].create({ + url: "exampleURL", + caption: "Caption", + width: 256, + }); + const nestedBlockGroup = tt.schema.nodes["blockGroup"].create(null, [ + nestedBlockContainer, + ]); + const blockContainer = tt.schema.nodes["blockContainer"].create(null, [ + image, + nestedBlockGroup, + ]); + const blockGroup = tt.schema.nodes["blockGroup"].create(null, [ + blockContainer, + ]); + + await serializeAndCompareSnapshots(serializer, blockGroup); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08525b7fe2..6072cf7922 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,7 @@ export * from "./extensions/Blocks/api/defaultProps"; export * from "./extensions/Blocks/api/defaultBlocks"; export * from "./extensions/Blocks/api/inlineContentTypes"; export * from "./extensions/Blocks/api/selectionTypes"; -export * from "./extensions/Blocks/api/serialization"; +export * from "./api/serialization/serialization"; export * as blockStyles from "./extensions/Blocks/nodes/Block.module.css"; export * from "./extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; From 3a7d3dc7f99fcbc31d8c557a42e7d51c4ce8438e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 3 Nov 2023 00:24:42 +0100 Subject: [PATCH 12/44] Changed copy/paste implementation --- .../formatConversions/formatConversions.ts | 20 ++- .../serialization}/serialization.ts | 115 +++++++++--------- 2 files changed, 76 insertions(+), 59 deletions(-) rename packages/core/src/{extensions/Blocks/api => api/serialization}/serialization.ts (56%) diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts index ba20c001c5..67309a8143 100644 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ b/packages/core/src/api/formatConversions/formatConversions.ts @@ -139,15 +139,27 @@ export async function markdownToBlocks( // Takes structured HTML and makes it comply with the HTML spec using the // `simplifyBlocks` plugin. // TODO: Remove classes? -export async function cleanHTML(html: string) { - const htmlString = await unified() +export async function cleanHTML(structuredHTMLString: string) { + const cleanHTMLString = await unified() .use(rehypeParse, { fragment: true }) .use(simplifyBlocks, { orderedListItemBlockTypes: new Set(["numberedListItem"]), unorderedListItemBlockTypes: new Set(["bulletListItem"]), }) .use(rehypeStringify) - .process(html); + .process(structuredHTMLString); - return htmlString.value as string; + return cleanHTMLString.value as string; +} + +export async function markdown(cleanHTMLString: string) { + const markdownString = await unified() + .use(rehypeParse, { fragment: true }) + .use(removeUnderlines) + .use(rehypeRemark) + .use(remarkGfm) + .use(remarkStringify) + .process(cleanHTMLString); + + return markdownString.value as string; } diff --git a/packages/core/src/extensions/Blocks/api/serialization.ts b/packages/core/src/api/serialization/serialization.ts similarity index 56% rename from packages/core/src/extensions/Blocks/api/serialization.ts rename to packages/core/src/api/serialization/serialization.ts index d0e8a84a95..5aa9ab11d0 100644 --- a/packages/core/src/extensions/Blocks/api/serialization.ts +++ b/packages/core/src/api/serialization/serialization.ts @@ -1,9 +1,22 @@ +// TODO: IMO this should be part of the formatConversions file since the custom +// serializer is used for all HTML & markdown conversions. I think this would +// also clean up testing converting to clean HTML. import { Extension } from "@tiptap/core"; import { Plugin } from "prosemirror-state"; import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; -import { nodeToBlock } from "../../../api/nodeConversions/nodeConversions"; -import { BlockNoteEditor } from "../../../BlockNoteEditor"; -import { Block, BlockSchema, SpecificBlock } from "./blockTypes"; +import { nodeToBlock } from "../nodeConversions/nodeConversions"; +import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { + BlockSchema, + SpecificBlock, +} from "../../extensions/Blocks/api/blockTypes"; +import { cleanHTML, markdown } from "../formatConversions/formatConversions"; + +const acceptedMIMETypes = [ + "blocknote/html", + "text/html", + "text/plain", +] as const; function doc(options: { document?: Document }) { return options.document || window.document; @@ -100,74 +113,66 @@ export const createCustomBlockSerializerExtension = < ) => Extension.create<{ editor: BlockNoteEditor }, undefined>({ addProseMirrorPlugins() { + const tiptap = this.editor; + const schema = this.editor.schema; return [ new Plugin({ props: { + // TODO: Totally broken on Firefox as it outright doesn't allow + // reading from or writing to the clipboard. handleDOMEvents: { - copy() { - // TODO: Fix editor.getSelection().blocks returning child blocks - // const blocks = editor.getSelection()?.blocks; - const blocks = editor.topLevelBlocks; + copy(_view, event) { + // Stops the default browser copy behaviour. + event.preventDefault(); + event.clipboardData!.clearData(); - if (blocks === undefined || blocks.length === 1) { - return; - } + async function setClipboardData() { + const serializer = customBlockSerializer(schema, editor); - async function copyToClipboard(blocks: Block[]) { - const html = await editor.blocksToHTML(blocks); - const markdown = await editor.blocksToMarkdown(blocks); - const blockNoteHTML = await ( - await ( - await navigator.clipboard.read() - )[0].getType("text/html") - ).text(); - - await navigator.clipboard.write([ - new ClipboardItem({ - "text/html": new Blob([html], { - type: "text/html", - }), - "text/plain": new Blob([markdown], { - type: "text/plain", - }), - "web blocknote/html": new Blob([blockNoteHTML], { - type: "blocknote/html", - }), - }), - ]); - - const formats = [ - "text/html", - "text/plain", - "web blocknote/html", - ] as const; - const items = await navigator.clipboard.read(); - for (const format of formats) { - const blob = await items[0].getType(format); - const text = await blob.text(); - console.log(format); - console.log(text); - } + const selectedFragment = + tiptap.state.selection.content().content; + + const selectedHTML = + serializer.serializeFragment(selectedFragment); + + const parent = document.createElement("div"); + parent.appendChild(selectedHTML); + + const structured = parent.innerHTML; + const clean = await cleanHTML(structured); + const plain = await markdown(clean); + + event.clipboardData!.setData("text/plain", plain); + event.clipboardData!.setData("text/html", clean); + // TODO: Writing to other MIME types not working in Safari for + // some reason. + event.clipboardData!.setData("blocknote/html", structured); } - copyToClipboard(blocks); + setClipboardData(); + + // Prevent default PM handler to be called + return true; }, paste(_view, event) { event.preventDefault(); - async function pasteFromClipboard() { - const items = await navigator.clipboard.read(); - const format = items[0].types.includes("web blocknote/html") - ? "web blocknote/html" - : "text/html"; + let format: (typeof acceptedMIMETypes)[number] | null = null; - const blob = await items[0].getType(format); - const text = await blob.text(); + for (const mimeType of acceptedMIMETypes) { + if (event.clipboardData!.types.includes(mimeType)) { + format = mimeType; + break; + } + } - editor._tiptapEditor.view.pasteHTML(text); + if (format !== null) { + editor._tiptapEditor.view.pasteHTML( + event.clipboardData!.getData(format!) + ); } - pasteFromClipboard(); + return true; }, }, }, From cb0b2313adeab92cf2d893159bb2b50b1807f3dc Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 3 Nov 2023 13:47:15 +0100 Subject: [PATCH 13/44] Small fix --- packages/react/src/ReactBlockSpec.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index baa6314686..67e4024981 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -10,6 +10,7 @@ import { getBlockFromPos, mergeCSSClasses, parse, + Props, PropSchema, propsToAttributes, SpecificBlock, @@ -206,12 +207,13 @@ export function createReactBlockSpec< element.innerHTML = renderToString(); } - return wrapInBlockStructure< - BType, - PSchema, - ContainsInlineContent, - BSchema - >({ dom: element }, block as any, blockContentDOMAttributes).dom; + return wrapInBlockStructure( + { dom: element }, + block.type as BType, + block.props as Props, + blockConfig.propSchema, + blockContentDOMAttributes + ).dom; }, }; } From e121c317ba2c9caaecaee41c81054bc64a98673e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 5 Nov 2023 17:52:28 +0100 Subject: [PATCH 14/44] Implemented PR feedback --- .../blockManipulation.test.ts | 4 - .../formatConversions.test.ts | 4 - .../formatConversions/formatConversions.ts | 2 +- .../nodeConversions/nodeConversions.test.ts | 4 - .../__snapshots__/imageButtonClean.html | 1 + .../__snapshots__/imageButtonStructured.html | 1 + .../__snapshots__/imageClean.html | 1 + .../__snapshots__/imageNestedClean.html | 1 + .../__snapshots__/imageNestedStructured.html | 1 + .../__snapshots__/imageStructured.html | 1 + .../__snapshots__/paragraphClean.html | 1 + .../__snapshots__/paragraphNestedClean.html | 1 + .../paragraphNestedStructured.html | 1 + .../__snapshots__/paragraphStructured.html | 1 + .../__snapshots__/paragraphStyledClean.html | 1 + .../paragraphStyledStructured.html | 1 + .../__snapshots__/serialization.test.ts.snap | 73 ------------------- .../__snapshots__/simpleImageButtonClean.html | 1 + .../simpleImageButtonStructured.html | 1 + .../__snapshots__/simpleImageClean.html | 1 + .../__snapshots__/simpleImageNestedClean.html | 1 + .../simpleImageNestedStructured.html | 1 + .../__snapshots__/simpleImageStructured.html | 1 + .../api/serialization/serialization.test.ts | 51 +++++++++---- packages/core/vite.config.ts | 1 + packages/core/vitestSetup.ts | 9 +++ 26 files changed, 64 insertions(+), 102 deletions(-) create mode 100644 packages/core/src/api/serialization/__snapshots__/imageButtonClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/imageButtonStructured.html create mode 100644 packages/core/src/api/serialization/__snapshots__/imageClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/imageNestedClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/imageNestedStructured.html create mode 100644 packages/core/src/api/serialization/__snapshots__/imageStructured.html create mode 100644 packages/core/src/api/serialization/__snapshots__/paragraphClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/paragraphNestedClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/paragraphNestedStructured.html create mode 100644 packages/core/src/api/serialization/__snapshots__/paragraphStructured.html create mode 100644 packages/core/src/api/serialization/__snapshots__/paragraphStyledClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/paragraphStyledStructured.html delete mode 100644 packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap create mode 100644 packages/core/src/api/serialization/__snapshots__/simpleImageButtonClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/simpleImageButtonStructured.html create mode 100644 packages/core/src/api/serialization/__snapshots__/simpleImageClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/simpleImageNestedClean.html create mode 100644 packages/core/src/api/serialization/__snapshots__/simpleImageNestedStructured.html create mode 100644 packages/core/src/api/serialization/__snapshots__/simpleImageStructured.html create mode 100644 packages/core/vitestSetup.ts diff --git a/packages/core/src/api/blockManipulation/blockManipulation.test.ts b/packages/core/src/api/blockManipulation/blockManipulation.test.ts index 790962015a..4185f4b6b4 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.test.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.test.ts @@ -21,8 +21,6 @@ let multipleBlocks: PartialBlock[]; let insert: (placement: "before" | "nested" | "after") => Block[]; beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - editor = new BlockNoteEditor(); singleBlock = { @@ -76,8 +74,6 @@ beforeEach(() => { afterEach(() => { editor._tiptapEditor.destroy(); editor = undefined as any; - - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; }); describe("Inserting Blocks with Different Placements", () => { diff --git a/packages/core/src/api/formatConversions/formatConversions.test.ts b/packages/core/src/api/formatConversions/formatConversions.test.ts index bcf6714acf..8b3a5d4576 100644 --- a/packages/core/src/api/formatConversions/formatConversions.test.ts +++ b/packages/core/src/api/formatConversions/formatConversions.test.ts @@ -579,16 +579,12 @@ function removeInlineContentClass(html: string) { } beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - editor = new BlockNoteEditor(); }); afterEach(() => { editor._tiptapEditor.destroy(); editor = undefined as any; - - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; }); describe("Non-Nested Block/HTML/Markdown Conversions", () => { diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts index 67309a8143..7fabdf9dc4 100644 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ b/packages/core/src/api/formatConversions/formatConversions.ts @@ -24,7 +24,7 @@ export async function blocksToHTML( const serializer = customBlockSerializer(schema, editor); for (const block of blocks) { - const node = blockToNode(block, editor._tiptapEditor.schema); + const node = blockToNode(block, schema); const htmlNode = serializer.serializeNode(node); htmlParentElement.appendChild(htmlNode); } diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts index 37bdadfdb9..7c26165cca 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts @@ -13,8 +13,6 @@ let editor: BlockNoteEditor; let tt: Editor; beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - editor = new BlockNoteEditor(); tt = editor._tiptapEditor; }); @@ -23,8 +21,6 @@ afterEach(() => { tt.destroy(); editor = undefined as any; tt = undefined as any; - - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; }); describe("Simple ProseMirror Node Conversions", () => { diff --git a/packages/core/src/api/serialization/__snapshots__/imageButtonClean.html b/packages/core/src/api/serialization/__snapshots__/imageButtonClean.html new file mode 100644 index 0000000000..de77120ebf --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/imageButtonClean.html @@ -0,0 +1 @@ +

Add Image

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/imageButtonStructured.html b/packages/core/src/api/serialization/__snapshots__/imageButtonStructured.html new file mode 100644 index 0000000000..fd0f1be519 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/imageButtonStructured.html @@ -0,0 +1 @@ +

Add Image

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/imageClean.html b/packages/core/src/api/serialization/__snapshots__/imageClean.html new file mode 100644 index 0000000000..f214a9a441 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/imageClean.html @@ -0,0 +1 @@ +
Caption
\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/imageNestedClean.html b/packages/core/src/api/serialization/__snapshots__/imageNestedClean.html new file mode 100644 index 0000000000..1a4a0986a2 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/imageNestedClean.html @@ -0,0 +1 @@ +
Caption
Caption
\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/imageNestedStructured.html b/packages/core/src/api/serialization/__snapshots__/imageNestedStructured.html new file mode 100644 index 0000000000..cd78e3ff6e --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/imageNestedStructured.html @@ -0,0 +1 @@ +
Caption
Caption
\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/imageStructured.html b/packages/core/src/api/serialization/__snapshots__/imageStructured.html new file mode 100644 index 0000000000..1fb4543a29 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/imageStructured.html @@ -0,0 +1 @@ +
Caption
\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/paragraphClean.html b/packages/core/src/api/serialization/__snapshots__/paragraphClean.html new file mode 100644 index 0000000000..d22655804b --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/paragraphClean.html @@ -0,0 +1 @@ +

Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/paragraphNestedClean.html b/packages/core/src/api/serialization/__snapshots__/paragraphNestedClean.html new file mode 100644 index 0000000000..a42efd34b1 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/paragraphNestedClean.html @@ -0,0 +1 @@ +

Paragraph

Nested Paragraph 1

Nested Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/paragraphNestedStructured.html b/packages/core/src/api/serialization/__snapshots__/paragraphNestedStructured.html new file mode 100644 index 0000000000..de08200764 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/paragraphNestedStructured.html @@ -0,0 +1 @@ +

Paragraph

Nested Paragraph 1

Nested Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/paragraphStructured.html b/packages/core/src/api/serialization/__snapshots__/paragraphStructured.html new file mode 100644 index 0000000000..4bf862bab5 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/paragraphStructured.html @@ -0,0 +1 @@ +

Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/paragraphStyledClean.html b/packages/core/src/api/serialization/__snapshots__/paragraphStyledClean.html new file mode 100644 index 0000000000..123bc3114a --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/paragraphStyledClean.html @@ -0,0 +1 @@ +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/paragraphStyledStructured.html b/packages/core/src/api/serialization/__snapshots__/paragraphStyledStructured.html new file mode 100644 index 0000000000..164f2d42a5 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/paragraphStyledStructured.html @@ -0,0 +1 @@ +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap b/packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap deleted file mode 100644 index aa9f5b98a0..0000000000 --- a/packages/core/src/api/serialization/__snapshots__/serialization.test.ts.snap +++ /dev/null @@ -1,73 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Convert images to structured HTML > Convert add image button to structured HTML 1`] = `"

Add Image

"`; - -exports[`Convert images to structured HTML > Convert add image button to structured HTML 2`] = `"

Add Image

"`; - -exports[`Convert images to structured HTML > Convert add image button to structured HTML 3`] = `"

Add Image

"`; - -exports[`Convert images to structured HTML > Convert add image button to structured HTML 4`] = `"

Add Image

"`; - -exports[`Convert images to structured HTML > Convert image to structured HTML 1`] = `"
Caption
"`; - -exports[`Convert images to structured HTML > Convert image to structured HTML 2`] = `"
Caption
"`; - -exports[`Convert images to structured HTML > Convert image to structured HTML 3`] = `"
Caption
"`; - -exports[`Convert images to structured HTML > Convert image to structured HTML 4`] = `"
Caption
"`; - -exports[`Convert images to structured HTML > Convert nested image to structured HTML 1`] = `"
Caption
Caption
"`; - -exports[`Convert images to structured HTML > Convert nested image to structured HTML 2`] = `"
Caption
Caption
"`; - -exports[`Convert images to structured HTML > Convert nested image to structured HTML 3`] = `"
Caption
Caption
"`; - -exports[`Convert images to structured HTML > Convert nested image to structured HTML 4`] = `"
Caption
Caption
"`; - -exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 1`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; - -exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 2`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; - -exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 3`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; - -exports[`Convert paragraphs to structured HTML > Convert nested paragraph to structured HTML 4`] = `"

Paragraph

Nested Paragraph 1

Nested Paragraph 2

"`; - -exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 1`] = `"

Paragraph

"`; - -exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 2`] = `"

Paragraph

"`; - -exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 3`] = `"

Paragraph

"`; - -exports[`Convert paragraphs to structured HTML > Convert paragraph to structured HTML 4`] = `"

Paragraph

"`; - -exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 1`] = `"

Plain Red Text Blue Background Mixed Colors

"`; - -exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 2`] = `"

Plain Red Text Blue Background Mixed Colors

"`; - -exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 3`] = `"

Plain Red Text Blue Background Mixed Colors

"`; - -exports[`Convert paragraphs to structured HTML > Convert styled paragraph to structured HTML 4`] = `"

Plain Red Text Blue Background Mixed Colors

"`; - -exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 1`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 2`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 3`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert nested image to structured HTML 4`] = `"

\\"placeholder\\"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 1`] = `"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 2`] = `"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 3`] = `"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple add image button to structured HTML 4`] = `"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 1`] = `"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 2`] = `"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 3`] = `"

\\"placeholder\\"

"`; - -exports[`Convert simple images to structured HTML > Convert simple image to structured HTML 4`] = `"

\\"placeholder\\"

"`; diff --git a/packages/core/src/api/serialization/__snapshots__/simpleImageButtonClean.html b/packages/core/src/api/serialization/__snapshots__/simpleImageButtonClean.html new file mode 100644 index 0000000000..062865b7e1 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/simpleImageButtonClean.html @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/simpleImageButtonStructured.html b/packages/core/src/api/serialization/__snapshots__/simpleImageButtonStructured.html new file mode 100644 index 0000000000..b0eca2eee3 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/simpleImageButtonStructured.html @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/simpleImageClean.html b/packages/core/src/api/serialization/__snapshots__/simpleImageClean.html new file mode 100644 index 0000000000..c2fa21d812 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/simpleImageClean.html @@ -0,0 +1 @@ +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/simpleImageNestedClean.html b/packages/core/src/api/serialization/__snapshots__/simpleImageNestedClean.html new file mode 100644 index 0000000000..68bc1864af --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/simpleImageNestedClean.html @@ -0,0 +1 @@ +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/simpleImageNestedStructured.html b/packages/core/src/api/serialization/__snapshots__/simpleImageNestedStructured.html new file mode 100644 index 0000000000..925bc7037a --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/simpleImageNestedStructured.html @@ -0,0 +1 @@ +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/serialization/__snapshots__/simpleImageStructured.html b/packages/core/src/api/serialization/__snapshots__/simpleImageStructured.html new file mode 100644 index 0000000000..397022a664 --- /dev/null +++ b/packages/core/src/api/serialization/__snapshots__/simpleImageStructured.html @@ -0,0 +1 @@ +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/serialization/serialization.test.ts b/packages/core/src/api/serialization/serialization.test.ts index cd0bfc17d7..cef8f71eeb 100644 --- a/packages/core/src/api/serialization/serialization.test.ts +++ b/packages/core/src/api/serialization/serialization.test.ts @@ -31,8 +31,6 @@ let editor: BlockNoteEditor; let tt: Editor; beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - editor = new BlockNoteEditor({ blockSchema: customSchema, uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, @@ -51,7 +49,8 @@ afterEach(() => { // Tests `serializeNode`, `serializeFragment`, and `cleanHTML` methods. async function serializeAndCompareSnapshots( serializer: DOMSerializer, - blockGroup: Node + blockGroup: Node, + snapshotName: string ) { const serializeNodeHTML = ( serializer.serializeNode(blockGroup) as HTMLElement @@ -59,15 +58,19 @@ async function serializeAndCompareSnapshots( const serializeFragmentHTML = ( serializer.serializeFragment(Fragment.from(blockGroup)) as HTMLElement ).firstElementChild!.outerHTML; + const structuredHTMLSnapshotPath = + "./__snapshots__/" + snapshotName + "Structured.html"; - expect(serializeNodeHTML).toMatchSnapshot(); - expect(serializeFragmentHTML).toMatchSnapshot(); + expect(serializeNodeHTML).toMatchFileSnapshot(structuredHTMLSnapshotPath); + expect(serializeFragmentHTML).toMatchFileSnapshot(structuredHTMLSnapshotPath); const serializeNodeCleanHTML = await cleanHTML(serializeNodeHTML); const serializeFragmentCleanHTML = await cleanHTML(serializeFragmentHTML); + const cleanHTMLSnapshotPath = + "./__snapshots__/" + snapshotName + "Clean.html"; - expect(serializeNodeCleanHTML).toMatchSnapshot(); - expect(serializeFragmentCleanHTML).toMatchSnapshot(); + expect(serializeNodeCleanHTML).toMatchFileSnapshot(cleanHTMLSnapshotPath); + expect(serializeFragmentCleanHTML).toMatchFileSnapshot(cleanHTMLSnapshotPath); } describe("Convert paragraphs to structured HTML", () => { @@ -84,7 +87,7 @@ describe("Convert paragraphs to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots(serializer, blockGroup, "paragraph"); }); it("Convert styled paragraph to structured HTML", async () => { @@ -115,7 +118,11 @@ describe("Convert paragraphs to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots( + serializer, + blockGroup, + "paragraphStyled" + ); }); it("Convert nested paragraph to structured HTML", async () => { @@ -155,7 +162,11 @@ describe("Convert paragraphs to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots( + serializer, + blockGroup, + "paragraphNested" + ); }); }); @@ -172,7 +183,7 @@ describe("Convert images to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots(serializer, blockGroup, "imageButton"); }); it("Convert image to structured HTML", async () => { @@ -191,7 +202,7 @@ describe("Convert images to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots(serializer, blockGroup, "image"); }); it("Convert nested image to structured HTML", async () => { @@ -223,7 +234,7 @@ describe("Convert images to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots(serializer, blockGroup, "imageNested"); }); }); @@ -240,7 +251,11 @@ describe("Convert simple images to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots( + serializer, + blockGroup, + "simpleImageButton" + ); }); it("Convert simple image to structured HTML", async () => { @@ -259,7 +274,7 @@ describe("Convert simple images to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots(serializer, blockGroup, "simpleImage"); }); it("Convert nested image to structured HTML", async () => { @@ -291,6 +306,10 @@ describe("Convert simple images to structured HTML", () => { blockContainer, ]); - await serializeAndCompareSnapshots(serializer, blockGroup); + await serializeAndCompareSnapshots( + serializer, + blockGroup, + "simpleImageNested" + ); }); }); diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index dd95c8759b..d3389d8eb6 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -9,6 +9,7 @@ const deps = Object.keys(pkg.dependencies); export default defineConfig({ test: { environment: "jsdom", + setupFiles: ["./vitestSetup.ts"], }, plugins: [], build: { diff --git a/packages/core/vitestSetup.ts b/packages/core/vitestSetup.ts new file mode 100644 index 0000000000..78f5b890bf --- /dev/null +++ b/packages/core/vitestSetup.ts @@ -0,0 +1,9 @@ +import { beforeEach, afterEach } from "vitest"; + +beforeEach(() => { + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); From 3d56aaf3f1f07ba609324079d2c55eddb84890b5 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 6 Nov 2023 21:32:56 +0100 Subject: [PATCH 15/44] Converted styles from modules to regular CSS --- .../editor/src/{App.module.css => App.css} | 0 examples/editor/src/App.tsx | 4 ++-- packages/core/src/BlockNoteEditor.ts | 8 +++---- packages/core/src/BlockNoteExtensions.ts | 16 +++++++------- .../src/{editor.module.css => editor.css} | 0 .../core/src/extensions/Blocks/api/block.ts | 5 ++--- .../nodes/{Block.module.css => Block.css} | 0 .../extensions/Blocks/nodes/BlockContainer.ts | 5 ++--- .../HeadingBlockContent.ts | 8 ++----- .../ImageBlockContent/ImageBlockContent.ts | 21 +++++++++---------- .../BulletListItemBlockContent.ts | 8 ++----- .../NumberedListItemBlockContent.ts | 8 ++----- .../ParagraphBlockContent.ts | 5 ++--- .../src/extensions/Blocks/nodes/BlockGroup.ts | 6 +----- .../src/extensions/SideMenu/SideMenuPlugin.ts | 7 +------ packages/core/src/index.ts | 2 +- packages/react/src/BlockNoteTheme.ts | 14 ++++++------- .../DefaultButtons/ImageCaptionButton.tsx | 2 +- 18 files changed, 45 insertions(+), 74 deletions(-) rename examples/editor/src/{App.module.css => App.css} (100%) rename packages/core/src/{editor.module.css => editor.css} (100%) rename packages/core/src/extensions/Blocks/nodes/{Block.module.css => Block.css} (100%) diff --git a/examples/editor/src/App.module.css b/examples/editor/src/App.css similarity index 100% rename from examples/editor/src/App.module.css rename to examples/editor/src/App.css diff --git a/examples/editor/src/App.tsx b/examples/editor/src/App.tsx index 128cdee58a..b4acf6ca17 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/src/App.tsx @@ -1,7 +1,7 @@ // import logo from './logo.svg' import "@blocknote/core/style.css"; import { BlockNoteView, useBlockNote } from "@blocknote/react"; -import styles from "./App.module.css"; +import "./App.css"; import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; @@ -13,7 +13,7 @@ function App() { }, domAttributes: { editor: { - class: styles.editor, + class: "editor", "data-test": "editor", }, }, diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 390077900c..73ce08d2d6 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -21,7 +21,6 @@ import { nodeToBlock, } from "./api/nodeConversions/nodeConversions"; import { getNodeById } from "./api/util/nodeUtil"; -import styles from "./editor.module.css"; import { Block, BlockIdentifier, @@ -51,6 +50,7 @@ import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlug import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; import { UniqueID } from "./extensions/UniqueID/UniqueID"; import { mergeCSSClasses } from "./shared/utils"; +import "./editor.css"; export type BlockNoteEditorOptions = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. @@ -288,9 +288,9 @@ export class BlockNoteEditor { attributes: { ...newOptions.domAttributes?.editor, class: mergeCSSClasses( - styles.bnEditor, - styles.bnRoot, - newOptions.defaultStyles ? styles.defaultStyles : "", + "bnEditor", + "bnRoot", + newOptions.defaultStyles ? "defaultStyles" : "", newOptions.domAttributes?.editor?.class || "" ), }, diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index 532bb6f1ab..71514c9e44 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -16,7 +16,6 @@ import { Strike } from "@tiptap/extension-strike"; import { Text } from "@tiptap/extension-text"; import { Underline } from "@tiptap/extension-underline"; import * as Y from "yjs"; -import styles from "./editor.module.css"; import { BackgroundColorExtension } from "./extensions/BackgroundColor/BackgroundColorExtension"; import { BackgroundColorMark } from "./extensions/BackgroundColor/BackgroundColorMark"; import { BlockContainer, BlockGroup, Doc } from "./extensions/Blocks"; @@ -24,8 +23,7 @@ import { BlockNoteDOMAttributes, BlockSchema, } from "./extensions/Blocks/api/blockTypes"; -import { createCustomBlockSerializerExtension } from "./api/serialization/serialization"; -import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; +import { createClipboardHandlerExtension } from "./api/serialization/serialization"; import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension"; import { TextColorExtension } from "./extensions/TextColor/TextColorExtension"; @@ -62,9 +60,9 @@ export const getBlockNoteExtensions = (opts: { // DropCursor, Placeholder.configure({ - emptyNodeClass: blockStyles.isEmpty, - hasAnchorClass: blockStyles.hasAnchor, - isFilterClass: blockStyles.isFilter, + emptyNodeClass: "isEmpty", + hasAnchorClass: "hasAnchor", + isFilterClass: "isFilter", includeChildren: true, showOnlyCurrent: false, }), @@ -104,7 +102,7 @@ export const getBlockNoteExtensions = (opts: { domAttributes: opts.domAttributes, }) ), - createCustomBlockSerializerExtension(opts.editor), + createClipboardHandlerExtension(opts.editor), Dropcursor.configure({ width: 5, color: "#ddeeff" }), // This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command), @@ -122,12 +120,12 @@ export const getBlockNoteExtensions = (opts: { const defaultRender = (user: { color: string; name: string }) => { const cursor = document.createElement("span"); - cursor.classList.add(styles["collaboration-cursor__caret"]); + cursor.classList.add("collaboration-cursor__caret"); cursor.setAttribute("style", `border-color: ${user.color}`); const label = document.createElement("span"); - label.classList.add(styles["collaboration-cursor__label"]); + label.classList.add("collaboration-cursor__label"); label.setAttribute("style", `background-color: ${user.color}`); label.insertBefore(document.createTextNode(user.name), null); diff --git a/packages/core/src/editor.module.css b/packages/core/src/editor.css similarity index 100% rename from packages/core/src/editor.module.css rename to packages/core/src/editor.css diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index fcb763949e..1d1720fd7a 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -2,7 +2,6 @@ import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; import { Fragment, ParseRule } from "prosemirror-model"; import { BlockNoteEditor, Props } from "../../.."; import { inlineContentToNodes } from "../../../api/nodeConversions/nodeConversions"; -import styles from "../nodes/Block.module.css"; import { BlockConfig, BlockNoteDOMAttributes, @@ -181,7 +180,7 @@ export function wrapInBlockStructure< } // Sets blockContent class blockContent.className = mergeCSSClasses( - styles.blockContent, + "blockContent", domAttributes?.class || "" ); // Sets content type attribute @@ -197,7 +196,7 @@ export function wrapInBlockStructure< if (element.contentDOM !== undefined) { element.contentDOM.className = mergeCSSClasses( - styles.inlineContent, + "inlineContent", element.contentDOM.className ); diff --git a/packages/core/src/extensions/Blocks/nodes/Block.module.css b/packages/core/src/extensions/Blocks/nodes/Block.css similarity index 100% rename from packages/core/src/extensions/Blocks/nodes/Block.module.css rename to packages/core/src/extensions/Blocks/nodes/Block.css diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 64fcb85f92..5477594a8f 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -13,7 +13,6 @@ import { } from "../api/blockTypes"; import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos"; import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; -import styles from "./Block.module.css"; import BlockAttributes from "./BlockAttributes"; import { mergeCSSClasses } from "../../../shared/utils"; import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin"; @@ -83,7 +82,7 @@ export const BlockContainer = Node.create<{ return [ "div", mergeAttributes(HTMLAttributes, { - class: styles.blockOuter, + class: "blockOuter", "data-node-type": "block-outer", }), [ @@ -91,7 +90,7 @@ export const BlockContainer = Node.create<{ mergeAttributes( { ...domAttributes, - class: mergeCSSClasses(styles.block, domAttributes.class), + class: mergeCSSClasses("block", domAttributes.class), "data-node-type": this.name, }, HTMLAttributes diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts index ad9d7b73d8..649ff29593 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts @@ -3,7 +3,6 @@ import { defaultProps } from "../../../api/defaultProps"; import { createTipTapBlock } from "../../../api/block"; import { BlockSpec, PropSchema } from "../../../api/blockTypes"; import { mergeCSSClasses } from "../../../../../shared/utils"; -import styles from "../../Block.module.css"; export const headingPropSchema = { ...defaultProps, @@ -115,10 +114,7 @@ const HeadingBlockContent = createTipTapBlock<"heading", true>({ "div", mergeAttributes(HTMLAttributes, { ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), + class: mergeCSSClasses("blockContent", blockContentDOMAttributes.class), "data-content-type": this.name, }), [ @@ -126,7 +122,7 @@ const HeadingBlockContent = createTipTapBlock<"heading", true>({ { ...inlineContentDOMAttributes, class: mergeCSSClasses( - styles.inlineContent, + "inlineContent", inlineContentDOMAttributes.class ), }, diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index d4546264b5..00c42ada90 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -3,7 +3,6 @@ import { defaultProps } from "../../../api/defaultProps"; import { BlockSpec, PropSchema, SpecificBlock } from "../../../api/blockTypes"; import { BlockNoteEditor } from "../../../../../BlockNoteEditor"; import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin"; -import styles from "../../Block.module.css"; export const imagePropSchema = { textAlignment: defaultProps.textAlignment, @@ -53,38 +52,38 @@ export const renderImage = ( // Wrapper element to set the image alignment, contains both image/image // upload dashboard and caption. const wrapper = document.createElement("div"); - wrapper.className = styles.wrapper; + wrapper.className = "wrapper"; wrapper.style.alignItems = textAlignmentToAlignItems( block.props.textAlignment ); // Button element that acts as a placeholder for images with no src. const addImageButton = document.createElement("div"); - addImageButton.className = styles.addImageButton; + addImageButton.className = "addImageButton"; addImageButton.style.display = block.props.url === "" ? "" : "none"; // Icon for the add image button. const addImageButtonIcon = document.createElement("div"); - addImageButtonIcon.className = styles.addImageButtonIcon; + addImageButtonIcon.className = "addImageButtonIcon"; // Text for the add image button. const addImageButtonText = document.createElement("p"); - addImageButtonText.className = styles.addImageButtonText; + addImageButtonText.className = "addImageButtonText"; addImageButtonText.innerText = "Add Image"; // Wrapper element for the image, resize handles and caption. const imageAndCaptionWrapper = document.createElement("div"); - imageAndCaptionWrapper.className = styles.imageAndCaptionWrapper; + imageAndCaptionWrapper.className = "imageAndCaptionWrapper"; imageAndCaptionWrapper.style.display = block.props.url !== "" ? "" : "none"; // Wrapper element for the image and resize handles. const imageWrapper = document.createElement("div"); - imageWrapper.className = styles.imageWrapper; + imageWrapper.className = "imageWrapper"; imageWrapper.style.display = block.props.url !== "" ? "" : "none"; // Image element. const image = document.createElement("img"); - image.className = styles.image; + image.className = "image"; image.src = block.props.url; image.alt = "placeholder"; image.contentEditable = "false"; @@ -96,15 +95,15 @@ export const renderImage = ( // Resize handle elements. const leftResizeHandle = document.createElement("div"); - leftResizeHandle.className = styles.resizeHandle; + leftResizeHandle.className = "resizeHandle"; leftResizeHandle.style.left = "4px"; const rightResizeHandle = document.createElement("div"); - rightResizeHandle.className = styles.resizeHandle; + rightResizeHandle.className = "resizeHandle"; rightResizeHandle.style.right = "4px"; // Caption element. const caption = document.createElement("p"); - caption.className = styles.caption; + caption.className = "caption"; caption.innerText = block.props.caption; caption.style.padding = block.props.caption ? "4px" : ""; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index 04c97338fb..06c04437de 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -4,7 +4,6 @@ import { createTipTapBlock } from "../../../../api/block"; import { BlockSpec, PropSchema } from "../../../../api/blockTypes"; import { mergeCSSClasses } from "../../../../../../shared/utils"; import { handleEnter } from "../ListItemKeyboardShortcuts"; -import styles from "../../../Block.module.css"; export const bulletListItemPropSchema = { ...defaultProps, @@ -109,10 +108,7 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({ "div", mergeAttributes(HTMLAttributes, { ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), + class: mergeCSSClasses("blockContent", blockContentDOMAttributes.class), "data-content-type": this.name, }), [ @@ -120,7 +116,7 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({ { ...inlineContentDOMAttributes, class: mergeCSSClasses( - styles.inlineContent, + "inlineContent", inlineContentDOMAttributes.class ), }, diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index 5972e215c0..f26585851d 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -5,7 +5,6 @@ import { BlockSpec, PropSchema } from "../../../../api/blockTypes"; import { mergeCSSClasses } from "../../../../../../shared/utils"; import { handleEnter } from "../ListItemKeyboardShortcuts"; import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin"; -import styles from "../../../Block.module.css"; export const numberedListItemPropSchema = { ...defaultProps, @@ -133,10 +132,7 @@ const NumberedListItemBlockContent = createTipTapBlock< "div", mergeAttributes(HTMLAttributes, { ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), + class: mergeCSSClasses("blockContent", blockContentDOMAttributes.class), "data-content-type": this.name, }), // we use a

tag, because for

  • tags we'd need to add a