diff --git a/examples/editor/package.json b/examples/editor/package.json index 1101a3d4e..791f58953 100644 --- a/examples/editor/package.json +++ b/examples/editor/package.json @@ -12,7 +12,8 @@ "@blocknote/core": "^0.8.1", "@blocknote/react": "^0.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zod": "^3.21.4" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/examples/editor/src/App.tsx b/examples/editor/src/App.tsx index 55ce63eb9..c4c11f162 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/src/App.tsx @@ -1,8 +1,67 @@ // import logo from './logo.svg' +import { z } from 'zod'; +import { DefaultBlockSchema, defaultBlockSchema } from '@blocknote/core'; +import { BlockNoteView, useBlockNote, createReactBlockSpec, ReactSlashMenuItem, defaultReactSlashMenuItems } from "@blocknote/react"; import "@blocknote/core/style.css"; -import { BlockNoteView, useBlockNote } from "@blocknote/react"; import styles from "./App.module.css"; +export const AccordionBlock = createReactBlockSpec({ + type: 'accordion', + propSchema: z.object({ + label: z.string(), + autoLayout: z.object({ + enabled: z.boolean(), + }).optional(), + }), + render: ({ editor, block }) => { + return ( + <> +

{block.props.label}

+ { + block.props.autoLayout?.enabled ? + ( +
+ Enabled +
+ ) : + <> + } + + ); + }, + containsInlineContent: false, +}); + +// Creates a slash menu item for inserting an image block. +export const insertAccordion = new ReactSlashMenuItem< + DefaultBlockSchema & { accordion: typeof AccordionBlock } +>( + 'Insert Accordion', + (editor) => { + editor.insertBlocks( + [ + // Default values are set here + { + type: 'accordion', + props: { + label: 'Default', + autoLayout: { + enabled: true + } + }, + }, + ], + editor.getTextCursorPosition().block, + 'before' + ); + }, + ['accordion'], + 'Containers', + <>+, + 'Used to group content in an accordion.' +); + + type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; function App() { @@ -10,6 +69,14 @@ function App() { onEditorContentChange: (editor) => { console.log(editor.topLevelBlocks); }, + blockSchema: { + ...defaultBlockSchema, + accordion: AccordionBlock, + }, + slashCommands: [ + ...defaultReactSlashMenuItems, + insertAccordion + ], editorDOMAttributes: { class: styles.editor, "data-test": "editor", diff --git a/package-lock.json b/package-lock.json index f66c08954..0e7e071cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "@blocknote/core": "^0.8.1", "@blocknote/react": "^0.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zod": "^3.21.4" }, "devDependencies": { "@types/react": "^18.0.25", @@ -20459,6 +20460,14 @@ "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz", "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==" }, + "node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -20506,7 +20515,8 @@ "uuid": "^8.3.2", "y-prosemirror": "1.0.20", "y-protocols": "^1.0.5", - "yjs": "^13.6.1" + "yjs": "^13.6.1", + "zod": "^3.21.4" }, "devDependencies": { "@types/hast": "^2.3.4", diff --git a/packages/core/package.json b/packages/core/package.json index 91c2b7fed..beb372216 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,7 +82,8 @@ "uuid": "^8.3.2", "y-prosemirror": "1.0.20", "y-protocols": "^1.0.5", - "yjs": "^13.6.1" + "yjs": "^13.6.1", + "zod": "^3.21.4" }, "devDependencies": { "@types/hast": "^2.3.4", diff --git a/packages/core/src/api/formatConversions/formatConversions.test.ts b/packages/core/src/api/formatConversions/formatConversions.test.ts index 0d8e4054c..5da57ecec 100644 --- a/packages/core/src/api/formatConversions/formatConversions.test.ts +++ b/packages/core/src/api/formatConversions/formatConversions.test.ts @@ -35,8 +35,8 @@ beforeEach(() => { id: UniqueID.options.generateID(), type: "heading", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", level: "1", }, @@ -53,8 +53,8 @@ beforeEach(() => { id: UniqueID.options.generateID(), type: "paragraph", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -70,8 +70,8 @@ beforeEach(() => { id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -87,8 +87,8 @@ beforeEach(() => { id: UniqueID.options.generateID(), type: "numberedListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -116,8 +116,8 @@ Paragraph id: UniqueID.options.generateID(), type: "heading", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", level: "1", }, @@ -133,8 +133,8 @@ Paragraph id: UniqueID.options.generateID(), type: "paragraph", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -149,8 +149,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -165,8 +165,8 @@ Paragraph id: UniqueID.options.generateID(), type: "numberedListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -200,8 +200,8 @@ Paragraph id: UniqueID.options.generateID(), type: "paragraph", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -323,8 +323,8 @@ Paragraph id: UniqueID.options.generateID(), type: "paragraph", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -343,8 +343,8 @@ Paragraph id: UniqueID.options.generateID(), type: "paragraph", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -379,8 +379,8 @@ Paragraph id: UniqueID.options.generateID(), type: "paragraph", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -415,8 +415,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -432,8 +432,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -448,8 +448,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -464,8 +464,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -481,8 +481,8 @@ Paragraph id: UniqueID.options.generateID(), type: "paragraph", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -498,8 +498,8 @@ Paragraph id: UniqueID.options.generateID(), type: "numberedListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -515,8 +515,8 @@ Paragraph id: UniqueID.options.generateID(), type: "numberedListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -532,8 +532,8 @@ Paragraph id: UniqueID.options.generateID(), type: "numberedListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -548,8 +548,8 @@ Paragraph id: UniqueID.options.generateID(), type: "numberedListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -567,8 +567,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -586,8 +586,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ @@ -605,8 +605,8 @@ Paragraph id: UniqueID.options.generateID(), type: "bulletListItem", props: { - backgroundColor: "default", - textColor: "default", + backgroundColor: "transparent", + textColor: "black", textAlignment: "left", }, content: [ diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index dc7f53c22..3b07844d4 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -4,6 +4,7 @@ import { Block, BlockSchema, PartialBlock, + Props, } from "../../extensions/Blocks/api/blockTypes"; import { defaultProps } from "../../extensions/Blocks/api/defaultBlocks"; @@ -148,6 +149,10 @@ export function blockToNode( let contentNode: Node; + if (!block.props) { + throw new Error("Block props are undefined"); + } + if (!block.content) { contentNode = schema.nodes[type].create(block.props); } else if (typeof block.content === "string") { @@ -378,22 +383,24 @@ export function nodeToBlock( id = UniqueID.options.generateID(); } - const props: any = {}; + const blockSpec = blockSchema[blockInfo.contentType.name]; + + if (!blockSpec) { + throw Error( + "Block is of an unrecognized type: " + blockInfo.contentType.name + ); + } + + const props: Props = Object.create(null); + for (const [attr, value] of Object.entries({ ...blockInfo.node.attrs, ...blockInfo.contentNode.attrs, })) { - const blockSpec = blockSchema[blockInfo.contentType.name]; - if (!blockSpec) { - throw Error( - "Block is of an unrecognized type: " + blockInfo.contentType.name - ); - } - - const propSchema = blockSpec.propSchema; - - if (attr in propSchema) { - props[attr] = value; + if (attr in blockSpec.propSchema.shape) { + if (props && typeof props === "object") { + props[attr as keyof typeof props] = value as never; + } } // Block ids are stored as node attributes the same way props are, so we // need to ensure we don't attempt to read block ids as props. diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index 77604299a..b27f2dfc7 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -29,20 +29,25 @@ export function propsToAttributes< ) { const tiptapAttributes: Record = {}; - Object.entries(blockConfig.propSchema).forEach(([name, spec]) => { + Object.entries(blockConfig.propSchema.shape).forEach(([name, spec]) => { tiptapAttributes[name] = { - default: spec.default, + default: null, // It will be provided by `insertBlocks` keepOnSplit: true, // Props are displayed in kebab-case as HTML attributes. If a prop's // value is the same as its default, we don't display an HTML // attribute for it. parseHTML: (element) => element.getAttribute(camelToDataKebab(name)), - renderHTML: (attributes) => - attributes[name] !== spec.default - ? { - [camelToDataKebab(name)]: attributes[name], - } - : {}, + renderHTML: (attributes) => { + const parsed = spec.safeParse(attributes[name]); + + if (!parsed.success || attributes[camelToDataKebab(name)] !== parsed.data) { + return { + [camelToDataKebab(name)]: typeof attributes[name] === 'string' ? attributes[name] : null, + } + } + + return {} + }, }; }); @@ -162,7 +167,7 @@ export function createBlockSpec< // Gets BlockNote editor instance const editor = this.options.editor! as BlockNoteEditor< - BSchema & { [k in BType]: BlockSpec } + { [k in BType]: BlockSpec } >; // Gets position of the node if (typeof getPos === "boolean") { diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index ee49d9921..88a65ee1f 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -1,4 +1,5 @@ /** Define the main block types **/ +import { z } from "zod"; import { Node, NodeConfig } from "@tiptap/core"; import { BlockNoteEditor } from "../../../BlockNoteEditor"; import { InlineContent, PartialInlineContent } from "./inlineContentTypes"; @@ -31,28 +32,17 @@ export type TipTapNode< group: "blockContent"; }; -// Defines a single prop spec, which includes the default value the prop should -// take and possible values it can take. -export type PropSpec = { - values?: readonly string[]; - default: string; -}; - // Defines multiple block prop specs. The key of each prop is the name of the // prop, while the value is a corresponding prop spec. This should be included // in a block config or schema. From a prop schema, we can derive both the props' // internal implementation (as TipTap node attributes) and the type information // for the external API. -export type PropSchema = Record; +export type PropSchema = z.SomeZodObject; // Defines Props objects for use in Block objects in the external API. Converts -// each prop spec into a union type of its possible values, or a string if no -// values are specified. -export type Props = { - [PType in keyof PSchema]: PSchema[PType]["values"] extends readonly string[] - ? PSchema[PType]["values"][number] - : string; -}; +// each prop spec into a union type of its possible values, or the type of the +// 'default' property if values are not specified. +export type Props = z.infer; // Defines the config for a single block. Meant to be used as an argument to // `createBlockSpec`, which will create a new block spec from it. This is the @@ -84,7 +74,7 @@ export type BlockConfig< * 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<{ [k in Type]: BlockSpec }> // (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 @@ -161,7 +151,7 @@ type PartialBlocksWithoutChildren = { [BType in keyof BSchema]: Partial<{ id: string; type: BType; - props: Partial>; + props: Props; content: PartialInlineContent[] | string; }>; }; diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index d60d716cc..7ab1790d5 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -1,44 +1,50 @@ +import { z } from "zod"; + import { HeadingBlockContent } from "../nodes/BlockContent/HeadingBlockContent/HeadingBlockContent"; 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"; + +export const defaultPropSchema = z.object({ + backgroundColor: z.enum(["transparent", "red", "orange", "yellow", "blue"]).optional(), + textColor: z.enum(["black", "red", "orange", "yellow"]).optional(), + textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), +}); export const defaultProps = { - backgroundColor: { - default: "transparent" as const, - }, - textColor: { - default: "black" as const, // TODO - }, - textAlignment: { - default: "left" as const, - values: ["left", "center", "right", "justify"] as const, - }, -} satisfies PropSchema; + backgroundColor: "transparent", + textColor: "black", + textAlignment: "left", +} satisfies z.infer; export type DefaultProps = typeof defaultProps; export const defaultBlockSchema = { paragraph: { - propSchema: defaultProps, + propSchema: defaultPropSchema, + props: defaultProps, node: ParagraphBlockContent, }, heading: { - propSchema: { + propSchema: defaultPropSchema.merge(z.object({ + level: z.enum(["1", "2", "3"]).optional(), + })), + props: { ...defaultProps, - level: { default: "1", values: ["1", "2", "3"] as const }, + level: "1", }, node: HeadingBlockContent, }, bulletListItem: { - propSchema: defaultProps, + propSchema: defaultPropSchema, + props: defaultProps, node: BulletListItemBlockContent, }, numberedListItem: { - propSchema: defaultProps, + propSchema: defaultPropSchema, + props: defaultProps, node: NumberedListItemBlockContent, }, } as const; -export type DefaultBlockSchema = TypesMatch; +export type DefaultBlockSchema = typeof defaultBlockSchema; \ No newline at end of file diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 3120ba825..d3fad13b7 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -200,7 +200,7 @@ export const BlockContainer = Node.create({ : state.schema.nodes[block.type], { ...contentNode.attrs, - ...block.props, + ...block.props!, } ); @@ -208,7 +208,7 @@ export const BlockContainer = Node.create({ // attributes. state.tr.setNodeMarkup(startPos - 1, undefined, { ...node.attrs, - ...block.props, + ...block.props!, }); } diff --git a/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx index 3fc435b27..4515fb4e8 100644 --- a/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx +++ b/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx @@ -29,11 +29,15 @@ export const BlockColorsButton = ( setOpened(true); }, []); + if (!(props.block.props && typeof props.block.props === 'object')) { + return <>; + } + if ( !("textColor" in props.block.props) || !("backgroundColor" in props.block.props) ) { - return null; + return <>; } return ( @@ -56,8 +60,8 @@ export const BlockColorsButton = ( style={{ marginLeft: "5px" }}> props.editor.updateBlock(props.block, { props: { textColor: color }, diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx index dde4fe634..5f4cb1cd8 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx @@ -1,8 +1,9 @@ import { BlockNoteEditor, BlockSchema, - DefaultProps, PartialBlock, + defaultPropSchema, + Props, } from "@blocknote/core"; import { useCallback, useMemo } from "react"; import { IconType } from "react-icons"; @@ -14,7 +15,7 @@ import { } from "react-icons/ri"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -type TextAlignment = DefaultProps["textAlignment"]["values"][number]; +type TextAlignment = Exclude["textAlignment"], undefined>; const icons: Record = { left: RiAlignLeft, @@ -32,14 +33,14 @@ export const TextAlignButton = (props: { if (selection) { for (const block of selection.blocks) { - if (!("textAlignment" in block.props)) { + if (block.props && typeof block.props === 'object' && !("textAlignment" in block.props)) { return false; } } } else { const block = props.editor.getTextCursorPosition().block; - if (!("textAlignment" in block.props)) { + if (block.props && typeof block.props === 'object' && !("textAlignment" in block.props)) { return false; } } @@ -78,7 +79,7 @@ export const TextAlignButton = (props: { setTextAlignment(props.textAlignment)} isSelected={ - props.editor.getTextCursorPosition().block.props.textAlignment === + (props.editor.getTextCursorPosition().block.props as any).textAlignment === props.textAlignment } mainTooltip={ diff --git a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx index b6ce8fe6f..bf0799896 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx @@ -2,6 +2,7 @@ import { BlockNoteEditor, BlockSchema, DefaultBlockSchema, + defaultBlockSchema, } from "@blocknote/core"; import { useEffect, useState } from "react"; import { IconType } from "react-icons"; @@ -25,7 +26,7 @@ const headingIcons: Record = { const shouldShow = (schema: BlockSchema) => { const paragraph = "paragraph" in schema; - const heading = "heading" in schema && "level" in schema.heading.propSchema; + const heading = "heading" in schema; const bulletListItem = "bulletListItem" in schema; const numberedListItem = "numberedListItem" in schema; @@ -52,18 +53,24 @@ export const BlockTypeDropdown = (props: { // the default block schema is being used let editor = props.editor as any as BlockNoteEditor; - const headingItems = editor.schema.heading.propSchema.level.values.map( + const parsedDefaultProps = defaultBlockSchema.heading.propSchema.safeParse(defaultBlockSchema.heading.props) + + if (!parsedDefaultProps.success) { + throw new Error("Default heading values are not valid"); + } + + const headingItems = (['1', '2', '3'] as const).map( (level) => ({ onClick: () => { editor.focus(); editor.updateBlock(block, { type: "heading", - props: { level: level }, + props: { ...parsedDefaultProps.data, level: level }, }); }, text: "Heading " + level, icon: headingIcons[level], - isSelected: block.type === "heading" && block.props.level === level, + isSelected: block.type === "heading" && (block.props as any).level === level, }) ); diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx index 85b3650d0..49772061a 100644 --- a/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx @@ -22,9 +22,9 @@ export const FormattingToolbar = (props: { - - - + + + diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 7b8aa7884..63fca6e70 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -88,8 +88,9 @@ export function createReactBlockSpec< // 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) { + if (attribute in blockConfig.propSchema.shape) { htmlAttributes[camelToDataKebab(attribute)] = value; } } diff --git a/tests/utils/customblocks/Alert.tsx b/tests/utils/customblocks/Alert.tsx index 8df5df14f..81baf48c3 100644 --- a/tests/utils/customblocks/Alert.tsx +++ b/tests/utils/customblocks/Alert.tsx @@ -1,4 +1,6 @@ -import { createBlockSpec, defaultProps } from "@blocknote/core"; +import { z } from "zod"; +import React from "react"; +import { createBlockSpec, defaultProps, defaultPropSchema } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiAlertFill } from "react-icons/ri"; @@ -23,13 +25,15 @@ const values = { export const Alert = createBlockSpec({ type: "alert" as const, - propSchema: { + propSchema: z.object({ + textAlignment: defaultPropSchema.shape.textAlignment, + textColor: defaultPropSchema.shape.textColor, + type: z.enum(["warning", "error", "info", "success"]), + }), + props: { textAlignment: defaultProps.textAlignment, textColor: defaultProps.textColor, - type: { - default: "warning", - values: ["warning", "error", "info", "success"], - }, + type: "warning", } as const, containsInlineContent: true, render: (block, editor) => { diff --git a/tests/utils/customblocks/Button.tsx b/tests/utils/customblocks/Button.tsx index 5e861e63a..a5afe611f 100644 --- a/tests/utils/customblocks/Button.tsx +++ b/tests/utils/customblocks/Button.tsx @@ -1,10 +1,15 @@ -import { createBlockSpec, defaultProps } from "@blocknote/core"; +import { z } from "zod"; +import React from "react"; +import { createBlockSpec, defaultProps, defaultPropSchema } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiRadioButtonFill } from "react-icons/ri"; export const Button = createBlockSpec({ type: "button" as const, - propSchema: { + propSchema: z.object({ + backgroundColor: defaultPropSchema.shape.backgroundColor, + }), + props: { backgroundColor: defaultProps.backgroundColor, } as const, containsInlineContent: false, @@ -15,7 +20,7 @@ export const Button = createBlockSpec({ editor.insertBlocks( [ { - type: "paragraph", + type: "button", content: "Hello World", }, ], diff --git a/tests/utils/customblocks/Embed.tsx b/tests/utils/customblocks/Embed.tsx index 9fbd82201..543787345 100644 --- a/tests/utils/customblocks/Embed.tsx +++ b/tests/utils/customblocks/Embed.tsx @@ -1,13 +1,16 @@ +import { z } from "zod"; +import React from "react"; import { createBlockSpec } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiLayout5Fill } from "react-icons/ri"; export const Embed = createBlockSpec({ type: "embed" as const, - propSchema: { - src: { - default: "https://www.youtube.com/embed/wjfuB8Xjhc4", - }, + propSchema: z.object({ + src: z.string().url(), + }), + props: { + src: "https://www.youtube.com/embed/wjfuB8Xjhc4" } as const, containsInlineContent: false, render: (block) => { diff --git a/tests/utils/customblocks/Image.tsx b/tests/utils/customblocks/Image.tsx index 9b03dc077..d7300d441 100644 --- a/tests/utils/customblocks/Image.tsx +++ b/tests/utils/customblocks/Image.tsx @@ -1,14 +1,17 @@ -import { createBlockSpec, defaultProps } from "@blocknote/core"; +import { z } from "zod"; +import React from "react"; +import { createBlockSpec, defaultPropSchema, defaultProps } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiImage2Fill } from "react-icons/ri"; export const Image = createBlockSpec({ type: "image" as const, - propSchema: { + propSchema: defaultPropSchema.merge(z.object({ + src: z.string().url(), + })), + props: { ...defaultProps, - src: { - default: "https://via.placeholder.com/1000", - }, + src: "https://via.placeholder.com/1000" } as const, containsInlineContent: true, render: (block) => { diff --git a/tests/utils/customblocks/ReactAlert.tsx b/tests/utils/customblocks/ReactAlert.tsx index e89b8586c..fdc9078a2 100644 --- a/tests/utils/customblocks/ReactAlert.tsx +++ b/tests/utils/customblocks/ReactAlert.tsx @@ -1,4 +1,6 @@ -import { defaultProps } from "@blocknote/core"; +import { z } from "zod"; +import React from "react"; +import { defaultProps, defaultPropSchema } from "@blocknote/core"; import { createReactBlockSpec, InlineContent, @@ -28,13 +30,13 @@ const values = { export const ReactAlert = createReactBlockSpec({ type: "reactAlert" as const, - propSchema: { + propSchema: defaultPropSchema.merge(z.object({ + type: z.enum(["warning", "error", "info", "success"]), + })), + props: { textAlignment: defaultProps.textAlignment, textColor: defaultProps.textColor, - type: { - default: "warning", - values: ["warning", "error", "info", "success"], - }, + type: "warning", } as const, containsInlineContent: true, render: (props) => { diff --git a/tests/utils/customblocks/ReactImage.tsx b/tests/utils/customblocks/ReactImage.tsx index f410fbd13..315a569b4 100644 --- a/tests/utils/customblocks/ReactImage.tsx +++ b/tests/utils/customblocks/ReactImage.tsx @@ -1,18 +1,21 @@ +import { z } from "zod"; +import React from "react"; import { InlineContent, createReactBlockSpec, ReactSlashMenuItem, } from "@blocknote/react"; -import { defaultProps } from "@blocknote/core"; +import { defaultProps, defaultPropSchema } from "@blocknote/core"; import { RiImage2Fill } from "react-icons/ri"; export const ReactImage = createReactBlockSpec({ type: "reactImage" as const, - propSchema: { + propSchema: defaultPropSchema.merge(z.object({ + src: z.string().url(), + })), + props: { ...defaultProps, - src: { - default: "https://via.placeholder.com/1000", - }, + src: "https://via.placeholder.com/1000" } as const, containsInlineContent: true, render: ({ block }) => { diff --git a/tests/utils/customblocks/Separator.tsx b/tests/utils/customblocks/Separator.tsx index 39e84067d..1b37afc8d 100644 --- a/tests/utils/customblocks/Separator.tsx +++ b/tests/utils/customblocks/Separator.tsx @@ -1,10 +1,13 @@ +import { z } from "zod"; +import React from "react"; import { createBlockSpec } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiSeparator } from "react-icons/ri"; export const Separator = createBlockSpec({ type: "separator" as const, - propSchema: {} as const, + propSchema: z.object({}), + props: {} as const, containsInlineContent: false, render: () => { const separator = document.createElement("div"); diff --git a/tests/utils/customblocks/TableOfContents.tsx b/tests/utils/customblocks/TableOfContents.tsx index e29844d95..f98a97c85 100644 --- a/tests/utils/customblocks/TableOfContents.tsx +++ b/tests/utils/customblocks/TableOfContents.tsx @@ -1,3 +1,5 @@ +import { z } from "zod"; +import React from "react"; import { Block, BlockSchema, @@ -43,7 +45,8 @@ function createHeadingElements(block: Block) { export const TableOfContents = createBlockSpec({ type: "toc" as const, - propSchema: {} as const, + propSchema: z.object({}), + props: {} as const, containsInlineContent: false, render: (_, editor) => { const toc = document.createElement("ol"); @@ -51,7 +54,7 @@ export const TableOfContents = createBlockSpec({ editor.onEditorContentChange(() => { toc.innerHTML = ""; for (const block of editor.topLevelBlocks) { - if (block.type === "heading") { + if ((block.type as any) === "heading") { toc.appendChild(createHeadingElements(block)); } } diff --git a/tests/utils/draghandle.ts b/tests/utils/draghandle.ts index a576070b8..b3c4714d9 100644 --- a/tests/utils/draghandle.ts +++ b/tests/utils/draghandle.ts @@ -23,5 +23,5 @@ export async function getDragHandleYCoord(page: Page, selector: string) { await moveMouseOverElement(page, element); await page.waitForSelector(DRAG_HANDLE_SELECTOR); const boundingBox = await page.locator(DRAG_HANDLE_SELECTOR).boundingBox(); - return boundingBox.y; + return boundingBox?.y; } diff --git a/tests/utils/mouse.ts b/tests/utils/mouse.ts index 308b4f39b..56d1ad939 100644 --- a/tests/utils/mouse.ts +++ b/tests/utils/mouse.ts @@ -3,29 +3,29 @@ import { DRAG_HANDLE_SELECTOR } from "./const"; async function getElementLeftCoords(page: Page, element: Locator) { const boundingBox = await element.boundingBox(); - const centerY = boundingBox.y + boundingBox.height / 2; + const centerY = boundingBox!.y + boundingBox!.height / 2; - return { x: boundingBox.x + 1, y: centerY }; + return { x: boundingBox!.x + 1, y: centerY }; } async function getElementRightCoords(page: Page, element: Locator) { const boundingBox = await element.boundingBox(); - const centerY = boundingBox.y + boundingBox.height / 2; + const centerY = boundingBox!.y + boundingBox!.height / 2; - return { x: boundingBox.x + boundingBox.width - 1, y: centerY }; + return { x: boundingBox!.x + boundingBox!.width - 1, y: centerY }; } async function getElementCenterCoords(page: Page, element: Locator) { const boundingBox = await element.boundingBox(); - const centerX = boundingBox.x + boundingBox.width / 2; - const centerY = boundingBox.y + boundingBox.height / 2; + const centerX = boundingBox!.x + boundingBox!.width / 2; + const centerY = boundingBox!.y + boundingBox!.height / 2; return { x: centerX, y: centerY }; } export async function moveMouseOverElement(page: Page, element: Locator) { const boundingBox = await element.boundingBox(); - const coords = { x: boundingBox.x, y: boundingBox.y }; + const coords = { x: boundingBox!.x, y: boundingBox!.y }; await page.mouse.move(coords.x, coords.y, { steps: 5 }); }