From b749862b59c1711f866ca24f07302141de010bce Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 29 May 2025 17:14:02 +0200 Subject: [PATCH 1/4] zod poc --- packages/core/package.json | 3 +- .../html/util/serializeBlocksExternalHTML.ts | 10 +- .../html/util/serializeBlocksInternalHTML.ts | 10 +- .../src/api/nodeConversions/nodeToBlock.ts | 21 ++- .../AudioBlockContent/AudioBlockContent.ts | 38 +++-- .../CodeBlockContent/CodeBlockContent.ts | 11 +- .../FileBlockContent/FileBlockContent.ts | 29 ++-- .../HeadingBlockContent.ts | 9 +- .../ImageBlockContent/ImageBlockContent.ts | 49 +++---- .../BulletListItemBlockContent.ts | 5 +- .../CheckListItemBlockContent.ts | 11 +- .../NumberedListItemBlockContent.ts | 9 +- .../PageBreakBlockContent.ts | 11 +- .../ParagraphBlockContent.ts | 4 +- .../QuoteBlockContent/QuoteBlockContent.ts | 10 +- .../TableBlockContent/TableBlockContent.ts | 6 +- .../VideoBlockContent/VideoBlockContent.ts | 53 +++---- .../core/src/blocks/defaultBlockTypeGuards.ts | 43 +++--- packages/core/src/blocks/defaultProps.ts | 22 +-- .../BackgroundColorExtension.ts | 6 +- .../TextColor/TextColorExtension.ts | 9 +- packages/core/src/schema/blocks/internal.ts | 73 ++++------ packages/core/src/schema/blocks/types.ts | 61 ++++---- packages/core/src/schema/index.ts | 1 - .../src/schema/inlineContent/createSpec.ts | 8 +- .../core/src/schema/inlineContent/internal.ts | 24 ++-- .../core/src/schema/inlineContent/types.ts | 10 +- packages/core/src/schema/propTypes.ts | 55 ------- packages/react/package.json | 3 +- .../react/src/components/Comments/schema.ts | 4 +- packages/react/src/schema/ReactBlockSpec.tsx | 16 ++- .../src/schema/ReactInlineContentSpec.tsx | 15 +- .../src/testUtil/cases/schemas/mention.ts | 12 +- pnpm-lock.yaml | 136 ++++++++++-------- shared/formatConversionTestUtil.ts | 22 +-- shared/package.json | 3 +- tests/package.json | 3 +- tests/src/unit/core/testSchema.ts | 11 +- .../export/exportTestExecutors.ts | 6 + .../formatConversionTestUtil.ts | 22 +-- 40 files changed, 400 insertions(+), 454 deletions(-) delete mode 100644 packages/core/src/schema/propTypes.ts diff --git a/packages/core/package.json b/packages/core/package.json index c409f97fc5..afea1daadb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -112,7 +112,8 @@ "uuid": "^8.3.2", "y-prosemirror": "^1.3.4", "y-protocols": "^1.0.6", - "yjs": "^13.6.15" + "yjs": "^13.6.15", + "zod": "^3.25.30" }, "devDependencies": { "@types/emoji-mart": "^3.0.14", diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index f74757c8d7..a279f6ffb8 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -1,5 +1,6 @@ import { DOMSerializer, Fragment } from "prosemirror-model"; +import * as z from "zod/v4/core"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { @@ -90,10 +91,13 @@ function serializeBlock< if (!block.props) { props = {}; for (const [name, spec] of Object.entries( - editor.schema.blockSchema[block.type as any].propSchema, + (editor.schema.blockSchema[block.type as any].propSchema as z.$ZodObject) // TODO + ._zod.def.shape, )) { - if (spec.default !== undefined) { - (props as any)[name] = spec.default; + const def = + spec instanceof z.$ZodDefault ? spec._zod.def.defaultValue : undefined; + if (def !== undefined) { + (props as any)[name] = def; } } } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0bd7722172..b94661460e 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -1,5 +1,6 @@ import { DOMSerializer, Fragment } from "prosemirror-model"; +import * as z from "zod/v4/core"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { @@ -65,10 +66,13 @@ function serializeBlock< if (!block.props) { props = {}; for (const [name, spec] of Object.entries( - editor.schema.blockSchema[block.type as any].propSchema, + (editor.schema.blockSchema[block.type as any].propSchema as z.$ZodObject) // TODO + ._zod.def.shape, )) { - if (spec.default !== undefined) { - (props as any)[name] = spec.default; + const def = + spec instanceof z.$ZodDefault ? spec._zod.def.defaultValue : undefined; + if (def !== undefined) { + (props as any)[name] = def; } } } diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 1f5d2c75d4..f60d9fedee 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,4 +1,5 @@ import { Mark, Node, Schema, Slice } from "@tiptap/pm/model"; +import * as z from "zod/v4/core"; import type { Block } from "../../blocks/defaultBlocks.js"; import UniqueID from "../../extensions/UniqueID/UniqueID.js"; import type { @@ -429,12 +430,22 @@ export function nodeToBlock< ...node.attrs, ...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}), })) { - const propSchema = blockSpec.propSchema; + const propSchema = + blockSpec.propSchema._zod.def.shape[ + attr as keyof typeof blockSpec.propSchema._zod.def.shape + ]; - if ( - attr in propSchema && - !(propSchema[attr].default === undefined && value === undefined) - ) { + if (!propSchema) { + continue; + } + + const def = + propSchema instanceof z.$ZodDefault + ? propSchema._zod.def.defaultValue + : undefined; + + // TODO: is this if statement correct? + if (!(def === undefined && value === undefined)) { props[attr] = value; } } diff --git a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts index 7a3e0101fe..618b96b50f 100644 --- a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts +++ b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts @@ -1,10 +1,9 @@ +import z from "zod/v4"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockFromConfig, createBlockSpec, FileBlockConfig, - Props, - PropSchema, } from "../../schema/index.js"; import { defaultProps } from "../defaultProps.js"; @@ -17,25 +16,20 @@ import { parseAudioElement } from "./parseAudioElement.js"; export const FILE_AUDIO_ICON_SVG = ''; -export const audioPropSchema = { - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, - - showPreview: { - default: true, - }, -} satisfies PropSchema; +export const audioPropSchema = defaultProps + .pick({ + backgroundColor: true, + }) + .extend({ + // File name. + name: z.string().default(""), + // File url. + url: z.string().default(""), + // File caption. + caption: z.string().default(""), + + showPreview: z.boolean().default(true), + }); export const audioBlockConfig = { type: "audio" as const, @@ -76,7 +70,7 @@ export const audioRender = ( export const audioParse = ( element: HTMLElement, -): Partial> | undefined => { +): Partial> | undefined => { if (element.tagName === "AUDIO") { // Ignore if parent figure has already been parsed. if (element.closest("figure")) { diff --git a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts index e322a83be9..90f0e70aea 100644 --- a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts +++ b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts @@ -3,14 +3,13 @@ import { InputRule, isTextSelection } from "@tiptap/core"; import { TextSelection } from "@tiptap/pm/state"; import { Parser, createHighlightPlugin } from "prosemirror-highlight"; import { createParser } from "prosemirror-highlight/shiki"; +import { z } from "zod/v4"; import { BlockNoteEditor } from "../../index.js"; import { - PropSchema, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, } from "../../schema/index.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; - export type CodeBlockOptions = { /** * Whether to indent lines with a tab when the user presses `Tab` in a code block. @@ -66,11 +65,9 @@ export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); export const shikiHighlighterPromiseSymbol = Symbol.for( "blocknote.shikiHighlighterPromise", ); -export const defaultCodeBlockPropSchema = { - language: { - default: "text", - }, -} satisfies PropSchema; +export const defaultCodeBlockPropSchema = z.object({ + language: z.string().default("text"), +}); const CodeBlockContent = createStronglyTypedTiptapNode({ name: "codeBlock", diff --git a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts index 433487d8e0..c150f0da0d 100644 --- a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts +++ b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts @@ -1,8 +1,8 @@ +import z from "zod/v4"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockFromConfig, FileBlockConfig, - PropSchema, createBlockSpec, } from "../../schema/index.js"; import { defaultProps } from "../defaultProps.js"; @@ -11,21 +11,18 @@ import { parseFigureElement } from "./helpers/parse/parseFigureElement.js"; import { createFileBlockWrapper } from "./helpers/render/createFileBlockWrapper.js"; import { createLinkWithCaption } from "./helpers/toExternalHTML/createLinkWithCaption.js"; -export const filePropSchema = { - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, -} satisfies PropSchema; +export const filePropSchema = defaultProps + .pick({ + backgroundColor: true, + }) + .extend({ + // File name. + name: z.string().default(""), + // File url. + url: z.string().default(""), + // File caption. + caption: z.string().default(""), + }); export const fileBlockConfig = { type: "file" as const, diff --git a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts index 8299892a03..8014e54b8c 100644 --- a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts @@ -1,8 +1,8 @@ import { InputRule } from "@tiptap/core"; +import * as z from "zod/v4"; import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { - PropSchema, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, propsToAttributes, @@ -10,10 +10,9 @@ import { import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; -export const headingPropSchema = { - ...defaultProps, - level: { default: 1, values: [1, 2, 3] as const }, -} satisfies PropSchema; +export const headingPropSchema = defaultProps.extend({ + level: z.number().int().min(1).max(3).default(1), +}); const HeadingBlockContent = createStronglyTypedTiptapNode({ name: "heading", diff --git a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts index 32b7338640..9a4ec0e318 100644 --- a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts @@ -1,46 +1,37 @@ +import z from "zod/v4"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockFromConfig, createBlockSpec, FileBlockConfig, - Props, - PropSchema, } from "../../schema/index.js"; import { defaultProps } from "../defaultProps.js"; import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js"; +import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; import { parseImageElement } from "./parseImageElement.js"; export const FILE_IMAGE_ICON_SVG = ''; -export const imagePropSchema = { - textAlignment: defaultProps.textAlignment, - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, - - showPreview: { - default: true, - }, - // File preview width in px. - previewWidth: { - default: undefined, - type: "number", - }, -} satisfies PropSchema; +export const imagePropSchema = defaultProps + .pick({ + textAlignment: true, + backgroundColor: true, + }) + .extend({ + // File name. + name: z.string().default(""), + // File url. + url: z.string().default(""), + // File caption. + caption: z.string().default(""), + // Show preview. + showPreview: z.boolean().default(true), + // File preview width in px. + previewWidth: z.number().optional(), + }); export const imageBlockConfig = { type: "image" as const, @@ -87,7 +78,7 @@ export const imageRender = ( export const imageParse = ( element: HTMLElement, -): Partial> | undefined => { +): Partial> | undefined => { if (element.tagName === "IMG") { // Ignore if parent figure has already been parsed. if (element.closest("figure")) { diff --git a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index e6412633c4..3b23092949 100644 --- a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -2,7 +2,6 @@ import { InputRule } from "@tiptap/core"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; import { - PropSchema, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, } from "../../../schema/index.js"; @@ -11,9 +10,7 @@ import { defaultProps } from "../../defaultProps.js"; import { getListItemContent } from "../getListItemContent.js"; import { handleEnter } from "../ListItemKeyboardShortcuts.js"; -export const bulletListItemPropSchema = { - ...defaultProps, -} satisfies PropSchema; +export const bulletListItemPropSchema = defaultProps; const BulletListItemBlockContent = createStronglyTypedTiptapNode({ name: "bulletListItem", diff --git a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts index 8ebf62aa63..9f2650cc56 100644 --- a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts @@ -1,11 +1,11 @@ import { InputRule } from "@tiptap/core"; +import * as z from "zod/v4"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection, getNearestBlockPos, } from "../../../api/getBlockInfoFromPos.js"; import { - PropSchema, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, propsToAttributes, @@ -15,12 +15,9 @@ import { defaultProps } from "../../defaultProps.js"; import { getListItemContent } from "../getListItemContent.js"; import { handleEnter } from "../ListItemKeyboardShortcuts.js"; -export const checkListItemPropSchema = { - ...defaultProps, - checked: { - default: false, - }, -} satisfies PropSchema; +export const checkListItemPropSchema = defaultProps.extend({ + checked: z.boolean().default(false), +}); const checkListItemBlockContent = createStronglyTypedTiptapNode({ name: "checkListItem", diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index 4e271bae14..598791d04b 100644 --- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -1,8 +1,8 @@ import { InputRule } from "@tiptap/core"; +import { z } from "zod/v4"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; import { - PropSchema, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, propsToAttributes, @@ -13,10 +13,9 @@ import { getListItemContent } from "../getListItemContent.js"; import { handleEnter } from "../ListItemKeyboardShortcuts.js"; import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin.js"; -export const numberedListItemPropSchema = { - ...defaultProps, - start: { default: undefined, type: "number" }, -} satisfies PropSchema; +export const numberedListItemPropSchema = defaultProps.extend({ + start: z.number().optional(), +}); const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ name: "numberedListItem", diff --git a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts index c8343a30f3..178bfab5a2 100644 --- a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts +++ b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts @@ -1,12 +1,9 @@ -import { - createBlockSpec, - CustomBlockConfig, - Props, -} from "../../schema/index.js"; +import z from "zod/v4"; +import { createBlockSpec, CustomBlockConfig } from "../../schema/index.js"; export const pageBreakConfig = { type: "pageBreak" as const, - propSchema: {}, + propSchema: z.object(), content: "none", isFileBlock: false, isSelectable: false, @@ -23,7 +20,7 @@ export const pageBreakRender = () => { }; export const pageBreakParse = ( element: HTMLElement, -): Partial> | undefined => { +): Partial> | undefined => { if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) { return { type: "pageBreak", diff --git a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts index 0c35c117a7..ae3c6b401c 100644 --- a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts +++ b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts @@ -7,9 +7,7 @@ import { import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; -export const paragraphPropSchema = { - ...defaultProps, -}; +export const paragraphPropSchema = defaultProps; export const ParagraphBlockContent = createStronglyTypedTiptapNode({ name: "paragraph", diff --git a/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts b/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts index 3c13c56c2d..5e4dc501bd 100644 --- a/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts +++ b/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts @@ -1,16 +1,14 @@ +import { InputRule } from "@tiptap/core"; +import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, } from "../../schema/index.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { InputRule } from "@tiptap/core"; -export const quotePropSchema = { - ...defaultProps, -}; +export const quotePropSchema = defaultProps; export const QuoteBlockContent = createStronglyTypedTiptapNode({ name: "quote", diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts index 6d26b8ec54..06544f3964 100644 --- a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts @@ -13,9 +13,9 @@ import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; import { EMPTY_CELL_WIDTH, TableExtension } from "./TableExtension.js"; -export const tablePropSchema = { - textColor: defaultProps.textColor, -}; +export const tablePropSchema = defaultProps.pick({ + textColor: true, +}); export const TableBlockContent = createStronglyTypedTiptapNode({ name: "table", diff --git a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts index af65e3d0df..45245fafaa 100644 --- a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts +++ b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts @@ -1,46 +1,37 @@ +import z from "zod/v4"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockFromConfig, createBlockSpec, FileBlockConfig, - Props, - PropSchema, } from "../../schema/index.js"; import { defaultProps } from "../defaultProps.js"; import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js"; +import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; import { parseVideoElement } from "./parseVideoElement.js"; export const FILE_VIDEO_ICON_SVG = ''; -export const videoPropSchema = { - textAlignment: defaultProps.textAlignment, - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, - - showPreview: { - default: true, - }, - // File preview width in px. - previewWidth: { - default: undefined, - type: "number", - }, -} satisfies PropSchema; +export const videoPropSchema = defaultProps + .pick({ + textAlignment: true, + backgroundColor: true, + }) + .extend({ + // File name. + name: z.string().default(""), + // File url. + url: z.string().default(""), + // File caption. + caption: z.string().default(""), + // Show preview. + showPreview: z.boolean().default(true), + // File preview width in px. + previewWidth: z.number().optional(), + }); export const videoBlockConfig = { type: "video" as const, @@ -72,7 +63,9 @@ export const videoRender = ( video.controls = true; video.contentEditable = "false"; video.draggable = false; - video.width = block.props.previewWidth; + if (block.props.previewWidth) { + video.width = block.props.previewWidth; + } videoWrapper.appendChild(video); return createResizableFileBlockWrapper( @@ -87,7 +80,7 @@ export const videoRender = ( export const videoParse = ( element: HTMLElement, -): Partial> | undefined => { +): Partial> | undefined => { if (element.tagName === "VIDEO") { // Ignore if parent figure has already been parsed. if (element.closest("figure")) { diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 5db988da9a..a144fd8dcb 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -1,3 +1,4 @@ +import { Selection } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { @@ -15,7 +16,6 @@ import { defaultInlineContentSchema, } from "./defaultBlocks.js"; import { defaultProps } from "./defaultProps.js"; -import { Selection } from "prosemirror-state"; export function checkDefaultBlockTypeInSchema< BlockType extends keyof DefaultBlockSchema, @@ -89,9 +89,10 @@ export function checkBlockIsFileBlockWithPreview< block: Block, editor: BlockNoteEditor, ): block is BlockFromConfig< - FileBlockConfig & { - propSchema: Required; - }, + // FileBlockConfig & { + // propSchema: Required; + // }, + any, // TODO I, S > { @@ -121,15 +122,16 @@ export function checkBlockTypeHasDefaultProp< blockType: string, editor: BlockNoteEditor, ): editor is BlockNoteEditor< - { - [BT in string]: { - type: BT; - propSchema: { - [P in Prop]: (typeof defaultProps)[P]; - }; - content: "table" | "inline" | "none"; - }; - }, + // { + // [BT in string]: { + // type: BT; + // propSchema: { + // [P in Prop]: (typeof defaultProps)[P]; + // }; + // content: "table" | "inline" | "none"; + // }; + // }, + any, // TODO I, S > { @@ -149,13 +151,14 @@ export function checkBlockHasDefaultProp< block: Block, editor: BlockNoteEditor, ): block is BlockFromConfig< - { - type: string; - propSchema: { - [P in Prop]: (typeof defaultProps)[P]; - }; - content: "table" | "inline" | "none"; - }, + // { + // type: string; + // propSchema: { + // [P in Prop]: (typeof defaultProps)[P]; + // }; + // content: "table" | "inline" | "none"; + // }, + any, // TODO I, S > { diff --git a/packages/core/src/blocks/defaultProps.ts b/packages/core/src/blocks/defaultProps.ts index 4fb0b838c8..5b807c4597 100644 --- a/packages/core/src/blocks/defaultProps.ts +++ b/packages/core/src/blocks/defaultProps.ts @@ -1,22 +1,14 @@ -import type { Props, PropSchema } from "../schema/index.js"; - +import * as z from "zod/v4"; // TODO: this system should probably be moved / refactored. // The dependency from schema on this file doesn't make sense -export const defaultProps = { - backgroundColor: { - default: "default" as const, - }, - textColor: { - default: "default" as const, - }, - textAlignment: { - default: "left" as const, - values: ["left", "center", "right", "justify"] as const, - }, -} satisfies PropSchema; +export const defaultProps = z.object({ + backgroundColor: z.string().default("default"), + textColor: z.string().default("default"), + textAlignment: z.enum(["left", "center", "right", "justify"]).default("left"), +}); -export type DefaultProps = Props; +export type DefaultProps = z.infer; // Default props which are set on `blockContainer` nodes rather than // `blockContent` nodes. Ensures that they are not redundantly added to diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts index fca4922a1a..95e5137553 100644 --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts @@ -10,15 +10,15 @@ export const BackgroundColorExtension = Extension.create({ types: ["blockContainer", "tableCell", "tableHeader"], attributes: { backgroundColor: { - default: defaultProps.backgroundColor.default, + default: defaultProps.shape.backgroundColor._zod.def.defaultValue, parseHTML: (element) => element.hasAttribute("data-background-color") ? element.getAttribute("data-background-color") - : defaultProps.backgroundColor.default, + : defaultProps.shape.backgroundColor._zod.def.defaultValue, renderHTML: (attributes) => { if ( attributes.backgroundColor === - defaultProps.backgroundColor.default + defaultProps.shape.backgroundColor._zod.def.defaultValue ) { return {}; } diff --git a/packages/core/src/extensions/TextColor/TextColorExtension.ts b/packages/core/src/extensions/TextColor/TextColorExtension.ts index 4060fea6d6..d3a1934e89 100644 --- a/packages/core/src/extensions/TextColor/TextColorExtension.ts +++ b/packages/core/src/extensions/TextColor/TextColorExtension.ts @@ -10,13 +10,16 @@ export const TextColorExtension = Extension.create({ types: ["blockContainer", "tableCell", "tableHeader"], attributes: { textColor: { - default: defaultProps.textColor.default, + default: defaultProps.shape.textColor._zod.def.defaultValue, parseHTML: (element) => element.hasAttribute("data-text-color") ? element.getAttribute("data-text-color") - : defaultProps.textColor.default, + : defaultProps.shape.textColor._zod.def.defaultValue, renderHTML: (attributes) => { - if (attributes.textColor === defaultProps.textColor.default) { + if ( + attributes.textColor === + defaultProps.shape.textColor._zod.def.defaultValue + ) { return {}; } return { diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index d3749ab53a..b807b65dd0 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,13 +6,13 @@ import { Node, NodeConfig, } from "@tiptap/core"; +import * as z from "zod/v4/core"; import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; import { inheritedProps } from "../../blocks/defaultProps.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { InlineContentSchema } from "../inlineContent/types.js"; -import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { BlockConfig, @@ -26,15 +26,18 @@ import { // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. -// TODO: extract function -export function propsToAttributes(propSchema: PropSchema): Attributes { +// TODO: extract function0 +export function propsToAttributes(propSchema: z.$ZodObject): Attributes { const tiptapAttributes: Record = {}; - Object.entries(propSchema) + Object.entries(propSchema._zod.def.shape) .filter(([name, _spec]) => !inheritedProps.includes(name)) .forEach(([name, spec]) => { + const def = + spec instanceof z.$ZodDefault ? spec._zod.def.defaultValue : undefined; + tiptapAttributes[name] = { - default: spec.default, + default: def, 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 @@ -46,41 +49,19 @@ export function propsToAttributes(propSchema: PropSchema): Attributes { return null; } - if ( - (spec.default === undefined && spec.type === "boolean") || - (spec.default !== undefined && typeof spec.default === "boolean") - ) { - if (value === "true") { - return true; - } - - if (value === "false") { - return false; - } - - return null; + // TBD: this might not be fault proof, but it's also ugly to store prop=""..."" for strings + try { + const jsonValue = JSON.parse(value); + // it was a number / boolean / json object stored as attribute + return z.parse(spec, jsonValue); + } catch (e) { + // it might have been a string directly stored as attribute + return z.parse(spec, value); } - - if ( - (spec.default === undefined && spec.type === "number") || - (spec.default !== undefined && typeof spec.default === "number") - ) { - const asNumber = parseFloat(value); - const isNumeric = - !Number.isNaN(asNumber) && Number.isFinite(asNumber); - - if (isNumeric) { - return asNumber; - } - - return null; - } - - return value; }, renderHTML: (attributes) => { // don't render to html if the value is the same as the default - return attributes[name] !== spec.default + return attributes[name] !== def ? { [camelToDataKebab(name)]: attributes[name], } @@ -142,7 +123,7 @@ export function getBlockFromPos< // an `inlineContent` class to it. export function wrapInBlockStructure< BType extends string, - PSchema extends PropSchema, + PSchema extends z.$ZodObject, >( element: { dom: HTMLElement; @@ -150,7 +131,7 @@ export function wrapInBlockStructure< destroy?: () => void; }, blockType: BType, - blockProps: Props, + blockProps: z.output, propSchema: PSchema, isFileBlock = false, domAttributes?: Record, @@ -181,10 +162,18 @@ export function wrapInBlockStructure< // which are already added as HTML attributes to the parent `blockContent` // element (inheritedProps) and props set to their default values. for (const [prop, value] of Object.entries(blockProps)) { - const spec = propSchema[prop]; - const defaultValue = spec.default; + const spec = propSchema._zod.def.shape[prop]; + const defaultValue = + spec instanceof z.$ZodDefault ? spec._zod.def.defaultValue : undefined; if (!inheritedProps.includes(prop) && value !== defaultValue) { - blockContent.setAttribute(camelToDataKebab(prop), value); + if (typeof value === "string") { + blockContent.setAttribute(camelToDataKebab(prop), value); + } else { + blockContent.setAttribute( + camelToDataKebab(prop), + JSON.stringify(value), + ); + } } } // Adds file block attribute @@ -249,7 +238,7 @@ export function createInternalBlockSpec( export function createBlockSpecFromStronglyTypedTiptapNode< T extends Node, - P extends PropSchema, + P extends z.$ZodObject, >(node: T, propSchema: P, requiredExtensions?: Array) { return createInternalBlockSpec( { diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 0f97205638..594ff4ba37 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,13 +1,14 @@ /** Define the main block types **/ import type { Extension, Node } from "@tiptap/core"; - +import * as z from "zod/v4"; +import * as zCore from "zod/v4/core"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { InlineContent, InlineContentSchema, PartialInlineContent, } from "../inlineContent/types.js"; -import type { PropSchema, Props } from "../propTypes.js"; + import type { StyleSchema } from "../styles/types.js"; export type BlockNoteDOMElement = @@ -21,34 +22,32 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; +const filePropSchema = z.object({ + caption: z.string().default(""), + name: z.string().default(""), + // URL is optional, as we also want to accept files with no URL, but for example ids + // (ids can be used for files that are resolved on the backend) + url: z.string().default(""), + // Whether to show the file preview or the name only. + // This is useful for some file blocks, but not all + // (e.g.: not relevant for default "file" block which doesn;'t show previews) + showPreview: z.boolean().default(true), + // File preview width in px. + previewWidth: z.number().optional(), +}); + +// TODO: comment +type shape = Pick & + Partial< + Pick< + typeof filePropSchema._zod.def.shape, + "url" | "showPreview" | "previewWidth" + > + >; + export type FileBlockConfig = { type: string; - readonly propSchema: PropSchema & { - caption: { - default: ""; - }; - name: { - default: ""; - }; - - // URL is optional, as we also want to accept files with no URL, but for example ids - // (ids can be used for files that are resolved on the backend) - url?: { - default: ""; - }; - - // Whether to show the file preview or the name only. - // This is useful for some file blocks, but not all - // (e.g.: not relevant for default "file" block which doesn;'t show previews) - showPreview?: { - default: boolean; - }; - // File preview width in px. - previewWidth?: { - default: undefined; - type: "number"; - }; - }; + readonly propSchema: zCore.$ZodObject; content: "none"; isSelectable?: boolean; isFileBlock: true; @@ -60,7 +59,7 @@ export type FileBlockConfig = { export type BlockConfig = | { type: string; - readonly propSchema: PropSchema; + readonly propSchema: zCore.$ZodObject; content: "inline" | "none" | "table"; isSelectable?: boolean; isFileBlock?: false; @@ -186,7 +185,7 @@ export type BlockFromConfigNoChildren< > = { id: string; type: B["type"]; - props: Props; + props: z.output; content: B["content"] extends "inline" ? InlineContent[] : B["content"] extends "table" @@ -270,7 +269,7 @@ type PartialBlockFromConfigNoChildren< > = { id?: string; type?: B["type"]; - props?: Partial>; + props?: Partial>; content?: B["content"] extends "inline" ? PartialInlineContent : B["content"] extends "table" diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 7f7cafc561..cf987a86e0 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -4,7 +4,6 @@ export * from "./blocks/types.js"; export * from "./inlineContent/createSpec.js"; export * from "./inlineContent/internal.js"; export * from "./inlineContent/types.js"; -export * from "./propTypes.js"; export * from "./styles/createSpec.js"; export * from "./styles/internal.js"; export * from "./styles/types.js"; diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index 9168c1207b..4bd7d248f2 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -5,7 +5,8 @@ import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { propsToAttributes } from "../blocks/internal.js"; -import { Props } from "../propTypes.js"; + +import * as z from "zod/v4/core"; import { StyleSchema } from "../styles/types.js"; import { addInlineContentAttributes, @@ -18,7 +19,6 @@ import { InlineContentSpec, PartialCustomInlineContentFromConfig, } from "./types.js"; - // TODO: support serialization export type CustomInlineContentImplementation< @@ -116,7 +116,7 @@ export function createInlineContentSpec< return addInlineContentAttributes( output, inlineContentConfig.type, - node.attrs as Props, + node.attrs as z.infer, inlineContentConfig.propSchema, ); }, @@ -148,7 +148,7 @@ export function createInlineContentSpec< return addInlineContentAttributes( output, inlineContentConfig.type, - node.attrs as Props, + node.attrs as z.infer, inlineContentConfig.propSchema, ); }; diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index 3e438d7cfb..1039c6f7f0 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -1,7 +1,8 @@ import { KeyboardShortcutCommand, Node } from "@tiptap/core"; import { camelToDataKebab } from "../../util/string.js"; -import { PropSchema, Props } from "../propTypes.js"; + +import * as z from "zod/v4/core"; import { CustomInlineContentConfig, InlineContentConfig, @@ -10,21 +11,20 @@ import { InlineContentSpec, InlineContentSpecs, } from "./types.js"; - // Function that adds necessary classes and attributes to the `dom` element // returned from a custom inline content's 'render' function, to ensure no data // is lost on internal copy & paste. export function addInlineContentAttributes< IType extends string, - PSchema extends PropSchema, + PSchema extends z.$ZodObject, >( element: { dom: HTMLElement; contentDOM?: HTMLElement; }, inlineContentType: IType, - inlineContentProps: Props, - propSchema: PSchema, + inlineContentProps: z.infer, + propSchema: z.$ZodObject, ): { dom: HTMLElement; contentDOM?: HTMLElement; @@ -35,13 +35,17 @@ export function addInlineContentAttributes< // set to their default values. Object.entries(inlineContentProps) .filter(([prop, value]) => { - const spec = propSchema[prop]; - return value !== spec.default; + const spec = propSchema._zod.def.shape[prop]; + const defaultValue = + spec instanceof z.$ZodDefault ? spec._zod.def.defaultValue : undefined; + return value !== defaultValue; }) .map(([prop, value]) => { - return [camelToDataKebab(prop), value]; + return [camelToDataKebab(prop), value] satisfies [string, unknown]; }) - .forEach(([prop, value]) => element.dom.setAttribute(prop, value)); + .forEach(([prop, value]) => + element.dom.setAttribute(prop, JSON.stringify(value)), + ); if (element.contentDOM !== undefined) { element.contentDOM.setAttribute("data-editable", ""); @@ -85,7 +89,7 @@ export function createInternalInlineContentSpec( export function createInlineContentSpecFromTipTapNode< T extends Node, - P extends PropSchema, + P extends z.$ZodObject, >(node: T, propSchema: P) { return createInternalInlineContentSpec( { diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index 6ec87055d4..a975923899 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -1,11 +1,11 @@ import { Node } from "@tiptap/core"; -import { PropSchema, Props } from "../propTypes.js"; -import { StyleSchema, Styles } from "../styles/types.js"; +import * as z from "zod/v4/core"; +import { StyleSchema, Styles } from "../styles/types.js"; export type CustomInlineContentConfig = { type: string; content: "styled" | "none"; // | "plain" - readonly propSchema: PropSchema; + readonly propSchema: z.$ZodObject; // content: "inline" | "none" | "table"; }; // InlineContentConfig contains the "schema" info about an InlineContent type @@ -47,7 +47,7 @@ export type CustomInlineContentFromConfig< S extends StyleSchema, > = { type: I["type"]; - props: Props; + props: z.infer; content: I["content"] extends "styled" ? StyledText[] : I["content"] extends "plain" @@ -73,7 +73,7 @@ export type PartialCustomInlineContentFromConfig< S extends StyleSchema, > = { type: I["type"]; - props?: Props; + props?: Partial>; content?: I["content"] extends "styled" ? StyledText[] | string : I["content"] extends "plain" diff --git a/packages/core/src/schema/propTypes.ts b/packages/core/src/schema/propTypes.ts deleted file mode 100644 index 76a8df2769..0000000000 --- a/packages/core/src/schema/propTypes.ts +++ /dev/null @@ -1,55 +0,0 @@ -// The PropSpec specifies the type of a prop and possibly a default value. -// Note that props are always optional when used as "input" -// (i.e., when creating a PartialBlock, for example by calling `insertBlocks({...})`) -// -// However, internally they're always set to `default`, unless a prop is marked optional -// -// At some point we should migrate this to zod or effect-schema -export type PropSpec = - | { - // We infer the type of the prop from the default value - default: PType; - // a list of possible values, for example for a string prop (this will then be used as a string union type) - values?: readonly PType[]; - } - | { - default: undefined; - // Because there is no default value (for an optional prop, the default value is undefined), - // we need to specify the type of the prop manually (we can't infer it from the default value) - type: "string" | "number" | "boolean"; - values?: readonly PType[]; - }; - -// 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>; - -// 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 = { - // for required props, get type from type of "default" value, - // and if values are specified, get type from values - [PName in keyof PSchema]: ( - PSchema[PName] extends { default: boolean } | { type: "boolean" } - ? PSchema[PName]["values"] extends readonly boolean[] - ? PSchema[PName]["values"][number] - : boolean - : PSchema[PName] extends { default: number } | { type: "number" } - ? PSchema[PName]["values"] extends readonly number[] - ? PSchema[PName]["values"][number] - : number - : PSchema[PName] extends { default: string } | { type: "string" } - ? PSchema[PName]["values"] extends readonly string[] - ? PSchema[PName]["values"][number] - : string - : never - ) extends infer T - ? PSchema[PName] extends { optional: true } - ? T | undefined - : T - : never; -}; diff --git a/packages/react/package.json b/packages/react/package.json index 68c50eec8f..e9dcad935f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -66,7 +66,8 @@ "@tiptap/react": "^2.12.0", "emoji-mart": "^5.6.0", "lodash.merge": "^4.6.2", - "react-icons": "^5.2.1" + "react-icons": "^5.2.1", + "zod": "^3.25.36" }, "devDependencies": { "@types/emoji-mart": "^3.0.14", diff --git a/packages/react/src/components/Comments/schema.ts b/packages/react/src/components/Comments/schema.ts index 576aa2c3ef..6cc244b3e2 100644 --- a/packages/react/src/components/Comments/schema.ts +++ b/packages/react/src/components/Comments/schema.ts @@ -5,7 +5,7 @@ import { defaultBlockSpecs, defaultStyleSpecs, } from "@blocknote/core"; - +import * as z from "zod/v4"; // this is quite convoluted. we'll clean this up when we make // it easier to extend / customize the default blocks const paragraph = createBlockSpecFromStronglyTypedTiptapNode( @@ -13,7 +13,7 @@ const paragraph = createBlockSpecFromStronglyTypedTiptapNode( defaultBlockSpecs.paragraph.implementation.node.config as any, ), // disable default props on paragraph (such as textalignment and colors) - {}, + z.object({}), ); // remove textColor, backgroundColor from styleSpecs diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 9b15550d13..d2016dfaf3 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -13,8 +13,6 @@ import { InlineContentSchema, mergeCSSClasses, PartialBlockFromConfig, - Props, - PropSchema, propsToAttributes, StyleSchema, wrapInBlockStructure, @@ -27,8 +25,8 @@ import { useReactNodeView, } from "@tiptap/react"; import { FC, ReactNode } from "react"; +import * as z from "zod/v4/core"; import { renderToDOMSpec } from "./@util/ReactRenderUtil.js"; - // this file is mostly analogoues to `customBlocks.ts`, but for React blocks export type ReactCustomBlockRenderProps< @@ -59,10 +57,10 @@ export type ReactCustomBlockImplementation< // block type and props as HTML attributes. export function BlockContentWrapper< BType extends string, - PSchema extends PropSchema, + PSchema extends z.$ZodObject, >(props: { blockType: BType; - blockProps: Props; + blockProps: z.infer; propSchema: PSchema; isFileBlock?: boolean; domAttributes?: Record; @@ -92,8 +90,12 @@ export function BlockContentWrapper< {...Object.fromEntries( Object.entries(props.blockProps) .filter(([prop, value]) => { - const spec = props.propSchema[prop]; - return !inheritedProps.includes(prop) && value !== spec.default; + const spec = props.propSchema._zod.def.shape[prop]; + const def = + spec instanceof z.$ZodDefault + ? spec._zod.def.defaultValue + : undefined; + return !inheritedProps.includes(prop) && value !== def; }) .map(([prop, value]) => { return [camelToDataKebab(prop), value]; diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 63d81c0142..db7e38b879 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -1,6 +1,7 @@ import { addInlineContentAttributes, addInlineContentKeyboardShortcuts, + BlockNoteEditor, camelToDataKebab, createInternalInlineContentSpec, createStronglyTypedTiptapNode, @@ -10,11 +11,8 @@ import { inlineContentToNodes, nodeToCustomInlineContent, PartialCustomInlineContentFromConfig, - Props, - PropSchema, propsToAttributes, StyleSchema, - BlockNoteEditor, } from "@blocknote/core"; import { NodeViewProps, @@ -22,6 +20,7 @@ import { ReactNodeViewRenderer, useReactNodeView, } from "@tiptap/react"; +import * as z from "zod/v4/core"; // import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView"; import { FC } from "react"; import { renderToDOMSpec } from "./@util/ReactRenderUtil.js"; @@ -52,11 +51,11 @@ export type ReactInlineContentImplementation< // ensure no data is lost on internal copy & paste. export function InlineContentWrapper< IType extends string, - PSchema extends PropSchema, + PSchema extends z.$ZodObject, >(props: { children: JSX.Element; inlineContentType: IType; - inlineContentProps: Props; + inlineContentProps: z.infer; propSchema: PSchema; }) { return ( @@ -142,7 +141,7 @@ export function createReactInlineContentSpec< return addInlineContentAttributes( output, inlineContentConfig.type, - node.attrs as Props, + node.attrs as z.infer, inlineContentConfig.propSchema, ); }, @@ -162,7 +161,9 @@ export function createReactInlineContentSpec< const Content = inlineContentImplementation.render; return ( } + inlineContentProps={ + props.node.attrs as z.infer + } inlineContentType={inlineContentConfig.type} propSchema={inlineContentConfig.propSchema} > diff --git a/packages/xl-ai/src/testUtil/cases/schemas/mention.ts b/packages/xl-ai/src/testUtil/cases/schemas/mention.ts index 743e34948a..5948f73e31 100644 --- a/packages/xl-ai/src/testUtil/cases/schemas/mention.ts +++ b/packages/xl-ai/src/testUtil/cases/schemas/mention.ts @@ -3,15 +3,13 @@ import { createInlineContentSpec, defaultInlineContentSpecs, } from "@blocknote/core"; - +import * as z from "zod/v4/core"; export const mention = createInlineContentSpec( { type: "mention", - propSchema: { - user: { - default: "", - }, - }, + propSchema: z.object({ + user: z.string().default(""), + }), content: "none", }, { @@ -23,7 +21,7 @@ export const mention = createInlineContentSpec( dom: mention, }; }, - } + }, ); export const schemaWithMention = BlockNoteSchema.create({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be4527f3d8..f037f3ff22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2775,7 +2775,7 @@ importers: dependencies: '@ai-sdk/groq': specifier: ^1.2.9 - version: 1.2.9(zod@3.24.2) + version: 1.2.9(zod@3.25.36) '@blocknote/ariakit': specifier: latest version: link:../../../packages/ariakit @@ -2799,7 +2799,7 @@ importers: version: 7.17.3(@mantine/hooks@7.17.3(react@18.3.1))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ai: specifier: ^4.3.15 - version: 4.3.15(react@18.3.1)(zod@3.24.2) + version: 4.3.15(react@18.3.1)(zod@3.25.36) react: specifier: ^18.3.1 version: 18.3.1 @@ -2827,19 +2827,19 @@ importers: dependencies: '@ai-sdk/anthropic': specifier: ^1.2.11 - version: 1.2.11(zod@3.24.2) + version: 1.2.11(zod@3.25.36) '@ai-sdk/groq': specifier: ^1.2.9 - version: 1.2.9(zod@3.24.2) + version: 1.2.9(zod@3.25.36) '@ai-sdk/mistral': specifier: ^1.2.8 - version: 1.2.8(zod@3.24.2) + version: 1.2.8(zod@3.25.36) '@ai-sdk/openai': specifier: ^1.3.22 - version: 1.3.22(zod@3.24.2) + version: 1.3.22(zod@3.25.36) '@ai-sdk/openai-compatible': specifier: ^0.2.14 - version: 0.2.14(zod@3.24.2) + version: 0.2.14(zod@3.25.36) '@blocknote/ariakit': specifier: latest version: link:../../../packages/ariakit @@ -2863,7 +2863,7 @@ importers: version: 7.17.3(@mantine/hooks@7.17.3(react@18.3.1))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ai: specifier: ^4.3.15 - version: 4.3.15(react@18.3.1)(zod@3.24.2) + version: 4.3.15(react@18.3.1)(zod@3.25.36) react: specifier: ^18.3.1 version: 18.3.1 @@ -2891,10 +2891,10 @@ importers: dependencies: '@ai-sdk/groq': specifier: ^1.1.0 - version: 1.2.9(zod@3.24.2) + version: 1.2.9(zod@3.25.36) '@ai-sdk/openai': specifier: ^1.1.0 - version: 1.3.22(zod@3.24.2) + version: 1.3.22(zod@3.25.36) '@blocknote/ariakit': specifier: latest version: link:../../../packages/ariakit @@ -2918,7 +2918,7 @@ importers: version: 7.17.3(@mantine/hooks@7.17.3(react@18.3.1))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ai: specifier: ^4.1.0 - version: 4.3.15(react@18.3.1)(zod@3.24.2) + version: 4.3.15(react@18.3.1)(zod@3.25.36) react: specifier: ^18.3.1 version: 18.3.1 @@ -2949,7 +2949,7 @@ importers: dependencies: '@ai-sdk/groq': specifier: ^1.2.9 - version: 1.2.9(zod@3.24.2) + version: 1.2.9(zod@3.25.36) '@blocknote/ariakit': specifier: latest version: link:../../../packages/ariakit @@ -2973,7 +2973,7 @@ importers: version: 7.17.3(@mantine/hooks@7.17.3(react@18.3.1))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ai: specifier: ^4.3.15 - version: 4.3.15(react@18.3.1)(zod@3.24.2) + version: 4.3.15(react@18.3.1)(zod@3.25.36) react: specifier: ^18.3.1 version: 18.3.1 @@ -3328,6 +3328,9 @@ importers: yjs: specifier: ^13.6.15 version: 13.6.24 + zod: + specifier: ^3.25.30 + version: 3.25.30 devDependencies: '@types/emoji-mart': specifier: ^3.0.14 @@ -3484,6 +3487,9 @@ importers: react-icons: specifier: ^5.2.1 version: 5.5.0(react@18.3.1) + zod: + specifier: ^3.25.36 + version: 3.25.36 devDependencies: '@types/emoji-mart': specifier: ^3.0.14 @@ -3714,16 +3720,16 @@ importers: dependencies: '@ai-sdk/groq': specifier: ^1.2.9 - version: 1.2.9(zod@3.24.2) + version: 1.2.9(zod@3.25.36) '@ai-sdk/mistral': specifier: ^1.2.8 - version: 1.2.8(zod@3.24.2) + version: 1.2.8(zod@3.25.36) '@ai-sdk/openai': specifier: ^1.3.22 - version: 1.3.22(zod@3.24.2) + version: 1.3.22(zod@3.25.36) '@ai-sdk/openai-compatible': specifier: ^0.2.14 - version: 0.2.14(zod@3.24.2) + version: 0.2.14(zod@3.25.36) '@blocknote/core': specifier: 0.31.0 version: link:../core @@ -3744,7 +3750,7 @@ importers: version: 2.12.0(@tiptap/pm@2.12.0) ai: specifier: ^4.3.15 - version: 4.3.15(react@18.3.1)(zod@3.24.2) + version: 4.3.15(react@18.3.1)(zod@3.25.36) lodash.isequal: specifier: ^4.5.0 version: 4.5.0 @@ -4140,19 +4146,19 @@ importers: dependencies: '@ai-sdk/anthropic': specifier: ^1.2.11 - version: 1.2.11(zod@3.24.2) + version: 1.2.11(zod@3.25.36) '@ai-sdk/groq': specifier: ^1.2.9 - version: 1.2.9(zod@3.24.2) + version: 1.2.9(zod@3.25.36) '@ai-sdk/mistral': specifier: ^1.2.8 - version: 1.2.8(zod@3.24.2) + version: 1.2.8(zod@3.25.36) '@ai-sdk/openai': specifier: ^1.3.22 - version: 1.3.22(zod@3.24.2) + version: 1.3.22(zod@3.25.36) '@ai-sdk/openai-compatible': specifier: ^0.2.14 - version: 0.2.14(zod@3.24.2) + version: 0.2.14(zod@3.25.36) '@aws-sdk/client-s3': specifier: ^3.609.0 version: 3.775.0 @@ -4263,7 +4269,7 @@ importers: version: 0.6.4(react@18.3.1)(yjs@13.6.24) ai: specifier: ^4.3.15 - version: 4.3.15(react@18.3.1)(zod@3.24.2) + version: 4.3.15(react@18.3.1)(zod@3.25.36) autoprefixer: specifier: 10.4.21 version: 10.4.21(postcss@8.5.3) @@ -4332,6 +4338,9 @@ importers: typescript: specifier: ^5.3.3 version: 5.8.2 + zod: + specifier: ^3.25.36 + version: 3.25.36 tests: devDependencies: @@ -4395,6 +4404,9 @@ importers: vitest: specifier: ^2.0.3 version: 2.1.9(@types/node@20.17.28)(@vitest/ui@2.1.9)(jsdom@25.0.1(canvas@2.11.2(encoding@0.1.13)))(msw@2.7.3(@types/node@20.17.28)(typescript@5.8.2))(terser@5.39.2) + zod: + specifier: ^3.25.36 + version: 3.25.36 packages: @@ -14635,6 +14647,12 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@3.25.30: + resolution: {integrity: sha512-VolhdEtu6TJr/fzGuHA/SZ5ixvXqA6ADOG9VRcQ3rdOKmF5hkmcJbyaQjUH5BgmpA9gej++zYRX7zjSmdReIwA==} + + zod@3.25.36: + resolution: {integrity: sha512-eRFS3i8T0IrpGdL8HQyqFAugGOn7jOjyGgGdtv5NY4Wkhi7lJDk732bNZ609YMIGFbLoaj6J69O1Mura23gfIw==} + zustand@5.0.3: resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} engines: {node: '>=12.20.0'} @@ -14658,63 +14676,63 @@ packages: snapshots: - '@ai-sdk/anthropic@1.2.11(zod@3.24.2)': + '@ai-sdk/anthropic@1.2.11(zod@3.25.36)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + zod: 3.25.36 - '@ai-sdk/groq@1.2.9(zod@3.24.2)': + '@ai-sdk/groq@1.2.9(zod@3.25.36)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + zod: 3.25.36 - '@ai-sdk/mistral@1.2.8(zod@3.24.2)': + '@ai-sdk/mistral@1.2.8(zod@3.25.36)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + zod: 3.25.36 - '@ai-sdk/openai-compatible@0.2.14(zod@3.24.2)': + '@ai-sdk/openai-compatible@0.2.14(zod@3.25.36)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + zod: 3.25.36 - '@ai-sdk/openai@1.3.22(zod@3.24.2)': + '@ai-sdk/openai@1.3.22(zod@3.25.36)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + zod: 3.25.36 - '@ai-sdk/provider-utils@2.2.8(zod@3.24.2)': + '@ai-sdk/provider-utils@2.2.8(zod@3.25.36)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 3.24.2 + zod: 3.25.36 '@ai-sdk/provider@1.1.3': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@18.3.1)(zod@3.24.2)': + '@ai-sdk/react@1.2.12(react@18.3.1)(zod@3.25.36)': dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.11(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + '@ai-sdk/ui-utils': 1.2.11(zod@3.25.36) react: 18.3.1 swr: 2.3.3(react@18.3.1) throttleit: 2.1.0 optionalDependencies: - zod: 3.24.2 + zod: 3.25.36 - '@ai-sdk/ui-utils@1.2.11(zod@3.24.2)': + '@ai-sdk/ui-utils@1.2.11(zod@3.25.36)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - zod: 3.24.2 - zod-to-json-schema: 3.24.5(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + zod: 3.25.36 + zod-to-json-schema: 3.24.5(zod@3.25.36) '@alloc/quick-lru@5.2.0': {} @@ -20053,15 +20071,15 @@ snapshots: agent-base@7.1.3: {} - ai@4.3.15(react@18.3.1)(zod@3.24.2): + ai@4.3.15(react@18.3.1)(zod@3.25.36): dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) - '@ai-sdk/react': 1.2.12(react@18.3.1)(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.11(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.36) + '@ai-sdk/react': 1.2.12(react@18.3.1)(zod@3.25.36) + '@ai-sdk/ui-utils': 1.2.11(zod@3.25.36) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 - zod: 3.24.2 + zod: 3.25.36 optionalDependencies: react: 18.3.1 @@ -23923,7 +23941,7 @@ snapshots: workerd: 1.20240129.0 ws: 8.18.1 youch: 3.3.4 - zod: 3.24.2 + zod: 3.25.36 transitivePeerDependencies: - bufferutil - supports-color @@ -27075,12 +27093,16 @@ snapshots: mustache: 4.2.0 stacktracey: 2.1.8 - zod-to-json-schema@3.24.5(zod@3.24.2): + zod-to-json-schema@3.24.5(zod@3.25.36): dependencies: - zod: 3.24.2 + zod: 3.25.36 zod@3.24.2: {} + zod@3.25.30: {} + + zod@3.25.36: {} + zustand@5.0.3(@types/react@18.3.20)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.20 diff --git a/shared/formatConversionTestUtil.ts b/shared/formatConversionTestUtil.ts index 5a9c95f090..08038b1a89 100644 --- a/shared/formatConversionTestUtil.ts +++ b/shared/formatConversionTestUtil.ts @@ -17,6 +17,7 @@ import { TableContent, UniqueID, } from "@blocknote/core"; +import * as z from "zod/v4/core"; function textShorthandToStyledText( content: string | StyledText[] = "", @@ -153,16 +154,17 @@ export function partialBlockToBlockForTesting< ...partialBlock, }; - Object.entries(schema[partialBlock.type!].propSchema).forEach( - ([propKey, propValue]) => { - if ( - withDefaults.props[propKey] === undefined && - propValue.default !== undefined - ) { - (withDefaults.props as any)[propKey] = propValue.default; - } - }, - ); + Object.entries( + (schema[partialBlock.type!].propSchema as z.$ZodObject)._zod.def.shape, + ).forEach(([propKey, propValue]) => { + const def = + propValue instanceof z.$ZodDefault + ? propValue._zod.def.defaultValue + : undefined; + if (withDefaults.props[propKey] === undefined && def !== undefined) { + (withDefaults.props as any)[propKey] = def; + } + }); if (contentType === "inline") { const content = withDefaults.content as InlineContent[] | undefined; diff --git a/shared/package.json b/shared/package.json index 1811aa6016..ac0753784e 100644 --- a/shared/package.json +++ b/shared/package.json @@ -17,7 +17,8 @@ }, "devDependencies": { "@types/node": "22.13.13", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "zod": "^3.25.36" }, "peerDependencies": { "image-meta": "^0.2.1" diff --git a/tests/package.json b/tests/package.json index 83b9d58a01..9bcdfdb29a 100644 --- a/tests/package.json +++ b/tests/package.json @@ -32,7 +32,8 @@ "rimraf": "^5.0.5", "vite": "^5.3.4", "vite-plugin-eslint": "^1.8.1", - "vitest": "^2.0.3" + "vitest": "^2.0.3", + "zod": "^3.25.36" }, "eslintConfig": { "extends": [ diff --git a/tests/src/unit/core/testSchema.ts b/tests/src/unit/core/testSchema.ts index b47a3276f0..2164dfa0a2 100644 --- a/tests/src/unit/core/testSchema.ts +++ b/tests/src/unit/core/testSchema.ts @@ -11,6 +11,7 @@ import { imageRender, PageBreak, } from "@blocknote/core"; +import * as z from "zod/v4"; // BLOCKS ---------------------------------------------------------------------- @@ -80,11 +81,9 @@ const SimpleCustomParagraph = createBlockSpec( const Mention = createInlineContentSpec( { type: "mention", - propSchema: { - user: { - default: "", - }, - }, + propSchema: z.object({ + user: z.string().default(""), + }), content: "none", }, { @@ -102,7 +101,7 @@ const Mention = createInlineContentSpec( const Tag = createInlineContentSpec( { type: "tag" as const, - propSchema: {}, + propSchema: z.object({}), content: "styled", }, { diff --git a/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts b/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts index 055218bb22..209716a0b9 100644 --- a/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts +++ b/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts @@ -78,6 +78,12 @@ export const testExportNodes = async < addIdsToBlocks(testCase.content); + const node1 = blockToNode( + testCase.content[0], + editor.pmSchema, + editor.schema.styleSchema, + ); + debugger; await expect( testCase.content.map((block) => blockToNode(block, editor.pmSchema, editor.schema.styleSchema), diff --git a/tests/src/unit/shared/formatConversion/formatConversionTestUtil.ts b/tests/src/unit/shared/formatConversion/formatConversionTestUtil.ts index 8eea1129df..76ea36591a 100644 --- a/tests/src/unit/shared/formatConversion/formatConversionTestUtil.ts +++ b/tests/src/unit/shared/formatConversion/formatConversionTestUtil.ts @@ -17,6 +17,7 @@ import { TableContent, UniqueID, } from "@blocknote/core"; +import * as z from "zod/v4/core"; function textShorthandToStyledText( content: string | StyledText[] = "", @@ -143,16 +144,17 @@ export function partialBlockToBlockForTesting< ...partialBlock, }; - Object.entries(schema[partialBlock.type!].propSchema).forEach( - ([propKey, propValue]) => { - if ( - withDefaults.props[propKey] === undefined && - propValue.default !== undefined - ) { - (withDefaults.props as any)[propKey] = propValue.default; - } - }, - ); + Object.entries( + (schema[partialBlock.type!].propSchema as z.$ZodObject)._zod.def.shape, + ).forEach(([propKey, propValue]) => { + const def = + propValue instanceof z.$ZodDefault + ? propValue._zod.def.defaultValue + : undefined; + if (withDefaults.props[propKey] === undefined && def !== undefined) { + (withDefaults.props as any)[propKey] = def; + } + }); if (contentType === "inline") { const content = withDefaults.content as InlineContent[] | undefined; From 2452e0f6ff1fb1b10e32b2cb0ddbcc020445406b Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 29 May 2025 17:21:39 +0200 Subject: [PATCH 2/4] remove debugger --- .../shared/formatConversion/export/exportTestExecutors.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts b/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts index 209716a0b9..055218bb22 100644 --- a/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts +++ b/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts @@ -78,12 +78,6 @@ export const testExportNodes = async < addIdsToBlocks(testCase.content); - const node1 = blockToNode( - testCase.content[0], - editor.pmSchema, - editor.schema.styleSchema, - ); - debugger; await expect( testCase.content.map((block) => blockToNode(block, editor.pmSchema, editor.schema.styleSchema), From 62f4fb9e4b05be264296c8993751485aa9232af9 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 5 Jun 2025 18:13:48 +0200 Subject: [PATCH 3/4] best shot so far at file blocks --- .../AudioBlockContent/AudioBlockContent.ts | 29 ++++++++--------- .../FileBlockContent/FileBlockContent.ts | 31 +++++++++++++------ .../core/src/blocks/defaultBlockTypeGuards.ts | 28 ++++++++--------- packages/core/src/schema/blocks/types.ts | 27 ++++++---------- .../AudioBlockContent/AudioBlockContent.tsx | 10 +++--- 5 files changed, 64 insertions(+), 61 deletions(-) diff --git a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts index 618b96b50f..488e6f5f73 100644 --- a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts +++ b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts @@ -7,6 +7,10 @@ import { } from "../../schema/index.js"; import { defaultProps } from "../defaultProps.js"; +import { + baseFilePropSchema, + optionalFilePropFields, +} from "../FileBlockContent/FileBlockContent.js"; import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js"; import { createFileBlockWrapper } from "../FileBlockContent/helpers/render/createFileBlockWrapper.js"; import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; @@ -16,25 +20,22 @@ import { parseAudioElement } from "./parseAudioElement.js"; export const FILE_AUDIO_ICON_SVG = ''; -export const audioPropSchema = defaultProps - .pick({ +export const audioPropSchema = z.object({ + ...defaultProps.pick({ backgroundColor: true, - }) - .extend({ - // File name. - name: z.string().default(""), - // File url. - url: z.string().default(""), - // File caption. - caption: z.string().default(""), - - showPreview: z.boolean().default(true), - }); + }).shape, + ...baseFilePropSchema.shape, + ...optionalFilePropFields.pick({ + url: true, + showPreview: true, + previewWidth: true, + }).shape, +}); export const audioBlockConfig = { type: "audio" as const, propSchema: audioPropSchema, - content: "none", + content: "none" as const, isFileBlock: true, fileBlockAccept: ["audio/*"], } satisfies FileBlockConfig; diff --git a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts index c150f0da0d..c3f85e1c85 100644 --- a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts +++ b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts @@ -1,9 +1,9 @@ -import z from "zod/v4"; +import * as z from "zod/v4"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockFromConfig, - FileBlockConfig, createBlockSpec, + FileBlockConfig, } from "../../schema/index.js"; import { defaultProps } from "../defaultProps.js"; import { parseEmbedElement } from "./helpers/parse/parseEmbedElement.js"; @@ -11,18 +11,29 @@ import { parseFigureElement } from "./helpers/parse/parseFigureElement.js"; import { createFileBlockWrapper } from "./helpers/render/createFileBlockWrapper.js"; import { createLinkWithCaption } from "./helpers/toExternalHTML/createLinkWithCaption.js"; +export const baseFilePropSchema = z.object({ + caption: z.string().default(""), + name: z.string().default(""), +}); + +export const optionalFilePropFields = z.object({ + // URL is optional, as we also want to accept files with no URL, but for example ids + // (ids can be used for files that are resolved on the backend) + url: z.string().default(""), + // Whether to show the file preview or the name only. + // This is useful for some file blocks, but not all + // (e.g.: not relevant for default "file" block which doesn;'t show previews) + showPreview: z.boolean().default(true), + // File preview width in px. + previewWidth: z.number(), +}); + export const filePropSchema = defaultProps .pick({ backgroundColor: true, }) - .extend({ - // File name. - name: z.string().default(""), - // File url. - url: z.string().default(""), - // File caption. - caption: z.string().default(""), - }); + .extend(baseFilePropSchema.shape) + .extend(optionalFilePropFields.pick({ url: true }).shape); export const fileBlockConfig = { type: "file" as const, diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index a144fd8dcb..f0db7f38a0 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -1,5 +1,6 @@ import { Selection } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; +import * as z from "zod/v4/core"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { BlockFromConfig, @@ -16,7 +17,6 @@ import { defaultInlineContentSchema, } from "./defaultBlocks.js"; import { defaultProps } from "./defaultProps.js"; - export function checkDefaultBlockTypeInSchema< BlockType extends keyof DefaultBlockSchema, I extends InlineContentSchema, @@ -114,7 +114,7 @@ export function checkBlockIsFileBlockWithPlaceholder< } export function checkBlockTypeHasDefaultProp< - Prop extends keyof typeof defaultProps, + Prop extends keyof typeof defaultProps.def.shape, I extends InlineContentSchema, S extends StyleSchema, >( @@ -122,28 +122,28 @@ export function checkBlockTypeHasDefaultProp< blockType: string, editor: BlockNoteEditor, ): editor is BlockNoteEditor< - // { - // [BT in string]: { - // type: BT; - // propSchema: { - // [P in Prop]: (typeof defaultProps)[P]; - // }; - // content: "table" | "inline" | "none"; - // }; - // }, - any, // TODO + { + [BT in string]: { + type: BT; + propSchema: z.$ZodObject<{ + [P in Prop]: (typeof defaultProps.def.shape)[P]; + }>; + content: "table" | "inline" | "none"; + }; + }, I, S > { return ( blockType in editor.schema.blockSchema && prop in editor.schema.blockSchema[blockType].propSchema && - editor.schema.blockSchema[blockType].propSchema[prop] === defaultProps[prop] + editor.schema.blockSchema[blockType].propSchema[prop] === + defaultProps.def.shape[prop] ); } export function checkBlockHasDefaultProp< - Prop extends keyof typeof defaultProps, + Prop extends keyof typeof defaultProps.def.shape, I extends InlineContentSchema, S extends StyleSchema, >( diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 594ff4ba37..1889c1afee 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -22,32 +22,23 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; -const filePropSchema = z.object({ +const filePropSchema = z.looseObject({ caption: z.string().default(""), name: z.string().default(""), // URL is optional, as we also want to accept files with no URL, but for example ids // (ids can be used for files that are resolved on the backend) - url: z.string().default(""), - // Whether to show the file preview or the name only. - // This is useful for some file blocks, but not all - // (e.g.: not relevant for default "file" block which doesn;'t show previews) - showPreview: z.boolean().default(true), - // File preview width in px. - previewWidth: z.number().optional(), + // url: z.string().default("").optional(), + // // Whether to show the file preview or the name only. + // // This is useful for some file blocks, but not all + // // (e.g.: not relevant for default "file" block which doesn;'t show previews) + // showPreview: z.boolean().default(true).optional(), + // // File preview width in px. + // previewWidth: z.number().optional(), }); -// TODO: comment -type shape = Pick & - Partial< - Pick< - typeof filePropSchema._zod.def.shape, - "url" | "showPreview" | "previewWidth" - > - >; - export type FileBlockConfig = { type: string; - readonly propSchema: zCore.$ZodObject; + readonly propSchema: typeof filePropSchema; content: "none"; isSelectable?: boolean; isFileBlock: true; diff --git a/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx b/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx index b602aaa971..46914e61e2 100644 --- a/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx +++ b/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx @@ -1,4 +1,4 @@ -import { FileBlockConfig, audioBlockConfig, audioParse } from "@blocknote/core"; +import { audioBlockConfig, audioParse } from "@blocknote/core"; import { RiVolumeUpFill } from "react-icons/ri"; @@ -6,18 +6,18 @@ import { ReactCustomBlockRenderProps, createReactBlockSpec, } from "../../schema/ReactBlockSpec.js"; -import { useResolveUrl } from "../FileBlockContent/useResolveUrl.js"; -import { FigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/FigureWithCaption.js"; import { FileBlockWrapper } from "../FileBlockContent/helpers/render/FileBlockWrapper.js"; +import { FigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/FigureWithCaption.js"; import { LinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/LinkWithCaption.js"; +import { useResolveUrl } from "../FileBlockContent/useResolveUrl.js"; export const AudioPreview = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps, "contentRef" >, ) => { - const resolved = useResolveUrl(props.block.props.url!); + const resolved = useResolveUrl(props.block.props.url); return (