diff --git a/packages/core/src/editor/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts index b4c1e26558..8d056a5ae0 100644 --- a/packages/core/src/editor/BlockNoteEditor.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.test.ts @@ -1,7 +1,11 @@ -import { expect, it } from "vitest"; +import { assertType, expect, it } from "vitest"; import { BlockNoteEditor } from "./BlockNoteEditor"; import { getBlockInfoFromPos } from "../api/getBlockInfoFromPos"; - +import { createBlockSpec } from "../schema/blocks/createSpec"; +import { DefaultBlockSchema, defaultBlockSchema, defaultBlockSpecs } from "../blocks/defaultBlocks"; +import { BlockSchemaFromSpecs, PartialBlock } from "../schema/blocks/types"; +import { getDefaultSlashMenuItems, } from '../extensions/SlashMenu/defaultSlashMenuItems' +import { BaseSlashMenuItem } from "../extensions/SlashMenu/BaseSlashMenuItem"; /** * @vitest-environment jsdom */ @@ -10,3 +14,97 @@ it("creates an editor", () => { const blockInfo = getBlockInfoFromPos(editor._tiptapEditor.state.doc, 2); expect(blockInfo?.contentNode.type.name).toEqual("paragraph"); }); + +it("support custom block", () => { + const testBlock = createBlockSpec( + { + type: "testBlock", + propSchema: { + content: { + default: "test", + }, + }, + content: "none", + }, + { + render: (block) => { + const div = document.createElement("div"); + div.contentEditable = "true"; + div.innerHTML = block.props.content; + + return { + dom: div, + }; + }, + } + ) + + const editor = BlockNoteEditor.create({ + blockSpecs: { + ...defaultBlockSpecs, + test: testBlock, + }, + slashMenuItems: [ + ...getDefaultSlashMenuItems(), + { + name: "table", + execute: (_editor) => { + assertType>>(_editor) + + const currentBlock = editor.getTextCursorPosition().block; + + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _errorHelloWorldBlock = { + // @ts-expect-error type should be "testBlock" + type: "test", + props: { + content: "Hello World", + }, + } satisfies PartialBlock, any, any> + + const helloWorldBlock = { + type: "testBlock", + props: { + content: "Hello World", + }, + } satisfies PartialBlock, any, any> + + editor.insertBlocks([helloWorldBlock], currentBlock, "after"); + }, + }, + ], + }); + + assertType[]>(getDefaultSlashMenuItems(defaultBlockSchema)) + + assertType, + any, + any + >[]>(getDefaultSlashMenuItems({ + test: testBlock['config'], + })) + + // @ts-expect-error blockSpecs of editor need to inculde the test block + assertType(editor) + + assertType>>(editor) + + const blockInfo = getBlockInfoFromPos(editor._tiptapEditor.state.doc, 2); + expect(blockInfo?.contentNode.type.name).toEqual("paragraph"); +}) \ No newline at end of file diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 38d2a85641..af71367e8f 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -58,7 +58,7 @@ import { StyleSpecs, } from "../schema"; import { mergeCSSClasses } from "../util/browser"; -import { UnreachableCaseError } from "../util/typescript"; +import { NoInfer, UnreachableCaseError } from "../util/typescript"; import { getBlockNoteExtensions } from "./BlockNoteExtensions"; import { TextCursorPosition } from "./cursorPositionTypes"; @@ -79,11 +79,17 @@ export type BlockNoteEditorOptions< enableBlockNoteExtensions: boolean; /** * - * (couldn't fix any type, see https://github.com/TypeCellOS/BlockNote/pull/191#discussion_r1210708771) + * // (couldn't fix any type, see https://github.com/TypeCellOS/BlockNote/pull/191#discussion_r1210708771) * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashMenuItems: BaseSlashMenuItem[]; + slashMenuItems: Array< + BaseSlashMenuItem< + BlockSchemaFromSpecs>, + any, + any + > + >; /** * The HTML element that should be used as the parent element for the editor. diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index b6b3aa0115..b7c4042201 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -8,6 +8,7 @@ import { StyleSchema, isStyledTextInlineContent, } from "../../schema"; +import { Equal } from "../../util/typescript"; import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; @@ -81,7 +82,7 @@ export const getDefaultSlashMenuItems = < >( schema: BSchema = defaultBlockSchema as unknown as BSchema ) => { - const slashMenuItems: BaseSlashMenuItem[] = []; + const slashMenuItems: BaseSlashMenuItem extends true ? any : BSchema, I, S>[] = []; if ("heading" in schema && "level" in schema.heading.propSchema) { // Command for creating a level 1 heading diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index eee2ac6788..833592da4f 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -115,7 +115,7 @@ export function getParseRules( // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createBlockSpec< - T extends CustomBlockConfig, + const T extends CustomBlockConfig, I extends InlineContentSchema, S extends StyleSchema >(blockConfig: T, blockImplementation: CustomBlockImplementation) { diff --git a/packages/core/src/util/typescript.ts b/packages/core/src/util/typescript.ts index 93b94f8b51..9b9e5eb418 100644 --- a/packages/core/src/util/typescript.ts +++ b/packages/core/src/util/typescript.ts @@ -2,4 +2,16 @@ export class UnreachableCaseError extends Error { constructor(val: never) { super(`Unreachable case: ${val}`); } -} \ No newline at end of file +} + +/** + * https://stackoverflow.com/questions/56687668/a-way-to-disable-type-argument-inference-in-generics + * + * https://github.com/microsoft/TypeScript/pull/56794 + * + * TODO: maybe remove this type after typescript 5.4 is released + */ +export type NoInfer = [T][T extends any ? 0 : never]; + +export type Equal = + (() => U extends Left ? 1 : 0) extends (() => U extends Right ? 1 : 0) ? true : false diff --git a/packages/react/src/hooks/useBlockNote.ts b/packages/react/src/hooks/useBlockNote.ts index 2cf1f213e8..3574f83abe 100644 --- a/packages/react/src/hooks/useBlockNote.ts +++ b/packages/react/src/hooks/useBlockNote.ts @@ -14,17 +14,33 @@ import { } from "@blocknote/core"; import { DependencyList, useMemo, useRef } from "react"; import { getDefaultReactSlashMenuItems } from "../slashMenuItems/defaultReactSlashMenuItems"; +import { ReactSlashMenuItem } from "../slashMenuItems/ReactSlashMenuItem"; +import { NoInfer } from "@blocknote/core/src/util/typescript"; + +type ReactBlockNoteEditorOptions< + BSpecs extends BlockSpecs, + ISpecs extends InlineContentSpecs, + SSpecs extends StyleSpecs +> = Omit, 'slashMenuItems'> & { + slashMenuItems: Array< + ReactSlashMenuItem< + BlockSchemaFromSpecs>, + any, + any + > + > +}; const initEditor = < BSpecs extends BlockSpecs, ISpecs extends InlineContentSpecs, SSpecs extends StyleSpecs >( - options: Partial> + options: Partial> ) => BlockNoteEditor.create({ slashMenuItems: getDefaultReactSlashMenuItems( - getBlockSchemaFromSpecs(options.blockSpecs || defaultBlockSpecs) + getBlockSchemaFromSpecs(options.blockSpecs as BSpecs || defaultBlockSpecs) ), ...options, }); @@ -37,7 +53,7 @@ export const useBlockNote = < ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, SSpecs extends StyleSpecs = typeof defaultStyleSpecs >( - options: Partial> = {}, + options: Partial> = {}, deps: DependencyList = [] ) => { const editorRef = diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index ed447edefe..7c66aa4426 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -101,7 +101,7 @@ export function BlockContentWrapper< // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createReactBlockSpec< - T extends CustomBlockConfig, + const T extends CustomBlockConfig, I extends InlineContentSchema, S extends StyleSchema >( diff --git a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx index d252b8b605..6fd998b3e0 100644 --- a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx @@ -85,9 +85,9 @@ export function getDefaultReactSlashMenuItems< // the schema type to be automatically inferred if it is defined, or be // inferred as any if it is not defined. I don't think it's possible to make it // infer to DefaultBlockSchema if it is not defined. - schema: BSchema = defaultBlockSchema as any as BSchema -): ReactSlashMenuItem[] { - const slashMenuItems: BaseSlashMenuItem[] = + schema: BSchema = defaultBlockSchema as unknown as BSchema +): ReactSlashMenuItem[] { + const slashMenuItems : BaseSlashMenuItem[] = getDefaultSlashMenuItems(schema); return slashMenuItems.map((item) => ({ diff --git a/tests/src/utils/components/Editor.tsx b/tests/src/utils/components/Editor.tsx index ffc47cc38f..dd6d206cd7 100644 --- a/tests/src/utils/components/Editor.tsx +++ b/tests/src/utils/components/Editor.tsx @@ -5,11 +5,11 @@ import { getDefaultReactSlashMenuItems, useBlockNote, } from "@blocknote/react"; -import { Alert, insertAlert } from "../customblocks/Alert"; -import { Button, insertButton } from "../customblocks/Button"; -import { Embed, insertEmbed } from "../customblocks/Embed"; -import { Image, insertImage } from "../customblocks/Image"; -import { Separator, insertSeparator } from "../customblocks/Separator"; +import { Alert } from "../customblocks/Alert"; +import { Button } from "../customblocks/Button"; +import { Embed } from "../customblocks/Embed"; +import { Image } from "../customblocks/Image"; +import { Separator } from "../customblocks/Separator"; import styles from "./Editor.module.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; @@ -25,21 +25,50 @@ export default function Editor() { // toc: TableOfContents, }; - const slashMenuItems = [ - insertAlert, - insertButton, - insertEmbed, - insertImage, - insertSeparator, - // insertTableOfContents, - ]; + // const slashMenuItems = [ + // insertAlert, + // insertButton, + // insertEmbed, + // insertImage, + // insertSeparator, + // // insertTableOfContents, + // ]; const editor = useBlockNote({ domAttributes: { editor: { class: styles.editor, "data-test": "editor" }, }, blockSpecs, - slashMenuItems: [...getDefaultReactSlashMenuItems(), ...slashMenuItems], + slashMenuItems: [ + ...getDefaultReactSlashMenuItems(), + // ...slashMenuItems, + { + name: "Insert Alert", + execute: (editor) => { + editor.insertBlocks( + [ + { + type: "alert", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + aliases: [ + "alert", + "notification", + "emphasize", + "warning", + "error", + "info", + "success", + ], + group: "Media", + icon:
, + hint: "Insert an alert block to emphasize text", + } + ], }); console.log(editor); diff --git a/tests/src/utils/customblocks/Alert.tsx b/tests/src/utils/customblocks/Alert.tsx index 1585f81de3..87f73252b0 100644 --- a/tests/src/utils/customblocks/Alert.tsx +++ b/tests/src/utils/customblocks/Alert.tsx @@ -1,4 +1,4 @@ -import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core"; +import { BlockSchemaWithBlock, createBlockSpec, defaultProps } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiAlertFill } from "react-icons/ri"; const values = { @@ -120,7 +120,12 @@ export const Alert = createBlockSpec( } ); -export const insertAlert: ReactSlashMenuItem = { + +export const insertAlert: ReactSlashMenuItem< + BlockSchemaWithBlock<"alert", typeof Alert.config>, + any, + any +> = { name: "Insert Alert", execute: (editor) => { // editor.topLevelBlocks[0]