diff --git a/examples/vanilla/src/ui/blockSideMenuFactory.ts b/examples/vanilla/src/ui/blockSideMenuFactory.ts index 1f6cad7fa4..039e6939d7 100644 --- a/examples/vanilla/src/ui/blockSideMenuFactory.ts +++ b/examples/vanilla/src/ui/blockSideMenuFactory.ts @@ -1,11 +1,13 @@ -import { BlockSideMenuFactory } from "@blocknote/core"; +import { BlockSideMenuFactory, DefaultBlockSchema } from "@blocknote/core"; import { createButton } from "./util"; /** * This menu is drawn next to a block, when it's hovered over * It renders a drag handle and + button to create a new block */ -export const blockSideMenuFactory: BlockSideMenuFactory = (staticParams) => { +export const blockSideMenuFactory: BlockSideMenuFactory = ( + staticParams +) => { const container = document.createElement("div"); container.style.background = "gray"; container.style.position = "absolute"; diff --git a/examples/vanilla/src/ui/formattingToolbarFactory.ts b/examples/vanilla/src/ui/formattingToolbarFactory.ts index 9d72c7907c..6ca82b69c8 100644 --- a/examples/vanilla/src/ui/formattingToolbarFactory.ts +++ b/examples/vanilla/src/ui/formattingToolbarFactory.ts @@ -1,13 +1,13 @@ -import { FormattingToolbarFactory } from "@blocknote/core"; +import { DefaultBlockSchema, FormattingToolbarFactory } from "@blocknote/core"; import { createButton } from "./util"; /** * This menu is drawn when a piece of text is selected. We can use it to change formatting options * such as bold, italic, indentation, etc. */ -export const formattingToolbarFactory: FormattingToolbarFactory = ( - staticParams -) => { +export const formattingToolbarFactory: FormattingToolbarFactory< + DefaultBlockSchema +> = (staticParams) => { const container = document.createElement("div"); container.style.background = "gray"; container.style.position = "absolute"; diff --git a/examples/vanilla/src/ui/slashMenuFactory.ts b/examples/vanilla/src/ui/slashMenuFactory.ts index 8cfaf00484..4730827626 100644 --- a/examples/vanilla/src/ui/slashMenuFactory.ts +++ b/examples/vanilla/src/ui/slashMenuFactory.ts @@ -1,13 +1,17 @@ -import { BaseSlashMenuItem, SuggestionsMenuFactory } from "@blocknote/core"; +import { + BaseSlashMenuItem, + DefaultBlockSchema, + SuggestionsMenuFactory, +} from "@blocknote/core"; import { createButton } from "./util"; /** * This menu is drawn when the cursor is moved to a hyperlink (using the keyboard), * or when the mouse is hovering over a hyperlink */ -export const slashMenuFactory: SuggestionsMenuFactory = ( - staticParams -) => { +export const slashMenuFactory: SuggestionsMenuFactory< + BaseSlashMenuItem +> = (staticParams) => { const container = document.createElement("div"); container.style.background = "gray"; container.style.position = "absolute"; @@ -17,8 +21,8 @@ export const slashMenuFactory: SuggestionsMenuFactory = ( document.body.appendChild(container); function updateItems( - items: BaseSlashMenuItem[], - onClick: (item: BaseSlashMenuItem) => void, + items: BaseSlashMenuItem[], + onClick: (item: BaseSlashMenuItem) => void, selected: number ) { container.innerHTML = ""; diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 389844f255..abf2067365 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -19,18 +19,18 @@ import { getNodeById } from "./api/util/nodeUtil"; import { getBlockNoteExtensions, UiFactories } from "./BlockNoteExtensions"; import styles from "./editor.module.css"; import { + Block, BlockIdentifier, - BlockSpec, - BlockTemplate, - PartialBlockTemplate, + BlockSchema, + PartialBlock, } from "./extensions/Blocks/api/blockTypes"; import { MouseCursorPosition, TextCursorPosition, } from "./extensions/Blocks/api/cursorPositionTypes"; import { - defaultBlocks, - DefaultBlockTypes, + defaultBlockSchema, + DefaultBlockSchema, } from "./extensions/Blocks/api/defaultBlocks"; import { ColorStyle, @@ -44,29 +44,20 @@ import { defaultSlashMenuItems, } from "./extensions/SlashMenu"; -// We need to separate WithChildren, otherwise we get issues with recursive types -// maybe worth a revisit before merging -type WithChildren> = Block & { - children: WithChildren[]; -}; - -export type BlockNoteEditorOptions< - BareBlock extends BlockTemplate, - Block extends BareBlock = WithChildren -> = { +export type BlockNoteEditorOptions = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. enableBlockNoteExtensions: boolean; disableHistoryExtension: boolean; /** * Factories used to create a custom UI for BlockNote */ - uiFactories: UiFactories; + uiFactories: UiFactories; /** * TODO: why is this called slashCommands and not slashMenuItems? * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashCommands: BaseSlashMenuItem[]; + slashCommands: BaseSlashMenuItem[]; /** * The HTML element that should be used as the parent element for the editor. @@ -83,16 +74,16 @@ export type BlockNoteEditorOptions< /** * A callback function that runs when the editor is ready to be used. */ - onEditorReady: (editor: BlockNoteEditor) => void; + onEditorReady: (editor: BlockNoteEditor) => void; /** * A callback function that runs whenever the editor's contents change. */ - onEditorContentChange: (editor: BlockNoteEditor) => void; + onEditorContentChange: (editor: BlockNoteEditor) => void; /** * A callback function that runs whenever the text cursor position changes. */ - onTextCursorPositionChange: (editor: BlockNoteEditor) => void; - initialContent: PartialBlockTemplate[]; + onTextCursorPositionChange: (editor: BlockNoteEditor) => void; + initialContent: PartialBlock[]; /** * Use default BlockNote font and reset the styles of

  • elements etc., that are used in BlockNote. @@ -104,7 +95,8 @@ export type BlockNoteEditorOptions< /** * A list of block types that should be available in the editor. */ - blocks: BlockSpec[]; // TODO, type this so that it matches + blockSchema: BSchema; + // tiptap options, undocumented _tiptapOptions: any; }; @@ -115,15 +107,11 @@ const blockNoteTipTapOptions = { enableCoreExtensions: false, }; -// TODO: make type of BareBlock / Block automatically based on options.blocks -export class BlockNoteEditor< - BareBlock extends BlockTemplate = DefaultBlockTypes, - Block extends BareBlock & { children: Block[] } = WithChildren -> { +export class BlockNoteEditor { public readonly _tiptapEditor: TiptapEditor & { contentComponent: any }; - private blockCache = new WeakMap(); + public blockCache = new WeakMap>(); private mousePos = { x: 0, y: 0 }; - private schema = new Map(); + public readonly schema: BSchema; public get domElement() { return this._tiptapEditor.view.dom as HTMLDivElement; @@ -133,36 +121,37 @@ export class BlockNoteEditor< this._tiptapEditor.view.focus(); } - constructor(options: Partial> = {}) { + constructor(options: Partial> = {}) { console.log("test"); // apply defaults - options = { + const newOptions: Omit & { + defaultStyles: boolean; + blockSchema: BSchema; + } = { defaultStyles: true, - blocks: defaultBlocks, + // TODO: There's a lot of annoying typing stuff to deal with here. If + // BSchema is specified, then options.blockSchema should also be required. + // If BSchema is not specified, then options.blockSchema should also not + // be defined. Unfortunately, trying to implement these constraints seems + // to be a huge pain, hence the `as any` casts. + blockSchema: options.blockSchema || (defaultBlockSchema as any), ...options, }; - const blockNoteExtensions = getBlockNoteExtensions({ + const blockNoteExtensions = getBlockNoteExtensions({ editor: this, - uiFactories: options.uiFactories || {}, - slashCommands: options.slashCommands || defaultSlashMenuItems, - blocks: options.blocks || [], + uiFactories: newOptions.uiFactories || {}, + slashCommands: + newOptions.slashCommands || (defaultSlashMenuItems() as any), + blocks: newOptions.blockSchema, }); - // add blocks to schema - for (const block of options.blocks || []) { - this.schema.set(block.type, block); - } + this.schema = newOptions.blockSchema; - let extensions = options.disableHistoryExtension + let extensions = newOptions.disableHistoryExtension ? blockNoteExtensions.filter((e) => e.name !== "history") : blockNoteExtensions; - // for (const ext of extensions) { - // console.log(ext); - // if (ext.type === "node" && ext.config.group === "blockContent") - // } - const tiptapOptions: EditorOptions = { // TODO: This approach to setting initial content is "cleaner" but requires the PM editor schema, which is only // created after initializing the TipTap editor. Not sure it's feasible. @@ -172,11 +161,11 @@ export class BlockNoteEditor< // blockToNode(block, this._tiptapEditor.schema).toJSON() // ), ...blockNoteTipTapOptions, - ...options._tiptapOptions, + ...newOptions._tiptapOptions, onCreate: () => { - options.onEditorReady?.(this); - options.initialContent && - this.replaceBlocks(this.topLevelBlocks, options.initialContent); + newOptions.onEditorReady?.(this); + newOptions.initialContent && + this.replaceBlocks(this.topLevelBlocks, newOptions.initialContent); document.addEventListener( "mousemove", (event: MouseEvent) => @@ -184,30 +173,30 @@ export class BlockNoteEditor< ); }, onUpdate: () => { - options.onEditorContentChange?.(this); + newOptions.onEditorContentChange?.(this); }, onSelectionUpdate: () => { - options.onTextCursorPositionChange?.(this); + newOptions.onTextCursorPositionChange?.(this); }, extensions: - options.enableBlockNoteExtensions === false - ? options._tiptapOptions?.extensions - : [...(options._tiptapOptions?.extensions || []), ...extensions], + newOptions.enableBlockNoteExtensions === false + ? newOptions._tiptapOptions?.extensions + : [...(newOptions._tiptapOptions?.extensions || []), ...extensions], editorProps: { attributes: { - ...(options.editorDOMAttributes || {}), + ...(newOptions.editorDOMAttributes || {}), class: [ styles.bnEditor, styles.bnRoot, - options.defaultStyles ? styles.defaultStyles : "", - options.editorDOMAttributes?.class || "", + newOptions.defaultStyles ? styles.defaultStyles : "", + newOptions.editorDOMAttributes?.class || "", ].join(" "), }, }, }; - if (options.parentElement) { - tiptapOptions.element = options.parentElement; + if (newOptions.parentElement) { + tiptapOptions.element = newOptions.parentElement; } this._tiptapEditor = new Editor(tiptapOptions) as Editor & { @@ -219,8 +208,8 @@ export class BlockNoteEditor< * Gets a snapshot of all top-level (non-nested) blocks in the editor. * @returns A snapshot of all top-level (non-nested) blocks in the editor. */ - public get topLevelBlocks(): Block[] { - const blocks: Block[] = []; + public get topLevelBlocks(): Block[] { + const blocks: Block[] = []; this._tiptapEditor.state.doc.firstChild!.descendants((node) => { blocks.push(nodeToBlock(node, this.schema, this.blockCache)); @@ -236,12 +225,14 @@ export class BlockNoteEditor< * @param blockIdentifier The identifier of an existing block that should be retrieved. * @returns The block that matches the identifier, or `undefined` if no matching block was found. */ - public getBlock(blockIdentifier: BlockIdentifier): Block | undefined { + public getBlock( + blockIdentifier: BlockIdentifier + ): Block | undefined { const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - let newBlock: Block | undefined = undefined; + let newBlock: Block | undefined = undefined; this._tiptapEditor.state.doc.firstChild!.descendants((node) => { if (typeof newBlock !== "undefined") { @@ -266,7 +257,7 @@ export class BlockNoteEditor< * @param reverse Whether the blocks should be traversed in reverse order. */ public forEachBlock( - callback: (block: Block) => boolean, + callback: (block: Block) => boolean, reverse: boolean = false ): void { const blocks = this.topLevelBlocks.slice(); @@ -275,7 +266,7 @@ export class BlockNoteEditor< blocks.reverse(); } - function traverseBlockArray(blockArray: Block[]): boolean { + function traverseBlockArray(blockArray: Block[]): boolean { for (const block of blockArray) { if (!callback(block)) { return false; @@ -300,7 +291,7 @@ export class BlockNoteEditor< * Gets a snapshot of the current mouse cursor position. * @returns A snapshot of the current mouse cursor position. */ - public getMouseCursorPosition(): MouseCursorPosition | undefined { + public getMouseCursorPosition(): MouseCursorPosition | undefined { // Editor itself may have padding or other styling which affects size/position, so we get the boundingRect of // the first child (i.e. the blockGroup that wraps all blocks in the editor) for a more accurate bounding box. const editorBoundingBox = ( @@ -332,7 +323,7 @@ export class BlockNoteEditor< * Gets a snapshot of the current text cursor position. * @returns A snapshot of the current text cursor position. */ - public getTextCursorPosition(): TextCursorPosition { + public getTextCursorPosition(): TextCursorPosition { const { node, depth, startPos, endPos } = getBlockInfoFromPos( this._tiptapEditor.state.doc, this._tiptapEditor.state.selection.from @@ -402,8 +393,8 @@ export class BlockNoteEditor< /** * Gets a snapshot of the current selection. */ - public getSelection(): Selection { - const blocks: Block[] = []; + public getSelection(): Selection { + const blocks: Block[] = []; this._tiptapEditor.state.doc.descendants((node, pos) => { if (node.type.spec.group !== "blockContent") { @@ -440,7 +431,7 @@ export class BlockNoteEditor< * `referenceBlock`. Inserts the blocks at the start of the existing block's children if "nested" is used. */ public insertBlocks( - blocksToInsert: PartialBlockTemplate[], + blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, placement: "before" | "after" | "nested" = "before" ): void { @@ -456,7 +447,7 @@ export class BlockNoteEditor< */ public updateBlock( blockToUpdate: BlockIdentifier, - update: PartialBlockTemplate + update: PartialBlock ) { updateBlock(blockToUpdate, update, this._tiptapEditor); } @@ -478,7 +469,7 @@ export class BlockNoteEditor< */ public replaceBlocks( blocksToRemove: BlockIdentifier[], - blocksToInsert: PartialBlockTemplate[] + blocksToInsert: PartialBlock[] ) { replaceBlocks(blocksToRemove, blocksToInsert, this._tiptapEditor); } @@ -653,7 +644,7 @@ export class BlockNoteEditor< * @param blocks An array of blocks that should be serialized into HTML. * @returns The blocks, serialized as an HTML string. */ - public async blocksToHTML(blocks: Block[]): Promise { + public async blocksToHTML(blocks: Block[]): Promise { return blocksToHTML(blocks, this._tiptapEditor.schema); } @@ -664,7 +655,7 @@ export class BlockNoteEditor< * @param html The HTML string to parse blocks from. * @returns The blocks parsed from the HTML string. */ - public async HTMLToBlocks(html: string): Promise { + public async HTMLToBlocks(html: string): Promise[]> { return HTMLToBlocks(html, this.schema, this._tiptapEditor.schema) as any; // TODO: fix type } @@ -674,7 +665,7 @@ export class BlockNoteEditor< * @param blocks An array of blocks that should be serialized into Markdown. * @returns The blocks, serialized as a Markdown string. */ - public async blocksToMarkdown(blocks: Block[]): Promise { + public async blocksToMarkdown(blocks: Block[]): Promise { return blocksToMarkdown(blocks, this._tiptapEditor.schema); } @@ -685,7 +676,7 @@ export class BlockNoteEditor< * @param markdown The Markdown string to parse blocks from. * @returns The blocks parsed from the Markdown string. */ - public async markdownToBlocks(markdown: string): Promise { + public async markdownToBlocks(markdown: string): Promise[]> { return markdownToBlocks( markdown, this.schema, @@ -694,27 +685,57 @@ export class BlockNoteEditor< } } -// Playground: -/* -let x = new BlockNoteEditor(); // default block types are supported - -// this breaks because "level" is not valid on paragraph -x.updateBlock("", { type: "paragraph", content: "hello", props: { level: "3" } }); -x.updateBlock("", { - type: "heading", - content: "hello", - props: { level: "1" }, -}); - - -let y = new BlockNoteEditor(); - -y.updateBlock("", { type: "paragraph", content: "hello", props: { } }); - -// this breaks because "heading" is not a type on this editor -y.updateBlock("", { - type: "heading", - content: "hello", - props: { level: "1" }, -}); -*/ +// // Playground: +// let x = new BlockNoteEditor(); // default block types are supported +// +// // this breaks because "level" is not valid on paragraph +// x.updateBlock("", { +// type: "paragraph", +// content: "hello", +// props: { level: "1" }, +// }); +// +// x.updateBlock("", { +// type: "heading", +// content: "hello", +// props: { level: "1" }, +// }); +// +// x.updateBlock("", { +// type: "fgrsdgfrd", +// props: {}, +// }); +// +// let y = new BlockNoteEditor<{ +// paragraph: typeof defaultBlockSchema.paragraph; +// }>(); +// +// y.updateBlock("", { type: "paragraph", content: "hello", props: {} }); +// +// // this breaks because "heading" is not a type on this editor +// y.updateBlock("", { +// type: "heading", +// content: "hello", +// props: { level: "1" }, +// }); +// +// let h = new BlockNoteEditor(); + +// class Test { +// word: Word; +// constructor(word: Word = "default") { +// this.word = word; +// } +// } +// +// type TestType = { word: Word }; +// +// function FTest( +// word: Word | "default" = "default" +// ): TestType { +// return { +// word: word, +// }; +// } +// +// const fTest = FTest<>("hello"); diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index 3c0460c408..b18e40f93e 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -16,16 +16,19 @@ import { Underline } from "@tiptap/extension-underline"; import { BackgroundColorExtension } from "./extensions/BackgroundColor/BackgroundColorExtension"; import { BackgroundColorMark } from "./extensions/BackgroundColor/BackgroundColorMark"; import { blocks } from "./extensions/Blocks"; -import { BlockSpec } from "./extensions/Blocks/api/blockTypes"; +import { BlockSchema } from "./extensions/Blocks/api/blockTypes"; import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; import { BlockSideMenuFactory } from "./extensions/DraggableBlocks/BlockSideMenuFactoryTypes"; -import { DraggableBlocksExtension } from "./extensions/DraggableBlocks/DraggableBlocksExtension"; -import { FormattingToolbarExtension } from "./extensions/FormattingToolbar/FormattingToolbarExtension"; +import { createDraggableBlocksExtension } from "./extensions/DraggableBlocks/DraggableBlocksExtension"; +import { createFormattingToolbarExtension } from "./extensions/FormattingToolbar/FormattingToolbarExtension"; import { FormattingToolbarFactory } from "./extensions/FormattingToolbar/FormattingToolbarFactoryTypes"; import HyperlinkMark from "./extensions/HyperlinkToolbar/HyperlinkMark"; import { HyperlinkToolbarFactory } from "./extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes"; import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; -import { BaseSlashMenuItem, SlashMenuExtension } from "./extensions/SlashMenu"; +import { + BaseSlashMenuItem, + createSlashMenuExtension, +} from "./extensions/SlashMenu"; import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension"; import { TextColorExtension } from "./extensions/TextColor/TextColorExtension"; import { TextColorMark } from "./extensions/TextColor/TextColorMark"; @@ -33,21 +36,21 @@ import { TrailingNode } from "./extensions/TrailingNode/TrailingNodeExtension"; import UniqueID from "./extensions/UniqueID/UniqueID"; import { SuggestionsMenuFactory } from "./shared/plugins/suggestion/SuggestionsMenuFactoryTypes"; -export type UiFactories = Partial<{ - formattingToolbarFactory: FormattingToolbarFactory; +export type UiFactories = Partial<{ + formattingToolbarFactory: FormattingToolbarFactory; hyperlinkToolbarFactory: HyperlinkToolbarFactory; - slashMenuFactory: SuggestionsMenuFactory; - blockSideMenuFactory: BlockSideMenuFactory; + slashMenuFactory: SuggestionsMenuFactory>; + blockSideMenuFactory: BlockSideMenuFactory; }>; /** * Get all the Tiptap extensions BlockNote is configured with by default */ -export const getBlockNoteExtensions = (opts: { - editor: BlockNoteEditor; - uiFactories: UiFactories; - slashCommands: BaseSlashMenuItem[]; - blocks: BlockSpec[]; +export const getBlockNoteExtensions = (opts: { + editor: BlockNoteEditor; + uiFactories: UiFactories; + slashCommands: BaseSlashMenuItem[]; + blocks: BSchema; }) => { const ret: Extensions = [ extensions.ClipboardTextSerializer, @@ -90,7 +93,9 @@ export const getBlockNoteExtensions = (opts: { // custom blocks: ...blocks, - ...opts.blocks.map((b) => b.node), + ...Object.values(opts.blocks).map((blockSpec) => + blockSpec.node.configure({ editor: opts.editor }) + ), Dropcursor.configure({ width: 5, color: "#ddeeff" }), History, @@ -101,7 +106,7 @@ export const getBlockNoteExtensions = (opts: { if (opts.uiFactories.blockSideMenuFactory) { ret.push( - DraggableBlocksExtension.configure({ + createDraggableBlocksExtension().configure({ editor: opts.editor, blockSideMenuFactory: opts.uiFactories.blockSideMenuFactory, }) @@ -110,7 +115,7 @@ export const getBlockNoteExtensions = (opts: { if (opts.uiFactories.formattingToolbarFactory) { ret.push( - FormattingToolbarExtension.configure({ + createFormattingToolbarExtension().configure({ editor: opts.editor, formattingToolbarFactory: opts.uiFactories.formattingToolbarFactory, }) @@ -129,7 +134,7 @@ export const getBlockNoteExtensions = (opts: { if (opts.uiFactories.slashMenuFactory) { ret.push( - SlashMenuExtension.configure({ + createSlashMenuExtension().configure({ editor: opts.editor, commands: opts.slashCommands, slashMenuFactory: opts.uiFactories.slashMenuFactory, diff --git a/packages/core/src/api/blockManipulation/blockManipulation.test.ts b/packages/core/src/api/blockManipulation/blockManipulation.test.ts index e52c46256c..d869304649 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.test.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Block, BlockNoteEditor, PartialBlock } from "../.."; +import { DefaultBlockSchema } from "../../extensions/Blocks/api/defaultBlocks"; let editor: BlockNoteEditor; @@ -14,11 +15,13 @@ function waitForEditor() { }); } -let singleBlock: PartialBlock; +let singleBlock: PartialBlock; -let multipleBlocks: PartialBlock[]; +let multipleBlocks: PartialBlock[]; -let insert: (placement: "before" | "nested" | "after") => Block[]; +let insert: ( + placement: "before" | "nested" | "after" +) => Block[]; beforeEach(() => { (window as Window & { __TEST_OPTIONS?: {} }).__TEST_OPTIONS = {}; diff --git a/packages/core/src/api/blockManipulation/blockManipulation.ts b/packages/core/src/api/blockManipulation/blockManipulation.ts index 6e9350400b..f49729475a 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.ts @@ -2,14 +2,14 @@ import { Editor } from "@tiptap/core"; import { Node } from "prosemirror-model"; import { BlockIdentifier, - BlockTemplate, - PartialBlockTemplate, + BlockSchema, + PartialBlock, } from "../../extensions/Blocks/api/blockTypes"; import { blockToNode } from "../nodeConversions/nodeConversions"; import { getNodeById } from "../util/nodeUtil"; -export function insertBlocks>( - blocksToInsert: PartialBlockTemplate[], +export function insertBlocks( + blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, placement: "before" | "after" | "nested" = "before", editor: Editor @@ -57,9 +57,9 @@ export function insertBlocks>( editor.view.dispatch(editor.state.tr.insert(insertionPos, nodesToInsert)); } -export function updateBlock>( +export function updateBlock( blockToUpdate: BlockIdentifier, - update: PartialBlockTemplate, + update: PartialBlock, editor: Editor ) { const id = @@ -116,9 +116,9 @@ export function removeBlocks( } } -export function replaceBlocks>( +export function replaceBlocks( blocksToRemove: BlockIdentifier[], - blocksToInsert: PartialBlockTemplate[], + blocksToInsert: PartialBlock[], editor: Editor ) { insertBlocks(blocksToInsert, blocksToRemove[0], "before", editor); diff --git a/packages/core/src/api/formatConversions/formatConversions.test.ts b/packages/core/src/api/formatConversions/formatConversions.test.ts index ef390dad25..cd883cd947 100644 --- a/packages/core/src/api/formatConversions/formatConversions.test.ts +++ b/packages/core/src/api/formatConversions/formatConversions.test.ts @@ -1,22 +1,23 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Block, BlockNoteEditor } from "../.."; import UniqueID from "../../extensions/UniqueID/UniqueID"; +import { DefaultBlockSchema } from "../../extensions/Blocks/api/defaultBlocks"; let editor: BlockNoteEditor; -let nonNestedBlocks: Block[]; +let nonNestedBlocks: Block[]; let nonNestedHTML: string; let nonNestedMarkdown: string; -let nestedBlocks: Block[]; +let nestedBlocks: Block[]; // let nestedHTML: string; // let nestedMarkdown: string; -let styledBlocks: Block[]; +let styledBlocks: Block[]; let styledHTML: string; let styledMarkdown: string; -let complexBlocks: Block[]; +let complexBlocks: Block[]; // let complexHTML: string; // let complexMarkdown: string; diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts index f18a83e0c2..45aa3e46d4 100644 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ b/packages/core/src/api/formatConversions/formatConversions.ts @@ -7,17 +7,14 @@ import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import remarkStringify from "remark-stringify"; import { unified } from "unified"; -import { - BlockSpec, - BlockTemplate, -} from "../../extensions/Blocks/api/blockTypes"; +import { Block, BlockSchema } from "../../extensions/Blocks/api/blockTypes"; import { blockToNode, nodeToBlock } from "../nodeConversions/nodeConversions"; import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; import { simplifyBlocks } from "./simplifyBlocksRehypePlugin"; -export async function blocksToHTML( - blocks: BlockTemplate[], +export async function blocksToHTML( + blocks: Block[], schema: Schema ): Promise { const htmlParentElement = document.createElement("div"); @@ -41,18 +38,18 @@ export async function blocksToHTML( return htmlString.value as string; } -export async function HTMLToBlocks( +export async function HTMLToBlocks( html: string, - schema: Map, + schema: BSchema, pmSchema: Schema -): Promise[]> { +): Promise[]> { const htmlNode = document.createElement("div"); htmlNode.innerHTML = html.trim(); const parser = DOMParser.fromSchema(pmSchema); const parentNode = parser.parse(htmlNode); - const blocks: BlockTemplate[] = []; + const blocks: Block[] = []; for (let i = 0; i < parentNode.firstChild!.childCount; i++) { blocks.push(nodeToBlock(parentNode.firstChild!.child(i), schema)); @@ -61,8 +58,8 @@ export async function HTMLToBlocks( return blocks; } -export async function blocksToMarkdown( - blocks: BlockTemplate[], +export async function blocksToMarkdown( + blocks: Block[], schema: Schema ): Promise { const markdownString = await unified() @@ -76,11 +73,11 @@ export async function blocksToMarkdown( return markdownString.value as string; } -export async function markdownToBlocks( +export async function markdownToBlocks( markdown: string, - schema: Map, + schema: BSchema, pmSchema: Schema -): Promise[]> { +): Promise[]> { const htmlString = await unified() .use(remarkParse) .use(remarkGfm) diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts index 9b14d95648..6739936d14 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts @@ -5,14 +5,18 @@ import { BlockNoteEditor, PartialBlock } from "../.."; import UniqueID from "../../extensions/UniqueID/UniqueID"; import { blockToNode, nodeToBlock } from "./nodeConversions"; import { partialBlockToBlockForTesting } from "./testUtil"; +import { + defaultBlockSchema, + DefaultBlockSchema, +} from "../../extensions/Blocks/api/defaultBlocks"; let editor: BlockNoteEditor; let tt: Editor; -let simpleBlock: PartialBlock; +let simpleBlock: PartialBlock; let simpleNode: Node; -let complexBlock: PartialBlock; +let complexBlock: PartialBlock; let complexNode: Node; beforeEach(() => { @@ -119,7 +123,10 @@ describe("Simple ProseMirror Node Conversions", () => { }); it("Convert simple node to block", async () => { - const firstBlockConversion = nodeToBlock(simpleNode); + const firstBlockConversion = nodeToBlock( + simpleNode, + defaultBlockSchema + ); expect(firstBlockConversion).toMatchSnapshot(); @@ -137,7 +144,10 @@ describe("Complex ProseMirror Node Conversions", () => { }); it("Convert complex node to block", async () => { - const firstBlockConversion = nodeToBlock(complexNode); + const firstBlockConversion = nodeToBlock( + complexNode, + defaultBlockSchema + ); expect(firstBlockConversion).toMatchSnapshot(); @@ -149,7 +159,7 @@ describe("Complex ProseMirror Node Conversions", () => { describe("links", () => { it("Convert a block with link", async () => { - const block: PartialBlock = { + const block: PartialBlock = { id: UniqueID.options.generateID(), type: "paragraph", content: [ @@ -162,7 +172,10 @@ describe("links", () => { }; const node = blockToNode(block, tt.schema); expect(node).toMatchSnapshot(); - const outputBlock = nodeToBlock(node); + const outputBlock = nodeToBlock( + node, + defaultBlockSchema + ); // Temporary fix to set props to {}, because at this point // we don't have an easy way to access default props at runtime, @@ -174,7 +187,7 @@ describe("links", () => { }); it("Convert link block with marks", async () => { - const block: PartialBlock = { + const block: PartialBlock = { id: UniqueID.options.generateID(), type: "paragraph", content: [ @@ -200,7 +213,10 @@ describe("links", () => { }; const node = blockToNode(block, tt.schema); // expect(node).toMatchSnapshot(); - const outputBlock = nodeToBlock(node); + const outputBlock = nodeToBlock( + node, + defaultBlockSchema + ); // Temporary fix to set props to {}, because at this point // we don't have an easy way to access default props at runtime, @@ -212,7 +228,7 @@ describe("links", () => { }); it("Convert two adjacent links in a block", async () => { - const block: PartialBlock = { + const block: PartialBlock = { id: UniqueID.options.generateID(), type: "paragraph", content: [ @@ -231,7 +247,10 @@ describe("links", () => { const node = blockToNode(block, tt.schema); expect(node).toMatchSnapshot(); - const outputBlock = nodeToBlock(node); + const outputBlock = nodeToBlock( + node, + defaultBlockSchema + ); // Temporary fix to set props to {}, because at this point // we don't have an easy way to access default props at runtime, diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index 57f7681f8d..a3092b27b0 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -1,9 +1,9 @@ import { Mark } from "@tiptap/pm/model"; import { Node, Schema } from "prosemirror-model"; import { - BlockSpec, - BlockTemplate, - PartialBlockTemplate, + Block, + BlockSchema, + PartialBlock, } from "../../extensions/Blocks/api/blockTypes"; import { @@ -105,8 +105,8 @@ export function inlineContentToNodes( /** * Converts a BlockNote block to a TipTap node. */ -export function blockToNode>( - block: PartialBlockTemplate, +export function blockToNode( + block: PartialBlock, schema: Schema ) { let id = block.id; @@ -217,11 +217,11 @@ function contentNodeToInlineContent(contentNode: Node) { /** * Convert a TipTap node to a BlockNote block. */ -export function nodeToBlock>( +export function nodeToBlock( node: Node, - schema: Map, - blockCache?: WeakMap -): Block { + schema: BSchema, + blockCache?: WeakMap> +): Block { if (node.type.name !== "blockContainer") { throw Error( "Node must be of type blockContainer, but is of type" + @@ -250,16 +250,16 @@ export function nodeToBlock>( ...blockInfo.node.attrs, ...blockInfo.contentNode.attrs, })) { - const blockSpec = schema.get(blockInfo.contentType.name); + const blockSpec = schema[blockInfo.contentType.name]; if (!blockSpec) { throw Error( "Block is of an unrecognized type: " + blockInfo.contentType.name ); } - const validAttrs = blockSpec.acceptedProps; + const propSchema = blockSpec.propSchema; - if (validAttrs.find((prop) => prop.name === attr)) { + if (attr in propSchema) { props[attr] = value; } else { console.warn("Block has an unrecognized attribute: " + attr); @@ -268,7 +268,7 @@ export function nodeToBlock>( const content = contentNodeToInlineContent(blockInfo.contentNode); - const children: Block[] = []; + const children: Block[] = []; for (let i = 0; i < blockInfo.numChildBlocks; i++) { children.push( nodeToBlock(blockInfo.node.lastChild!.child(i), schema, blockCache) @@ -278,7 +278,7 @@ export function nodeToBlock>( // TODO: fix types const block: any = { id, - type: blockInfo.contentType.name as Block["type"], + type: blockInfo.contentType.name as Block["type"], props, content, children, diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts index 69531e2fc9..272121ba01 100644 --- a/packages/core/src/api/nodeConversions/testUtil.ts +++ b/packages/core/src/api/nodeConversions/testUtil.ts @@ -1,4 +1,8 @@ -import { Block, PartialBlock } from "../../extensions/Blocks/api/blockTypes"; +import { + Block, + BlockSchema, + PartialBlock, +} from "../../extensions/Blocks/api/blockTypes"; import { InlineContent, PartialInlineContent, @@ -39,9 +43,9 @@ function partialContentToInlineContent( }); } -export function partialBlockToBlockForTesting( - partialBlock: PartialBlock -): Block { +export function partialBlockToBlockForTesting( + partialBlock: PartialBlock +): Block { const withDefaults = { id: "", type: "paragraph" as any, diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index 9fb2f6a7b4..190ae4f408 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -1,90 +1,78 @@ import { Attribute, Node } from "@tiptap/core"; -import { PropsFromPropSpec, PropSpec } from "./blockTypes"; +import { + Block, + BlockConfig, + BlockSchema, + BlockSpec, + PropSchema, + TipTapNode, + TipTapNodeConfig, +} from "./blockTypes"; +import { BlockNoteEditor } from "../../../BlockNoteEditor"; function camelToDataKebab(str: string): string { return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); } -// function dataKebabToCamel(str: string): string { -// const withoutData = str.replace(/^data-/, ""); -// return withoutData.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); -// } - // A function to create a "BlockSpec" from a tiptap node. // we use this to create the block specs for the built-in blocks -// TODO: rename to createBlockSpecFromTiptapNode? -export function createBlockFromTiptapNode< - Type extends string, - Props extends readonly PropSpec[] ->( - blockType: Type, - options: { - props: Props; - }, - node: Node -) { - if (node.name !== blockType) { - throw Error( - "Node must be of type " + blockType + ", but is of type" + node.name + "." - ); - } - - // TODO: how to handle markdown / html conversions - - // the return type gives us runtime access to the block name, props, and tiptap node - // but is also used to generate (derive) the type for the block spec - // so that we can have a strongly typed BlockNoteEditor API - return { - type: blockType, - node, - // TODO: rename to propSpec? - acceptedProps: options.props, - }; -} +// // TODO: rename to createBlockSpecFromTiptapNode? +// export function createBlockFromTiptapNode< +// Type extends string, +// Props extends PropSpecs +// >(blockSpec: BlockSpecWithNode): BlockSpecWithNode { +// if (blockSpec.node.name !== blockSpec.type) { +// throw Error( +// "Node must be of type " + +// blockSpec.type + +// ", but is of type" + +// blockSpec.node.name + +// "." +// ); +// } +// +// // TODO: how to handle markdown / html conversions +// +// // the return type gives us runtime access to the block name, props, and tiptap node +// // but is also used to generate (derive) the type for the block spec +// // so that we can have a strongly typed BlockNoteEditor API +// return blockSpec; +// } // 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 createCustomBlock< - Type extends string, - Props extends readonly PropSpec[] +export function createBlockSpec< + BType extends string, + PSchema extends PropSchema, + ContainsInlineContent extends boolean, + BSchema extends BlockSchema >( - blockType: Type, - options: ( - | { - // for blocks with a single inline content element - inlineContent: true; - render: () => { dom: HTMLElement; contentDOM: HTMLElement }; - } - | { - // for custom blocks that don't support content - inlineContent: false; - render: () => { dom: HTMLElement }; - } - ) & { - props: Props; - parseHTML?: (element: HTMLElement) => PropsFromPropSpec; - // todo: possibly add parseDom options / other options we need - } -) { - const node = Node.create({ - name: blockType, - group: "blockContent", - content: options.inlineContent ? "inline*" : "", + blockConfig: BlockConfig +): BlockSpec | undefined }> { + const node = createTipTapBlock< + BType, + { editor: BlockNoteEditor | undefined } + >({ + name: blockConfig.type, + content: blockConfig.containsInlineContent ? "inline*" : "", + selectable: blockConfig.containsInlineContent, addAttributes() { const tiptapAttributes: Record = {}; - Object.values(options.props).forEach((propSpec) => { - tiptapAttributes[propSpec.name] = { - default: propSpec.default, - keepOnSplit: false, - parseHTML: (element) => - element.getAttribute(camelToDataKebab(propSpec.name)), + Object.entries(blockConfig.propSchema).forEach(([name, spec]) => { + tiptapAttributes[name] = { + default: spec.default, + 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[propSpec.name] !== propSpec.default + attributes[name] !== spec.default ? { - [camelToDataKebab(propSpec.name)]: attributes[propSpec.name], + [camelToDataKebab(name)]: attributes[name], } : {}, }; @@ -93,36 +81,148 @@ export function createCustomBlock< return tiptapAttributes; }, + addOptions() { + return { + editor: undefined, + }; + }, + parseHTML() { // TODO: This won't work for content copied outside BlockNote. Given the // variety of possible custom block types, a one-size-fits-all solution // probably won't work and we'll need an optional parseHTML option. - return [ - { - tag: "div[data-content-type=" + blockType + "]", - }, - ]; + return blockConfig.parse + ? [ + { + getAttrs: (node: HTMLElement | string) => { + if (typeof node === "string") { + return false; + } + + return blockConfig.parse!(node); + }, + }, + ] + : [ + { + tag: "div[data-content-type=" + blockConfig.type + "]", + }, + ]; }, - // TODO, create node from render / inlineContent / other props from options renderHTML({ HTMLAttributes }) { // Create blockContent element const blockContent = document.createElement("div"); // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", blockType); - // Add props as HTML attributes + blockContent.setAttribute("data-content-type", blockConfig.type); + // Add props as HTML attributes in kebab-case with "data-" prefix for (const [attribute, value] of Object.entries(HTMLAttributes)) { blockContent.setAttribute(attribute, value); } - // Render content - const rendered = options.render(); - // TODO: Should we always assume contentDOM is always a descendant of dom? - // Add content to blockContent element - blockContent.appendChild(rendered.dom); - return blockContent; + // TODO: This only works for content copied within BlockNote. + // Creates contentDOM element to serialize inline content into. + let contentDOM: HTMLDivElement | undefined; + if (blockConfig.containsInlineContent) { + contentDOM = document.createElement("div"); + blockContent.appendChild(contentDOM); + } else { + contentDOM = undefined; + } + + // Alternative approach to serializing the block. + // // Gets BlockNote editor instance + // const editor = this.options.editor!; + // + // // Quite hacky but don't think there's a better way to do this. Since the + // // contentDOM can be anywhere inside the DOM, we don't know which element + // // it is. Calling render() will give us the contentDOM, but we need to + // // provide a block as a parameter. + // const getDummyBlock: () => Block = () => + // ({ + // id: "", + // type: "", + // props: {}, + // content: [], + // children: [], + // } as Block); + // + // // Render elements + // const rendered = blockConfig.render(getDummyBlock, editor); + // // Add elements to blockContent + // blockContent.appendChild(rendered.dom); + // + // const contentDOM = blockConfig.containsInlineContent + // ? rendered.contentDOM + // : undefined; + + return { + dom: blockContent, + contentDOM: contentDOM, + }; + }, + + addNodeView() { + return ({ HTMLAttributes, getPos }) => { + // Create blockContent element + const blockContent = document.createElement("div"); + // Add blockContent HTML attribute + blockContent.setAttribute("data-content-type", blockConfig.type); + // Add props as HTML attributes in kebab-case with "data-" prefix + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + blockContent.setAttribute(attribute, value); + } + + // Gets BlockNote editor instance + const editor = this.options.editor!; + // Gets position of the node + const pos = typeof getPos === "function" ? getPos() : undefined; + // Gets TipTap editor instance + const tipTapEditor = editor._tiptapEditor; + // Gets parent blockContainer node + const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); + // Gets block identifier + const blockIdentifier = blockContainer.attrs.id; + // Function to get the block + const getBlock: () => Block = () => + editor.getBlock(blockIdentifier)!; + + // Render elements + const rendered = blockConfig.render(getBlock, editor); + // Add elements to blockContent + blockContent.appendChild(rendered.dom); + + return { + dom: blockContent, + // I don't understand what's going on with the typing here + contentDOM: + "contentDOM" in rendered + ? (rendered.contentDOM as HTMLDivElement) + : undefined, + }; + }; }, }); - return createBlockFromTiptapNode(blockType, { props: options.props }, node); + return { + node: node, + propSchema: blockConfig.propSchema, + }; +} + +export function createTipTapBlock< + Type extends string, + Options = any, + Storage = any +>( + config: TipTapNodeConfig +): TipTapNode { + // Type cast is needed as Node.name is mutable, though there is basically no + // reason to change it after creation. Alternative is to wrap Node in a new + // class, which I don't think is worth it since we'd only be changing 1 + // attribute to be read only. + return Node.create({ + ...config, + group: "blockContent", + }) as TipTapNode; } diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index e6bdb7e370..194faabd8e 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -1,95 +1,155 @@ /** Define the main block types **/ - -import { createBlockFromTiptapNode } from "./block"; +import { Node, NodeConfig } from "@tiptap/core"; import { InlineContent, PartialInlineContent } from "./inlineContentTypes"; +import { BlockNoteEditor } from "../../../BlockNoteEditor"; -// the type of a block exposed to API consumers -export type BlockTemplate< - // Type of the block. - // Examples might include: "paragraph", "heading", or "bulletListItem". - Type extends string, - // Changeable props which affect the block's behaviour or appearance. - // An example might be: { textAlignment: "left" | "right" | "center" } for a paragraph block. - Props extends Record +// A configuration for a TipTap node, but with stricter type constraints on the +// "name" and "group" properties. The "name" property is now always a string +// literal type, and the "blockGroup" property cannot be configured as it should +// always be "blockContent". Used as the parameter in `createTipTapNode`. +export type TipTapNodeConfig< + Name extends string, + Options = any, + Storage = any > = { - id: string; - type: Type; - props: Props; - content: InlineContent[]; + [K in keyof NodeConfig]: K extends "name" + ? Name + : K extends "group" + ? never + : NodeConfig[K]; +}; + +// A TipTap node with stricter type constraints on the "name" and "group" +// properties. The "name" property is now a string literal type, and the +// "blockGroup" property is now "blockContent". Returned by `createTipTapNode`. +export type TipTapNode< + Name extends string, + Options = any, + Storage = any +> = Node & { + name: Name; + group: "blockContent"; }; -// information about a blocks props when defining Block types +// Defines a single prop spec, which includes the default value the prop should +// take and possible values it can take. export type PropSpec = { - name: string; values?: readonly string[]; default: string; }; -// define the default Props -export const defaultBlockProps = [ - { - name: "backgroundColor", - default: "transparent", // TODO think if this makes sense - }, - { - name: "textColor", - default: "black", // TODO - }, - { - name: "textAlignment", - values: ["left", "center", "right", "justify"], - default: "left", - }, -] as const; // TODO: upgrade typescript and use satisfies PropSpec +// 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 = { + [PType in keyof PSchema]: PSchema[PType]["values"] extends readonly string[] + ? PSchema[PType]["values"][number] + : string; +}; + +// 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 +// main way we expect people to create custom blocks as consumers don't need to +// know anything about the TipTap API since the associated nodes are created +// automatically. +export type BlockConfig< + Type extends string, + PSchema extends PropSchema, + ContainsInlineContent extends boolean, + BSchema extends BlockSchema +> = { + // Attributes to define block in the API as well as a TipTap node. + type: Type; + readonly propSchema: PSchema; -export type DefaultBlockPropsType = PropsFromPropSpec; + // Additional attributes to help define block as a TipTap node. + containsInlineContent: ContainsInlineContent; + parse?: (element: HTMLElement) => Props; + render: ( + block: () => Block, + editor: BlockNoteEditor + ) => { + dom: HTMLElement; + contentDOM: ContainsInlineContent extends true ? HTMLElement : undefined; + }; +}; -export type BlockIdentifier = string | { id: string }; +// Defines a single block spec, which includes the props that the block has and +// the TipTap node used to implement it. Can also be used to define more advanced +// custom blocks when used as an argument to `createBlockSpec` as consumers can +// create the associated nodes themselves. +export type BlockSpec< + Type extends string, + PSchema extends PropSchema, + Options = any, + Storage = any +> = { + readonly propSchema: PSchema; + node: TipTapNode; +}; -/** Define "Partial Blocks", these are for updating or creating blocks */ -export type PartialBlockTemplate> = - B extends BlockTemplate - ? Partial> & { - type?: B["type"]; - props?: Partial; - content?: string | PartialInlineContent[]; - children?: PartialBlockTemplate[]; - } +// Utility type. For a given object block schema, ensures that the key of each +// block spec matches the name of the TipTap node in it. +export type TypesMatch< + Blocks extends Record> +> = Blocks extends { + [Type in keyof Blocks]: Type extends string + ? Blocks[Type] extends BlockSpec + ? Blocks[Type] + : never : never; +} + ? Blocks + : never; -// export type BlockPropsTemplate< -// B extends BlockTemplate, -// Props -// > = Props extends PartialBlockTemplate["props"] ? keyof Props : never; +// Defines multiple block specs. Also ensures that the key of each block schema +// is the same as name of the TipTap node in it. This should be passed in the +// `blocks` option of the BlockNoteEditor. From a block schema, we can derive +// both the blocks' internal implementation (as TipTap nodes) and the type +// information for the external API. +export type BlockSchema = TypesMatch< + Record> +>; -// ExtractElement is a utility typ for PropsFromPropSpec that extracts the element with a specific key K from a union type T -// Example: ExtractElement<{ name: "level"; values: readonly ["warn", "error"]; }, "level"> will result in -// { name: "level"; values: readonly ["warn", "error"]; } -type ExtractElement = T extends { name: K } ? T : never; +// Converts each block spec into a Block object without children. We later merge +// them into a union type and add a children property to create the Block and +// PartialBlock objects we use in the external API. +type BlocksWithoutChildren = { + [BType in keyof BSchema]: { + id: string; + type: BType; + props: Props; + content: InlineContent[]; + }; +}; -// ConfigValue is a utility type for PropsFromPropSpec that gets the value type from an object T. -// If T has a `values` property, it uses the element type of the tuple (indexed by `number`), -// otherwise, it defaults to `string`. -// Example: ConfigValue<{ values: readonly ["warn", "error"] }> will result in "warn" | "error" -type ConfigValue = T extends { values: readonly any[] } - ? T["values"][number] - : string; +// Converts each block spec into a Block object without children, merges them +// into a union type, and adds a children property +export type Block = + BlocksWithoutChildren[keyof BlocksWithoutChildren] & { + children: Block[]; + }; -// PropsFromPropSpec is a mapped type that iterates over the keys (names) in the PropSpec array and constructs a -// new object type with properties corresponding to the keys in the PropSpec array and their value types. -// Example: With the provided PropSpec array: -// let config = [{ name: "level", values: ["warn", "error"] }, { name: "triggerOn", values: ["startup", "shutdown"] }, { name: "anystring" }] as const; -// PropsFromPropSpec will result in -// { level: "warn" | "error", triggerOn: "startup" | "shutdown", anystring: string } -export type PropsFromPropSpec = { - [K in T[number]["name"]]: ConfigValue>; +type PartialBlocksWithoutChildren = { + [BType in keyof BSchema]: Partial<{ + id: string; + type: BType; + props: Partial>; + content: PartialInlineContent[] | string; + }>; }; -// the return type of createBlockFromTiptapNode -export type BlockSpec = ReturnType; +export type PartialBlock = + PartialBlocksWithoutChildren[keyof PartialBlocksWithoutChildren] & + Partial<{ + children: PartialBlock[]; + }>; -// create the Block type from registererd block types (BlockSpecs) -export type BlockFromBlockSpec = BlockTemplate< - T["type"], - PropsFromPropSpec ->; +export type BlockIdentifier = { id: string } | string; diff --git a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts index f55e7b303c..18d2ba8e61 100644 --- a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts +++ b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts @@ -1,13 +1,13 @@ -import { Block } from "./blockTypes"; +import { Block, BlockSchema } from "./blockTypes"; -export type MouseCursorPosition = { - block: Block; +export type MouseCursorPosition = { + block: Block; // prevBlock: Block | undefined; // nextBlock: Block | undefined; }; -export type TextCursorPosition = { - block: Block; - prevBlock: Block | undefined; - nextBlock: Block | undefined; +export type TextCursorPosition = { + block: Block; + prevBlock: Block | undefined; + nextBlock: Block | undefined; }; diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index b708d79cf2..58825c512e 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -2,73 +2,58 @@ import { HeadingBlockContent } from "../nodes/BlockContent/HeadingBlockContent/H import { BulletListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { ParagraphBlockContent } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -import { createBlockFromTiptapNode } from "./block"; -import { BlockFromBlockSpec, defaultBlockProps } from "./blockTypes"; +import { createBlockSpec } from "./block"; +import { TypesMatch } from "./blockTypes"; -// this file defines the default blocks that are available in the editor -// and their types (Block types) - -export const NumberedListItemBlock = createBlockFromTiptapNode( - "numberedListItem", - { - props: defaultBlockProps, +export const defaultProps = { + backgroundColor: { + default: "transparent" as const, }, - NumberedListItemBlockContent -); - -export type NumberedListItemBlockType = BlockFromBlockSpec< - typeof NumberedListItemBlock ->; - -export const BulletListItemBlock = createBlockFromTiptapNode( - "bulletListItem", - { - props: defaultBlockProps, + textColor: { + default: "black" as const, // TODO }, - BulletListItemBlockContent -); - -export type BulletListItemBlockType = BlockFromBlockSpec< - typeof BulletListItemBlock ->; - -// TODO: rename to HeadingBlockSpec? -export const HeadingBlock = createBlockFromTiptapNode( - "heading", - { - // TODO: rename to propSpec? - props: [ - ...defaultBlockProps, - { - name: "level", - values: ["1", "2", "3"], - default: "1", - }, - ] as const, + textAlignment: { + default: "left" as const, + values: ["left", "center", "right", "justify"] as const, }, - HeadingBlockContent -); - -export type HeadingBlockType = BlockFromBlockSpec; +} as const; // TODO: upgrade typescript and use satisfies PropSpec -export const ParagraphBlock = createBlockFromTiptapNode( - "paragraph", - { - props: defaultBlockProps, +export const defaultBlockSchema = { + paragraph: { + propSchema: defaultProps, + node: ParagraphBlockContent, }, - ParagraphBlockContent -); - -export type ParagraphBlockType = BlockFromBlockSpec; - -export const defaultBlocks = [ - ParagraphBlock, - NumberedListItemBlock, - BulletListItemBlock, - HeadingBlock, -]; -export type DefaultBlockTypes = - | NumberedListItemBlockType - | BulletListItemBlockType - | HeadingBlockType - | ParagraphBlockType; + heading: { + propSchema: { + ...defaultProps, + level: { default: "1", values: ["1", "2", "3"] as const }, + }, + node: HeadingBlockContent, + }, + bulletListItem: { + propSchema: defaultProps, + node: BulletListItemBlockContent, + }, + numberedListItem: { + propSchema: defaultProps, + node: NumberedListItemBlockContent, + }, +} as const; + +const imageProps = { src: { default: "gfr" } } as const; +export const onlyImageBlockSchema = { + image: createBlockSpec({ + type: "image", + propSchema: imageProps, + containsInlineContent: false, + render: (block) => { + const img = document.createElement("img"); + img.setAttribute("src", block().props.src); + return { dom: img }; + }, + }), +} as const; + +export type DefaultBlockSchema = TypesMatch; + +// export type DefaultBlocks = Block; diff --git a/packages/core/src/extensions/Blocks/api/selectionTypes.ts b/packages/core/src/extensions/Blocks/api/selectionTypes.ts index 9fbd40d6b6..8a23f48094 100644 --- a/packages/core/src/extensions/Blocks/api/selectionTypes.ts +++ b/packages/core/src/extensions/Blocks/api/selectionTypes.ts @@ -1,5 +1,5 @@ -import { Block } from "./blockTypes"; +import { Block, BlockSchema } from "./blockTypes"; -export type Selection = { - blocks: Block[]; +export type Selection = { + blocks: Block[]; }; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index cc3339d392..5911b819a7 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -5,12 +5,12 @@ import { blockToNode, inlineContentToNodes, } from "../../../api/nodeConversions/nodeConversions"; -import { PartialBlockTemplate } from "../api/blockTypes"; import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos"; import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; import styles from "./Block.module.css"; import BlockAttributes from "./BlockAttributes"; +import { BlockSchema, PartialBlock } from "../api/blockTypes"; // TODO export interface IBlock { @@ -24,13 +24,13 @@ declare module "@tiptap/core" { BNDeleteBlock: (posInBlock: number) => ReturnType; BNMergeBlocks: (posBetweenBlocks: number) => ReturnType; BNSplitBlock: (posInBlock: number, keepType: boolean) => ReturnType; - BNUpdateBlock: ( + BNUpdateBlock: ( posInBlock: number, - block: PartialBlockTemplate + block: PartialBlock ) => ReturnType; - BNCreateOrUpdateBlock: ( + BNCreateOrUpdateBlock: ( posInBlock: number, - block: PartialBlockTemplate + block: PartialBlock ) => ReturnType; }; } @@ -499,7 +499,6 @@ export const BlockContainer = Node.create({ chain() .BNCreateBlock(newBlockInsertionPos) - // .BNUpdateBlock(newBlockContentPos, { type: "table" }) .setTextSelection(newBlockContentPos) .run(); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts index 5a92f696b8..ddb47980bb 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts @@ -1,9 +1,9 @@ -import { InputRule, mergeAttributes, Node } from "@tiptap/core"; +import { InputRule, mergeAttributes } from "@tiptap/core"; import styles from "../../Block.module.css"; +import { createTipTapBlock } from "../../../api/block"; -export const HeadingBlockContent = Node.create({ +export const HeadingBlockContent = createTipTapBlock<"heading">({ name: "heading", - group: "blockContent", content: "inline*", addAttributes() { diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index a3735ecd9b..0c256c9af8 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -1,10 +1,10 @@ -import { InputRule, mergeAttributes, Node } from "@tiptap/core"; +import { InputRule, mergeAttributes } from "@tiptap/core"; import styles from "../../../Block.module.css"; import { handleEnter } from "../ListItemKeyboardShortcuts"; +import { createTipTapBlock } from "../../../../api/block"; -export const BulletListItemBlockContent = Node.create({ +export const BulletListItemBlockContent = createTipTapBlock<"bulletListItem">({ name: "bulletListItem", - group: "blockContent", content: "inline*", addInputRules() { diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index bb4627d7b2..91e2671136 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -1,117 +1,124 @@ -import { InputRule, mergeAttributes, Node } from "@tiptap/core"; +import { InputRule, mergeAttributes } from "@tiptap/core"; import styles from "../../../Block.module.css"; import { handleEnter } from "../ListItemKeyboardShortcuts"; import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin"; +import { createTipTapBlock } from "../../../../api/block"; -export const NumberedListItemBlockContent = Node.create({ - name: "numberedListItem", - group: "blockContent", - content: "inline*", +// TODO: Something about defining addProseMirrorPlugins causes issues when +// inferring the `Type` type parameter. Not a big deal as it's fixed by +// explicitly setting it to "numberedListItem", but still weird. Issue goes +// away if we leave `addProseMirrorPlugins` undefined. +export const NumberedListItemBlockContent = + createTipTapBlock<"numberedListItem">({ + name: "numberedListItem", + content: "inline*", - addAttributes() { - return { - index: { - default: null, - parseHTML: (element) => element.getAttribute("data-index"), - renderHTML: (attributes) => { - return { - "data-index": attributes.index, - }; + addAttributes() { + return { + index: { + default: null, + parseHTML: (element) => element.getAttribute("data-index"), + renderHTML: (attributes) => { + return { + "data-index": attributes.index, + }; + }, }, - }, - }; - }, + }; + }, - addInputRules() { - return [ - // Creates an ordered list when starting with "1.". - new InputRule({ - find: new RegExp(`^1\\.\\s$`), - handler: ({ state, chain, range }) => { - chain() - .BNUpdateBlock(state.selection.from, { - type: "numberedListItem", - props: {}, - }) - // Removes the "1." characters used to set the list. - .deleteRange({ from: range.from, to: range.to }); - }, - }), - ]; - }, + addInputRules() { + return [ + // Creates an ordered list when starting with "1.". + new InputRule({ + find: new RegExp(`^1\\.\\s$`), + handler: ({ state, chain, range }) => { + chain() + .BNUpdateBlock(state.selection.from, { + type: "numberedListItem", + props: {}, + }) + // Removes the "1." characters used to set the list. + .deleteRange({ from: range.from, to: range.to }); + }, + }), + ]; + }, - addKeyboardShortcuts() { - return { - Enter: () => handleEnter(this.editor), - }; - }, + addKeyboardShortcuts() { + return { + Enter: () => handleEnter(this.editor), + }; + }, - addProseMirrorPlugins() { - return [NumberedListIndexingPlugin()]; - }, + addProseMirrorPlugins() { + return [NumberedListIndexingPlugin()]; + }, - parseHTML() { - return [ - // Case for regular HTML list structure. - // (e.g.: when pasting from other apps) - { - tag: "li", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } + parseHTML() { + return [ + // Case for regular HTML list structure. + // (e.g.: when pasting from other apps) + { + tag: "li", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } - const parent = element.parentElement; + const parent = element.parentElement; - if (parent === null) { - return false; - } + if (parent === null) { + return false; + } - if (parent.tagName === "OL") { - return {}; - } + if (parent.tagName === "OL") { + return {}; + } - return false; - }, - node: "numberedListItem", - }, - // Case for BlockNote list structure. - // (e.g.: when pasting from blocknote) - { - tag: "p", - getAttrs: (element) => { - if (typeof element === "string") { return false; - } + }, + node: "numberedListItem", + }, + // Case for BlockNote list structure. + // (e.g.: when pasting from blocknote) + { + tag: "p", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } - const parent = element.parentElement; + const parent = element.parentElement; - if (parent === null) { - return false; - } + if (parent === null) { + return false; + } - if (parent.getAttribute("data-content-type") === "numberedListItem") { - return {}; - } + if ( + parent.getAttribute("data-content-type") === "numberedListItem" + ) { + return {}; + } - return false; + return false; + }, + priority: 300, + node: "numberedListItem", }, - priority: 300, - node: "numberedListItem", - }, - ]; - }, + ]; + }, - renderHTML({ HTMLAttributes }) { - return [ - "div", - mergeAttributes(HTMLAttributes, { - class: styles.blockContent, - "data-content-type": this.name, - }), - // we use a

    tag, because for

  • tags we'd need to add a
      parent for around siblings to be semantically correct, - // which would be quite cumbersome - ["p", 0], - ]; - }, -}); + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { + class: styles.blockContent, + "data-content-type": this.name, + }), + // we use a

      tag, because for

    • tags we'd need to add a
        parent for around siblings to be semantically correct, + // which would be quite cumbersome + ["p", 0], + ]; + }, + }); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts index cee021986a..62265e678c 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts @@ -1,9 +1,9 @@ -import { mergeAttributes, Node } from "@tiptap/core"; +import { mergeAttributes } from "@tiptap/core"; import styles from "../../Block.module.css"; +import { createTipTapBlock } from "../../../api/block"; -export const ParagraphBlockContent = Node.create({ +export const ParagraphBlockContent = createTipTapBlock<"paragraph">({ name: "paragraph", - group: "blockContent", content: "inline*", parseHTML() { diff --git a/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts b/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts index 41d4767a68..61ba051ff4 100644 --- a/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts +++ b/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts @@ -1,8 +1,9 @@ import { EditorElement, ElementFactory } from "../../shared/EditorElement"; import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { BlockSchema } from "../Blocks/api/blockTypes"; -export type BlockSideMenuStaticParams = { - editor: BlockNoteEditor; +export type BlockSideMenuStaticParams = { + editor: BlockNoteEditor; addBlock: () => void; @@ -13,14 +14,16 @@ export type BlockSideMenuStaticParams = { unfreezeMenu: () => void; }; -export type BlockSideMenuDynamicParams = { - editor: BlockNoteEditor; +export type BlockSideMenuDynamicParams = { + editor: BlockNoteEditor; referenceRect: DOMRect; }; -export type BlockSideMenu = EditorElement; -export type BlockSideMenuFactory = ElementFactory< - BlockSideMenuStaticParams, - BlockSideMenuDynamicParams +export type BlockSideMenu = EditorElement< + BlockSideMenuDynamicParams +>; +export type BlockSideMenuFactory = ElementFactory< + BlockSideMenuStaticParams, + BlockSideMenuDynamicParams >; diff --git a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts index 9156368eed..6db0fde3da 100644 --- a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts +++ b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts @@ -2,11 +2,12 @@ import { Editor, Extension } from "@tiptap/core"; import { BlockSideMenuFactory } from "./BlockSideMenuFactoryTypes"; import { createDraggableBlocksPlugin } from "./DraggableBlocksPlugin"; import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { BlockSchema } from "../Blocks/api/blockTypes"; -export type DraggableBlocksOptions = { +export type DraggableBlocksOptions = { tiptapEditor: Editor; - editor: BlockNoteEditor; - blockSideMenuFactory: BlockSideMenuFactory; + editor: BlockNoteEditor; + blockSideMenuFactory: BlockSideMenuFactory; }; /** @@ -15,8 +16,8 @@ export type DraggableBlocksOptions = { * * code based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799 */ -export const DraggableBlocksExtension = - Extension.create({ +export const createDraggableBlocksExtension = () => + Extension.create>({ name: "DraggableBlocksExtension", priority: 1000, // Need to be high, in order to hide menu when typing slash addProseMirrorPlugins() { diff --git a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts index 2a9c78bbda..73daac8f6c 100644 --- a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts +++ b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts @@ -15,6 +15,7 @@ import { import { DraggableBlocksOptions } from "./DraggableBlocksExtension"; import { MultipleNodeSelection } from "./MultipleNodeSelection"; import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { BlockSchema } from "../Blocks/api/blockTypes"; const serializeForClipboard = (pv as any).__serializeForClipboard; // code based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799 @@ -237,15 +238,15 @@ function dragStart(e: DragEvent, view: EditorView) { } } -export type BlockMenuViewProps = { +export type BlockMenuViewProps = { tiptapEditor: Editor; - editor: BlockNoteEditor; - blockMenuFactory: BlockSideMenuFactory; + editor: BlockNoteEditor; + blockMenuFactory: BlockSideMenuFactory; horizontalPosAnchoredAtRoot: boolean; }; -export class BlockMenuView { - editor: BlockNoteEditor; +export class BlockMenuView { + editor: BlockNoteEditor; private ttEditor: Editor; // When true, the drag handle with be anchored at the same level as root elements @@ -254,7 +255,7 @@ export class BlockMenuView { horizontalPosAnchor: number; - blockMenu: BlockSideMenu; + blockMenu: BlockSideMenu; hoveredBlockContent: HTMLElement | undefined; @@ -266,7 +267,7 @@ export class BlockMenuView { editor, blockMenuFactory, horizontalPosAnchoredAtRoot, - }: BlockMenuViewProps) { + }: BlockMenuViewProps) { this.editor = editor; this.ttEditor = tiptapEditor; this.horizontalPosAnchoredAtRoot = horizontalPosAnchoredAtRoot; @@ -485,7 +486,7 @@ export class BlockMenuView { ); } - getStaticParams(): BlockSideMenuStaticParams { + getStaticParams(): BlockSideMenuStaticParams { return { editor: this.editor, addBlock: () => this.addBlock(), @@ -501,7 +502,7 @@ export class BlockMenuView { }; } - getDynamicParams(): BlockSideMenuDynamicParams { + getDynamicParams(): BlockSideMenuDynamicParams { const blockContentBoundingBox = this.hoveredBlockContent!.getBoundingClientRect(); @@ -519,8 +520,8 @@ export class BlockMenuView { } } -export const createDraggableBlocksPlugin = ( - options: DraggableBlocksOptions +export const createDraggableBlocksPlugin = ( + options: DraggableBlocksOptions ) => { return new Plugin({ key: new PluginKey("DraggableBlocksPlugin"), diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts index 283f92160e..1ff19aab1b 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts @@ -1,32 +1,37 @@ import { Extension } from "@tiptap/core"; import { PluginKey } from "prosemirror-state"; -import { BlockNoteEditor } from "../.."; +import { BlockNoteEditor, BlockSchema } from "../.."; import { FormattingToolbarFactory } from "./FormattingToolbarFactoryTypes"; import { createFormattingToolbarPlugin } from "./FormattingToolbarPlugin"; +export type FormattingToolbarOptions = { + formattingToolbarFactory: FormattingToolbarFactory; + editor: BlockNoteEditor; +}; + /** * The menu that is displayed when selecting a piece of text. */ -export const FormattingToolbarExtension = Extension.create<{ - formattingToolbarFactory: FormattingToolbarFactory; - editor: BlockNoteEditor; -}>({ - name: "FormattingToolbarExtension", +export const createFormattingToolbarExtension = < + BSchema extends BlockSchema +>() => + Extension.create>({ + name: "FormattingToolbarExtension", - addProseMirrorPlugins() { - if (!this.options.formattingToolbarFactory || !this.options.editor) { - throw new Error( - "required args not defined for FormattingToolbarExtension" - ); - } + addProseMirrorPlugins() { + if (!this.options.formattingToolbarFactory || !this.options.editor) { + throw new Error( + "required args not defined for FormattingToolbarExtension" + ); + } - return [ - createFormattingToolbarPlugin({ - tiptapEditor: this.editor, - editor: this.options.editor, - formattingToolbarFactory: this.options.formattingToolbarFactory, - pluginKey: new PluginKey("FormattingToolbarPlugin"), - }), - ]; - }, -}); + return [ + createFormattingToolbarPlugin({ + tiptapEditor: this.editor, + editor: this.options.editor, + formattingToolbarFactory: this.options.formattingToolbarFactory, + pluginKey: new PluginKey("FormattingToolbarPlugin"), + }), + ]; + }, + }); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts index fe24b7e543..7c283a83db 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts @@ -1,17 +1,21 @@ import { EditorElement, ElementFactory } from "../../shared/EditorElement"; import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { BlockSchema } from "../Blocks/api/blockTypes"; -export type FormattingToolbarStaticParams = { - editor: BlockNoteEditor; +export type FormattingToolbarStaticParams = { + editor: BlockNoteEditor; }; -export type FormattingToolbarDynamicParams = { - editor: BlockNoteEditor; +export type FormattingToolbarDynamicParams = { + editor: BlockNoteEditor; referenceRect: DOMRect; }; -export type FormattingToolbar = EditorElement; -export type FormattingToolbarFactory = ElementFactory< - FormattingToolbarStaticParams, - FormattingToolbarDynamicParams +export type FormattingToolbar = EditorElement< + FormattingToolbarDynamicParams >; +export type FormattingToolbarFactory = + ElementFactory< + FormattingToolbarStaticParams, + FormattingToolbarDynamicParams + >; diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index 0674567a50..9cfd6ae0fb 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -6,7 +6,7 @@ import { } from "@tiptap/core"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { BlockNoteEditor } from "../.."; +import { BlockNoteEditor, BlockSchema } from "../.."; import { FormattingToolbar, FormattingToolbarDynamicParams, @@ -16,14 +16,14 @@ import { // Same as TipTap bubblemenu plugin, but with these changes: // https://github.com/ueberdosis/tiptap/pull/2596/files -export interface FormattingToolbarPluginProps { +export interface FormattingToolbarPluginProps { pluginKey: PluginKey; tiptapEditor: Editor; - editor: BlockNoteEditor; - formattingToolbarFactory: FormattingToolbarFactory; + editor: BlockNoteEditor; + formattingToolbarFactory: FormattingToolbarFactory; shouldShow?: | ((props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; view: EditorView; state: EditorState; oldState?: EditorState; @@ -33,17 +33,18 @@ export interface FormattingToolbarPluginProps { | null; } -export type FormattingToolbarViewProps = FormattingToolbarPluginProps & { - view: EditorView; -}; +export type FormattingToolbarViewProps = + FormattingToolbarPluginProps & { + view: EditorView; + }; -export class FormattingToolbarView { - public editor: BlockNoteEditor; +export class FormattingToolbarView { + public editor: BlockNoteEditor; private ttEditor: Editor; public view: EditorView; - public formattingToolbar: FormattingToolbar; + public formattingToolbar: FormattingToolbar; public preventHide = false; @@ -51,19 +52,21 @@ export class FormattingToolbarView { public toolbarIsOpen = false; - public shouldShow: Exclude = - ({ view, state, from, to }) => { - const { doc, selection } = state; - const { empty } = selection; + public shouldShow: Exclude< + FormattingToolbarPluginProps["shouldShow"], + null + > = ({ view, state, from, to }) => { + const { doc, selection } = state; + const { empty } = selection; - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = - !doc.textBetween(from, to).length && isTextSelection(state.selection); + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = + !doc.textBetween(from, to).length && isTextSelection(state.selection); - return !(!view.hasFocus() || empty || isEmptyTextBlock); - }; + return !(!view.hasFocus() || empty || isEmptyTextBlock); + }; constructor({ editor, @@ -71,7 +74,7 @@ export class FormattingToolbarView { formattingToolbarFactory, view, shouldShow, - }: FormattingToolbarViewProps) { + }: FormattingToolbarViewProps) { this.editor = editor; this.ttEditor = tiptapEditor; this.view = view; @@ -231,13 +234,13 @@ export class FormattingToolbarView { return posToDOMRect(this.ttEditor.view, from, to); } - getStaticParams(): FormattingToolbarStaticParams { + getStaticParams(): FormattingToolbarStaticParams { return { editor: this.editor, }; } - getDynamicParams(): FormattingToolbarDynamicParams { + getDynamicParams(): FormattingToolbarDynamicParams { return { editor: this.editor, referenceRect: this.getSelectionBoundingBox(), @@ -245,8 +248,8 @@ export class FormattingToolbarView { } } -export const createFormattingToolbarPlugin = ( - options: FormattingToolbarPluginProps +export const createFormattingToolbarPlugin = ( + options: FormattingToolbarPluginProps ) => { return new Plugin({ key: new PluginKey("FormattingToolbarPlugin"), diff --git a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts index 59132d35aa..355cc0b1f6 100644 --- a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts +++ b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts @@ -1,12 +1,15 @@ import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem"; import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { BlockSchema } from "../Blocks/api/blockTypes"; /** * A class that defines a slash command (/). * * (Not to be confused with ProseMirror commands nor TipTap commands.) */ -export class BaseSlashMenuItem extends SuggestionItem { +export class BaseSlashMenuItem< + BSchema extends BlockSchema +> extends SuggestionItem { /** * Constructs a new slash-command. * @@ -16,7 +19,7 @@ export class BaseSlashMenuItem extends SuggestionItem { */ constructor( public readonly name: string, - public readonly execute: (editor: BlockNoteEditor) => void, + public readonly execute: (editor: BlockNoteEditor) => void, public readonly aliases: string[] = [] ) { super(name, (query: string): boolean => { diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts b/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts index 5cf22278ca..d51aeb3458 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts @@ -4,46 +4,50 @@ import { createSuggestionPlugin } from "../../shared/plugins/suggestion/Suggesti import { SuggestionsMenuFactory } from "../../shared/plugins/suggestion/SuggestionsMenuFactoryTypes"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; import { BlockNoteEditor } from "../../BlockNoteEditor"; +import { BlockSchema } from "../Blocks/api/blockTypes"; -export type SlashMenuOptions = { - editor: BlockNoteEditor | undefined; - commands: BaseSlashMenuItem[] | undefined; +export type SlashMenuOptions = { + editor: BlockNoteEditor | undefined; + commands: BaseSlashMenuItem[] | undefined; slashMenuFactory: SuggestionsMenuFactory | undefined; }; export const SlashMenuPluginKey = new PluginKey("suggestions-slash-commands"); -export const SlashMenuExtension = Extension.create({ - name: "slash-command", +export const createSlashMenuExtension = () => + Extension.create>({ + name: "slash-command", - addOptions() { - return { - editor: undefined, - commands: undefined, - slashMenuFactory: undefined, - }; - }, + addOptions() { + return { + editor: undefined, + commands: undefined, + slashMenuFactory: undefined, + }; + }, - addProseMirrorPlugins() { - if (!this.options.slashMenuFactory || !this.options.commands) { - throw new Error("required args not defined for SlashMenuExtension"); - } + addProseMirrorPlugins() { + if (!this.options.slashMenuFactory || !this.options.commands) { + throw new Error("required args not defined for SlashMenuExtension"); + } - const commands = this.options.commands; + const commands = this.options.commands; - return [ - createSuggestionPlugin({ - pluginKey: SlashMenuPluginKey, - editor: this.options.editor!, - defaultTriggerCharacter: "/", - suggestionsMenuFactory: this.options.slashMenuFactory!, - items: (query) => { - return commands.filter((cmd: BaseSlashMenuItem) => cmd.match(query)); - }, - onSelectItem: ({ item, editor }) => { - item.execute(editor); - }, - }), - ]; - }, -}); + return [ + createSuggestionPlugin, BSchema>({ + pluginKey: SlashMenuPluginKey, + editor: this.options.editor!, + defaultTriggerCharacter: "/", + suggestionsMenuFactory: this.options.slashMenuFactory!, + items: (query) => { + return commands.filter((cmd: BaseSlashMenuItem) => + cmd.match(query) + ); + }, + onSelectItem: ({ item, editor }) => { + item.execute(editor); + }, + }), + ]; + }, + }); diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.tsx b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.tsx index ddda0ed7f6..0bea1e54a3 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.tsx +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.tsx @@ -1,10 +1,11 @@ import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { PartialBlockTemplate } from "../Blocks/api/blockTypes"; +import { PartialBlock } from "../Blocks/api/blockTypes"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; +import { DefaultBlockSchema } from "../Blocks/api/defaultBlocks"; -function insertOrUpdateBlock( - editor: BlockNoteEditor, - block: PartialBlockTemplate +function insertOrUpdateBlock( + editor: BlockNoteEditor, + block: PartialBlock ) { const currentBlock = editor.getTextCursorPosition().block; @@ -24,42 +25,42 @@ function insertOrUpdateBlock( /** * An array containing commands for creating all default blocks. */ -export const defaultSlashMenuItems: BaseSlashMenuItem[] = [ +export const defaultSlashMenuItems = () => [ // Command for creating a level 1 heading - new BaseSlashMenuItem( + new BaseSlashMenuItem( "Heading", (editor) => - insertOrUpdateBlock(editor, { + insertOrUpdateBlock(editor, { type: "heading", props: { level: "1" }, - }), + } as PartialBlock), ["h", "heading1", "h1"] ), // Command for creating a level 2 heading - new BaseSlashMenuItem( + new BaseSlashMenuItem( "Heading 2", (editor) => insertOrUpdateBlock(editor, { type: "heading", props: { level: "2" }, - }), + } as PartialBlock), ["h2", "heading2", "subheading"] ), // Command for creating a level 3 heading - new BaseSlashMenuItem( + new BaseSlashMenuItem( "Heading 3", (editor) => insertOrUpdateBlock(editor, { type: "heading", props: { level: "3" }, - }), + } as PartialBlock), ["h3", "heading3", "subheading"] ), // Command for creating an ordered list - new BaseSlashMenuItem( + new BaseSlashMenuItem( "Numbered List", (editor) => insertOrUpdateBlock(editor, { @@ -69,7 +70,7 @@ export const defaultSlashMenuItems: BaseSlashMenuItem[] = [ ), // Command for creating a bullet list - new BaseSlashMenuItem( + new BaseSlashMenuItem( "Bullet List", (editor) => insertOrUpdateBlock(editor, { @@ -79,7 +80,7 @@ export const defaultSlashMenuItems: BaseSlashMenuItem[] = [ ), // Command for creating a paragraph (pretty useless) - new BaseSlashMenuItem( + new BaseSlashMenuItem( "Paragraph", (editor) => insertOrUpdateBlock(editor, { diff --git a/packages/core/src/extensions/SlashMenu/index.ts b/packages/core/src/extensions/SlashMenu/index.ts index d8551aa591..3fae65095c 100644 --- a/packages/core/src/extensions/SlashMenu/index.ts +++ b/packages/core/src/extensions/SlashMenu/index.ts @@ -1,5 +1,5 @@ import { defaultSlashMenuItems } from "./defaultSlashMenuItems"; -import { SlashMenuExtension } from "./SlashMenuExtension"; +import { createSlashMenuExtension } from "./SlashMenuExtension"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; -export { defaultSlashMenuItems, BaseSlashMenuItem, SlashMenuExtension }; +export { defaultSlashMenuItems, BaseSlashMenuItem, createSlashMenuExtension }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index feaa83bcbd..fbb89f6c86 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,8 @@ export * from "./BlockNoteEditor"; export * from "./BlockNoteExtensions"; +export * from "./extensions/Blocks/api/block"; export * from "./extensions/Blocks/api/blockTypes"; +export * from "./extensions/Blocks/api/defaultBlocks"; export * from "./extensions/DraggableBlocks/BlockSideMenuFactoryTypes"; export * from "./extensions/FormattingToolbar/FormattingToolbarFactoryTypes"; export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes"; diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts index 30045ce75e..80d9d0a985 100644 --- a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts @@ -10,8 +10,12 @@ import { } from "./SuggestionsMenuFactoryTypes"; import { SuggestionItem } from "./SuggestionItem"; import { BlockNoteEditor } from "../../../BlockNoteEditor"; +import { BlockSchema } from "../../../extensions/Blocks/api/blockTypes"; -export type SuggestionPluginOptions = { +export type SuggestionPluginOptions< + T extends SuggestionItem, + BSchema extends BlockSchema +> = { /** * The name of the plugin. * @@ -22,7 +26,7 @@ export type SuggestionPluginOptions = { /** * The BlockNote editor. */ - editor: BlockNoteEditor; + editor: BlockNoteEditor; /** * The character that should trigger the suggestion menu to pop up (e.g. a '/' for commands), when typed by the user. @@ -38,7 +42,7 @@ export type SuggestionPluginOptions = { * this should be done manually. The `editor` and `range` properties passed * to the callback function might come in handy when doing this. */ - onSelectItem?: (props: { item: T; editor: BlockNoteEditor }) => void; + onSelectItem?: (props: { item: T; editor: BlockNoteEditor }) => void; /** * A function that should supply the plugin with items to suggest, based on a certain query string. @@ -81,15 +85,21 @@ function getDefaultPluginState< }; } -type SuggestionPluginViewOptions = { - editor: BlockNoteEditor; +type SuggestionPluginViewOptions< + T extends SuggestionItem, + BSchema extends BlockSchema +> = { + editor: BlockNoteEditor; pluginKey: PluginKey; - onSelectItem: (props: { item: T; editor: BlockNoteEditor }) => void; + onSelectItem: (props: { item: T; editor: BlockNoteEditor }) => void; suggestionsMenuFactory: SuggestionsMenuFactory; }; -class SuggestionPluginView { - editor: BlockNoteEditor; +class SuggestionPluginView< + T extends SuggestionItem, + BSchema extends BlockSchema +> { + editor: BlockNoteEditor; pluginKey: PluginKey; suggestionsMenu: SuggestionsMenu; @@ -102,7 +112,7 @@ class SuggestionPluginView { pluginKey, onSelectItem: selectItemCallback = () => {}, suggestionsMenuFactory, - }: SuggestionPluginViewOptions) { + }: SuggestionPluginViewOptions) { this.editor = editor; this.pluginKey = pluginKey; @@ -202,14 +212,17 @@ class SuggestionPluginView { * @param options options for configuring the plugin * @returns the prosemirror plugin */ -export function createSuggestionPlugin({ +export function createSuggestionPlugin< + T extends SuggestionItem, + BSchema extends BlockSchema +>({ pluginKey, editor, defaultTriggerCharacter, suggestionsMenuFactory, onSelectItem: selectItemCallback = () => {}, items = () => [], -}: SuggestionPluginOptions) { +}: SuggestionPluginOptions) { // Assertions if (defaultTriggerCharacter.length !== 1) { throw new Error("'char' should be a single character"); @@ -224,10 +237,13 @@ export function createSuggestionPlugin({ key: pluginKey, view: (view: EditorView) => - new SuggestionPluginView({ + new SuggestionPluginView({ editor: editor, pluginKey: pluginKey, - onSelectItem: (props: { item: T; editor: BlockNoteEditor }) => { + onSelectItem: (props: { + item: T; + editor: BlockNoteEditor; + }) => { deactivate(view); selectItemCallback(props); }, diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx index 75b9385c13..ffc0ae860c 100644 --- a/packages/react/src/BlockNoteView.tsx +++ b/packages/react/src/BlockNoteView.tsx @@ -1,9 +1,11 @@ -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { EditorContent } from "@tiptap/react"; // import { BlockNoteTheme } from "./BlockNoteTheme"; // import { MantineProvider } from "@mantine/core"; -export function BlockNoteView(props: { editor: BlockNoteEditor | null }) { +export function BlockNoteView(props: { + editor: BlockNoteEditor | null; +}) { return ( // TODO: Should we wrap editor in MantineProvider? Otherwise we have to duplicate color hex values. // diff --git a/packages/react/src/BlockSideMenu/BlockSideMenuFactory.tsx b/packages/react/src/BlockSideMenu/BlockSideMenuFactory.tsx index e6bb354264..f846a29607 100644 --- a/packages/react/src/BlockSideMenu/BlockSideMenuFactory.tsx +++ b/packages/react/src/BlockSideMenu/BlockSideMenuFactory.tsx @@ -1,43 +1,45 @@ import { FC } from "react"; import { BlockNoteEditor, + BlockSchema, BlockSideMenu, BlockSideMenuDynamicParams, - BlockSideMenuFactory, BlockSideMenuStaticParams, } from "@blocknote/core"; import { BlockSideMenu as ReactBlockSideMenu } from "./components/BlockSideMenu"; import { ReactElementFactory } from "../ElementFactory/components/ReactElementFactory"; -export const createReactBlockSideMenuFactory = ( - dragHandleMenu: FC<{ editor: BlockNoteEditor; closeMenu: () => void }> +export const createReactBlockSideMenuFactory = ( + dragHandleMenu: FC<{ + editor: BlockNoteEditor; + closeMenu: () => void; + }> ) => { const CustomDragHandleMenu = dragHandleMenu; const CustomBlockSideMenu = ( - props: BlockSideMenuStaticParams & BlockSideMenuDynamicParams + props: BlockSideMenuStaticParams & + BlockSideMenuDynamicParams ) => ; - return (staticParams: BlockSideMenuStaticParams) => - ReactElementFactory( - staticParams, - CustomBlockSideMenu, - { - animation: "fade", - offset: [0, 0], - placement: "left", - } - ); -}; - -export const ReactBlockSideMenuFactory: BlockSideMenuFactory = ( - staticParams -): BlockSideMenu => - ReactElementFactory( - staticParams, - ReactBlockSideMenu, - { + return (staticParams: BlockSideMenuStaticParams) => + ReactElementFactory< + BlockSideMenuStaticParams, + BlockSideMenuDynamicParams + >(staticParams, CustomBlockSideMenu, { animation: "fade", offset: [0, 0], placement: "left", - } - ); + }); +}; + +export const ReactBlockSideMenuFactory = ( + staticParams: BlockSideMenuStaticParams +): BlockSideMenu => + ReactElementFactory< + BlockSideMenuStaticParams, + BlockSideMenuDynamicParams + >(staticParams, ReactBlockSideMenu, { + animation: "fade", + offset: [0, 0], + placement: "left", + }); diff --git a/packages/react/src/BlockSideMenu/components/BlockSideMenu.tsx b/packages/react/src/BlockSideMenu/components/BlockSideMenu.tsx index 6275898d74..f6930abb88 100644 --- a/packages/react/src/BlockSideMenu/components/BlockSideMenu.tsx +++ b/packages/react/src/BlockSideMenu/components/BlockSideMenu.tsx @@ -1,14 +1,17 @@ import { FC, useEffect, useRef, useState } from "react"; import { ActionIcon, Group, Menu } from "@mantine/core"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { AiOutlinePlus, MdDragIndicator } from "react-icons/all"; import { DragHandleMenu } from "./DragHandleMenu"; import { RemoveBlockButton } from "./DefaultButtons/RemoveBlockButton"; import { BlockColorsButton } from "./DefaultButtons/BlockColorsButton"; -export type BlockSideMenuProps = { - editor: BlockNoteEditor; - dragHandleMenu?: FC<{ editor: BlockNoteEditor; closeMenu: () => void }>; +export type BlockSideMenuProps = { + editor: BlockNoteEditor; + dragHandleMenu?: FC<{ + editor: BlockNoteEditor; + closeMenu: () => void; + }>; addBlock: () => void; blockDragStart: (event: DragEvent) => void; blockDragEnd: () => void; @@ -16,7 +19,9 @@ export type BlockSideMenuProps = { unfreezeMenu: () => void; }; -export const BlockSideMenu = (props: BlockSideMenuProps) => { +export const BlockSideMenu = ( + props: BlockSideMenuProps +) => { const [dragHandleMenuOpened, setDragHandleMenuOpened] = useState(false); const dragHandleRef = useRef(null); diff --git a/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx index 4ca554514f..748b55eeea 100644 --- a/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx +++ b/packages/react/src/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx @@ -1,11 +1,11 @@ import { ReactNode, useCallback, useRef, useState } from "react"; import { Box, Menu } from "@mantine/core"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { HiChevronRight } from "react-icons/hi"; import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker"; -export const BlockColorsButton = (props: { - editor: BlockNoteEditor; +export const BlockColorsButton = (props: { + editor: BlockNoteEditor; closeMenu: () => void; children: ReactNode; }) => { @@ -30,6 +30,14 @@ export const BlockColorsButton = (props: { setOpened(true); }, []); + if ( + !block || + !("textColor" in block.props) || + !("backgroundColor" in block.props) + ) { + return null; + } + return ( { @@ -64,13 +72,15 @@ export const BlockColorsButton = (props: { } setTextColor={(color) => block && - props.editor.updateBlock(block, { props: { textColor: color } }) + props.editor.updateBlock(block, { + props: { textColor: color }, + } as PartialBlock) } setBackgroundColor={(color) => block && props.editor.updateBlock(block, { props: { backgroundColor: color }, - }) + } as PartialBlock) } /> diff --git a/packages/react/src/BlockSideMenu/components/DefaultButtons/RemoveBlockButton.tsx b/packages/react/src/BlockSideMenu/components/DefaultButtons/RemoveBlockButton.tsx index e3fa4d4dca..fc6b4f4be4 100644 --- a/packages/react/src/BlockSideMenu/components/DefaultButtons/RemoveBlockButton.tsx +++ b/packages/react/src/BlockSideMenu/components/DefaultButtons/RemoveBlockButton.tsx @@ -1,9 +1,9 @@ import { ReactNode, useState } from "react"; import { Menu } from "@mantine/core"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; -export const RemoveBlockButton = (props: { - editor: BlockNoteEditor; +export const RemoveBlockButton = (props: { + editor: BlockNoteEditor; closeMenu: () => void; children: ReactNode; }) => { diff --git a/packages/react/src/FormattingToolbar/FormattingToolbarFactory.tsx b/packages/react/src/FormattingToolbar/FormattingToolbarFactory.tsx index 4d25b63549..27f094f5cc 100644 --- a/packages/react/src/FormattingToolbar/FormattingToolbarFactory.tsx +++ b/packages/react/src/FormattingToolbar/FormattingToolbarFactory.tsx @@ -1,32 +1,37 @@ import { FormattingToolbar, - FormattingToolbarFactory, FormattingToolbarStaticParams, FormattingToolbarDynamicParams, + BlockSchema, } from "@blocknote/core"; import { FormattingToolbar as ReactFormattingToolbar } from "./components/FormattingToolbar"; import { ReactElementFactory } from "../ElementFactory/components/ReactElementFactory"; import { FC } from "react"; -export const createReactFormattingToolbarFactory = ( - toolbar: FC +export const createReactFormattingToolbarFactory = < + BSchema extends BlockSchema +>( + toolbar: FC< + FormattingToolbarStaticParams & + FormattingToolbarDynamicParams + > ) => { - return (staticParams: FormattingToolbarStaticParams) => + return (staticParams: FormattingToolbarStaticParams) => ReactElementFactory< - FormattingToolbarStaticParams, - FormattingToolbarDynamicParams + FormattingToolbarStaticParams, + FormattingToolbarDynamicParams >(staticParams, toolbar, { animation: "fade", placement: "top-start", }); }; -export const ReactFormattingToolbarFactory: FormattingToolbarFactory = ( - staticParams -): FormattingToolbar => +export const ReactFormattingToolbarFactory = ( + staticParams: FormattingToolbarStaticParams +): FormattingToolbar => ReactElementFactory< - FormattingToolbarStaticParams, - FormattingToolbarDynamicParams + FormattingToolbarStaticParams, + FormattingToolbarDynamicParams >(staticParams, ReactFormattingToolbar, { animation: "fade", placement: "top-start", diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx index 33a0c48a64..8b6e114f14 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx @@ -1,11 +1,13 @@ import { useCallback } from "react"; import { Menu } from "@mantine/core"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/ColorIcon"; import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker"; -export const ColorStyleButton = (props: { editor: BlockNoteEditor }) => { +export const ColorStyleButton = (props: { + editor: BlockNoteEditor; +}) => { const getTextColor = useCallback( () => props.editor.getActiveStyles().textColor || "default", [props] diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx index 8ca93fa6b1..e4fa034d52 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx @@ -1,10 +1,12 @@ import { useCallback } from "react"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { RiLink } from "react-icons/ri"; import LinkToolbarButton from "../LinkToolbarButton"; import { formatKeyboardShortcut } from "../../../utils"; -export const CreateLinkButton = (props: { editor: BlockNoteEditor }) => { +export const CreateLinkButton = (props: { + editor: BlockNoteEditor; +}) => { const setLink = useCallback( (url: string, text?: string) => { props.editor.focus(); diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx index 9f7e515ef0..4aa920ed31 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx @@ -1,10 +1,12 @@ import { formatKeyboardShortcut } from "../../../utils"; import { RiIndentDecrease, RiIndentIncrease } from "react-icons/ri"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { useCallback } from "react"; -export const NestBlockButton = (props: { editor: BlockNoteEditor }) => { +export const NestBlockButton = (props: { + editor: BlockNoteEditor; +}) => { const nestBlock = useCallback(() => { props.editor.focus(); props.editor.nestBlock(); @@ -23,7 +25,9 @@ export const NestBlockButton = (props: { editor: BlockNoteEditor }) => { ); }; -export const UnnestBlockButton = (props: { editor: BlockNoteEditor }) => { +export const UnnestBlockButton = (props: { + editor: BlockNoteEditor; +}) => { const unnestBlock = useCallback(() => { props.editor.focus(); props.editor.unnestBlock(); diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx index 7b78d631d4..37c5022288 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx @@ -1,5 +1,11 @@ -import { BlockNoteEditor, DefaultBlockPropsType } from "@blocknote/core"; -import { useCallback } from "react"; +import { + Block, + BlockNoteEditor, + BlockSchema, + defaultProps, + Props, +} from "@blocknote/core"; +import { useCallback, useState } from "react"; import { IconType } from "react-icons"; import { RiAlignCenter, @@ -9,34 +15,56 @@ import { } from "react-icons/ri"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -const icons: Record = { +type TextAlignment = Props["textAlignment"]; + +const icons: Record = { left: RiAlignLeft, center: RiAlignCenter, right: RiAlignRight, justify: RiAlignJustify, }; -export const TextAlignButton = (props: { - editor: BlockNoteEditor; - textAlignment: DefaultBlockPropsType["textAlignment"]; +export const TextAlignButton = (props: { + editor: BlockNoteEditor; + textAlignment: TextAlignment; }) => { - const getTextAlignment = useCallback( - () => props.editor.getTextCursorPosition().block.props.textAlignment, - [props] - ); + const [show, setShow] = useState(false); + + const getTextAlignment = useCallback(() => { + const block = props.editor.getTextCursorPosition().block; + + if ("textAlignment" in block.props) { + if (!show) { + setShow(true); + } + return block.props.textAlignment; + } else { + if (show) { + setShow(false); + } + return "left"; + } + }, [show, props]); const setTextAlignment = useCallback( - (textAlignment: DefaultBlockPropsType["textAlignment"]) => { + (textAlignment: TextAlignment) => { props.editor.focus(); + for (const block of props.editor.getSelection().blocks) { - props.editor.updateBlock(block, { - props: { textAlignment: textAlignment }, - }); + if ("textAlignment" in block.props) { + props.editor.updateBlock(block, { + props: { textAlignment: textAlignment }, + } as unknown as Block); + } } }, [props] ); + if (!show) { + return null; + } + return ( setTextAlignment(props.textAlignment)} diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx index 59fd50f572..ef5abbcd46 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx @@ -1,7 +1,7 @@ import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { formatKeyboardShortcut } from "../../../utils"; import { RiBold, RiItalic, RiStrikethrough, RiUnderline } from "react-icons/ri"; -import { BlockNoteEditor, ToggledStyle } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema, ToggledStyle } from "@blocknote/core"; import { useCallback } from "react"; import { IconType } from "react-icons"; @@ -19,8 +19,8 @@ const icons: Record = { strike: RiStrikethrough, }; -export const ToggledStyleButton = (props: { - editor: BlockNoteEditor; +export const ToggledStyleButton = (props: { + editor: BlockNoteEditor; toggledStyle: ToggledStyle; }) => { const styleIsActive = useCallback( diff --git a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx index d69c85b812..a8998b8622 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { RiH1, RiH2, @@ -9,8 +9,28 @@ import { RiText, } from "react-icons/ri"; import { ToolbarDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarDropdown"; +import { IconType } from "react-icons"; -export const BlockTypeDropdown = (props: { editor: BlockNoteEditor }) => { +type HeadingLevels = "1" | "2" | "3"; + +const headingIcons: Record = { + "1": RiH1, + "2": RiH2, + "3": RiH3, +}; + +const shouldShow = (schema: BlockSchema) => { + const paragraph = "paragraph" in schema; + const heading = "heading" in schema && "level" in schema.heading.propSchema; + const bulletListItem = "bulletListItem" in schema; + const numberedListItem = "numberedListItem" in schema; + + return paragraph && heading && bulletListItem && numberedListItem; +}; + +export const BlockTypeDropdown = (props: { + editor: BlockNoteEditor; +}) => { const [block, setBlock] = useState( props.editor.getTextCursorPosition().block ); @@ -20,6 +40,25 @@ export const BlockTypeDropdown = (props: { editor: BlockNoteEditor }) => { [props] ); + if (!shouldShow(props.editor.schema)) { + return null; + } + + const headingItems = ( + props.editor.schema.heading.propSchema.level.values! as HeadingLevels[] + ).map((level) => ({ + onClick: () => { + props.editor.focus(); + props.editor.updateBlock(block, { + type: "heading", + props: { level: level }, + } as PartialBlock); + }, + text: "Heading " + level, + icon: headingIcons[level], + isSelected: block.type === "heading" && block.props.level === "1", + })); + return ( { icon: RiText, isSelected: block.type === "paragraph", }, - { - onClick: () => { - props.editor.focus(); - props.editor.updateBlock(block, { - type: "heading", - props: { level: "1" }, - }); - }, - text: "Heading 1", - icon: RiH1, - isSelected: block.type === "heading" && block.props.level === "1", - }, - { - onClick: () => { - props.editor.focus(); - props.editor.updateBlock(block, { - type: "heading", - props: { level: "2" }, - }); - }, - text: "Heading 2", - icon: RiH2, - isSelected: block.type === "heading" && block.props.level === "2", - }, - { - onClick: () => { - props.editor.focus(); - props.editor.updateBlock(block, { - type: "heading", - props: { level: "3" }, - }); - }, - text: "Heading 3", - icon: RiH3, - isSelected: block.type === "heading" && block.props.level === "3", - }, + ...headingItems, { onClick: () => { props.editor.focus(); diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx index dfacb7f90a..18027faa69 100644 --- a/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx @@ -1,5 +1,4 @@ -import { FC } from "react"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; import { BlockTypeDropdown } from "./DefaultDropdowns/BlockTypeDropdown"; import { ToggledStyleButton } from "./DefaultButtons/ToggledStyleButton"; @@ -11,8 +10,8 @@ import { } from "./DefaultButtons/NestBlockButtons"; import { CreateLinkButton } from "./DefaultButtons/CreateLinkButton"; -export const FormattingToolbar: FC<{ editor: BlockNoteEditor }> = (props: { - editor: BlockNoteEditor; +export const FormattingToolbar = (props: { + editor: BlockNoteEditor; }) => { return ( diff --git a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts index 31dc72f079..44ff73c2a1 100644 --- a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts +++ b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts @@ -1,9 +1,15 @@ -import { BaseSlashMenuItem, BlockNoteEditor } from "@blocknote/core"; +import { + BaseSlashMenuItem, + BlockNoteEditor, + BlockSchema, +} from "@blocknote/core"; -export class ReactSlashMenuItem extends BaseSlashMenuItem { +export class ReactSlashMenuItem< + BSchema extends BlockSchema +> extends BaseSlashMenuItem { constructor( public readonly name: string, - public readonly execute: (editor: BlockNoteEditor) => void, + public readonly execute: (editor: BlockNoteEditor) => void, public readonly aliases: string[] = [], public readonly group: string, public readonly icon: JSX.Element, diff --git a/packages/react/src/SlashMenu/SlashMenuFactory.tsx b/packages/react/src/SlashMenu/SlashMenuFactory.tsx index 1f398f24b2..ddedee5f59 100644 --- a/packages/react/src/SlashMenu/SlashMenuFactory.tsx +++ b/packages/react/src/SlashMenu/SlashMenuFactory.tsx @@ -1,19 +1,19 @@ import { + BlockSchema, SuggestionsMenu, SuggestionsMenuDynamicParams, - SuggestionsMenuFactory, SuggestionsMenuStaticParams, } from "@blocknote/core"; import { SlashMenu } from "./components/SlashMenu"; import { ReactSlashMenuItem } from "./ReactSlashMenuItem"; import { ReactElementFactory } from "../ElementFactory/components/ReactElementFactory"; -export const ReactSlashMenuFactory: SuggestionsMenuFactory< - ReactSlashMenuItem -> = (staticParams): SuggestionsMenu => +export const ReactSlashMenuFactory = ( + staticParams: SuggestionsMenuStaticParams> +): SuggestionsMenu> => ReactElementFactory< - SuggestionsMenuStaticParams, - SuggestionsMenuDynamicParams + SuggestionsMenuStaticParams>, + SuggestionsMenuDynamicParams> >(staticParams, SlashMenu, { animation: "fade", placement: "bottom-start", diff --git a/packages/react/src/SlashMenu/components/SlashMenu.tsx b/packages/react/src/SlashMenu/components/SlashMenu.tsx index 8cbb441f2b..f775ec7728 100644 --- a/packages/react/src/SlashMenu/components/SlashMenu.tsx +++ b/packages/react/src/SlashMenu/components/SlashMenu.tsx @@ -2,14 +2,17 @@ import { createStyles, Menu } from "@mantine/core"; import * as _ from "lodash"; import { SlashMenuItem } from "./SlashMenuItem"; import { ReactSlashMenuItem } from "../ReactSlashMenuItem"; +import { BlockSchema } from "@blocknote/core"; -export type SlashMenuProps = { - items: ReactSlashMenuItem[]; +export type SlashMenuProps = { + items: ReactSlashMenuItem[]; keyboardHoveredItemIndex: number; - itemCallback: (item: ReactSlashMenuItem) => void; + itemCallback: (item: ReactSlashMenuItem) => void; }; -export function SlashMenu(props: SlashMenuProps) { +export function SlashMenu( + props: SlashMenuProps +) { const { classes } = createStyles({ root: {} })(undefined, { name: "SlashMenu", }); diff --git a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx index 5aaf10c950..754a211bde 100644 --- a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx @@ -1,5 +1,9 @@ import { formatKeyboardShortcut } from "../utils"; -import { defaultSlashMenuItems, BaseSlashMenuItem } from "@blocknote/core"; +import { + defaultSlashMenuItems, + BaseSlashMenuItem, + DefaultBlockSchema, +} from "@blocknote/core"; import { ReactSlashMenuItem } from "./ReactSlashMenuItem"; import { RiH1, @@ -9,10 +13,12 @@ import { RiListUnordered, RiText, } from "react-icons/ri"; - const extraFields: Record< string, - Omit + Omit< + ReactSlashMenuItem, + keyof BaseSlashMenuItem + > > = { Heading: { group: "Headings", @@ -52,10 +58,12 @@ const extraFields: Record< }, }; -export const defaultReactSlashMenuItems: ReactSlashMenuItem[] = - defaultSlashMenuItems.map( +export const defaultReactSlashMenuItems = < + BSchema extends DefaultBlockSchema +>() => + defaultSlashMenuItems().map( (item) => - new ReactSlashMenuItem( + new ReactSlashMenuItem( item.name, item.execute, item.aliases, diff --git a/packages/react/src/hooks/useBlockNote.ts b/packages/react/src/hooks/useBlockNote.ts index 0a98acf0d2..e200fccacb 100644 --- a/packages/react/src/hooks/useBlockNote.ts +++ b/packages/react/src/hooks/useBlockNote.ts @@ -1,4 +1,9 @@ -import { BlockNoteEditor, BlockNoteEditorOptions } from "@blocknote/core"; +import { + BlockNoteEditor, + BlockNoteEditorOptions, + BlockSchema, + DefaultBlockSchema, +} from "@blocknote/core"; import { DependencyList, useEffect, useState } from "react"; import { ReactBlockSideMenuFactory } from "../BlockSideMenu/BlockSideMenuFactory"; import { ReactFormattingToolbarFactory } from "../FormattingToolbar/FormattingToolbarFactory"; @@ -17,11 +22,11 @@ function useForceUpdate() { /** * Main hook for importing a BlockNote editor into a React project */ -export const useBlockNote = ( - options: Partial = {}, +export const useBlockNote = ( + options: Partial> = {}, deps: DependencyList = [] ) => { - const [editor, setEditor] = useState(null); + const [editor, setEditor] = useState | null>(null); const forceUpdate = useForceUpdate(); useEffect(() => { @@ -30,7 +35,7 @@ export const useBlockNote = ( // but it would have to be on several different classes (BlockNoteEditor, BlockNoteEditorOptions, UiFactories) and // gets messy quick. let newOptions: Record = { - slashCommands: defaultReactSlashMenuItems, + slashCommands: defaultReactSlashMenuItems(), ...options, }; if (!newOptions.uiFactories) { @@ -45,8 +50,8 @@ export const useBlockNote = ( }; } console.log("create new blocknote instance"); - const instance = new BlockNoteEditor( - newOptions as Partial + const instance = new BlockNoteEditor( + newOptions as Partial> ); setEditor(instance);