diff --git a/examples/01-basic/01-minimal/package.json b/examples/01-basic/01-minimal/package.json index 7deed32f95..79bc3beed8 100644 --- a/examples/01-basic/01-minimal/package.json +++ b/examples/01-basic/01-minimal/package.json @@ -15,6 +15,7 @@ "@blocknote/ariakit": "latest", "@blocknote/mantine": "latest", "@blocknote/shadcn": "latest", + "@blocknote/code-block": "latest", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -24,4 +25,4 @@ "@vitejs/plugin-react": "^4.3.1", "vite": "^5.3.4" } -} \ No newline at end of file +} diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index 48e32a2b01..dc718437b1 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -1,6 +1,6 @@ import { BlockSchema, - checkBlockIsFileBlock, + blockHasType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +41,7 @@ export const FileReplaceButton = () => { if ( block === undefined || - !checkBlockIsFileBlock(block, editor) || + !blockHasType(block, editor, { url: "string" }) || !editor.isEditable ) { return null; diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index 56163fdcc5..81390947bf 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -48,7 +48,7 @@ export function insertBlocks< // re-convert them into full `Block`s. const insertedBlocks = nodesToInsert.map((node) => nodeToBlock(node, pmSchema), - ); + ) as Block[]; return insertedBlocks; } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index 2bfdba24e5..4ba8107636 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -102,7 +102,7 @@ export function removeAndInsertBlocks< // Converts the nodes created from `blocksToInsert` into full `Block`s. const insertedBlocks = nodesToInsert.map((node) => nodeToBlock(node, pmSchema), - ); + ) as Block[]; return { insertedBlocks, removedBlocks }; -} \ No newline at end of file +} diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 6a08ae4254..1e73471d23 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -1,9 +1,10 @@ -import { EditorState } from "prosemirror-state"; +import { EditorState, Transaction } from "prosemirror-state"; import { getBlockInfo, getNearestBlockPos, } from "../../../getBlockInfoFromPos.js"; +import { getPmSchema } from "../../../pmUtil.js"; export const splitBlockCommand = ( posInBlock: number, @@ -17,33 +18,41 @@ export const splitBlockCommand = ( state: EditorState; dispatch: ((args?: any) => any) | undefined; }) => { - const nearestBlockContainerPos = getNearestBlockPos(state.doc, posInBlock); - - const info = getBlockInfo(nearestBlockContainerPos); - - if (!info.isBlockContainer) { - throw new Error( - `BlockContainer expected when calling splitBlock, position ${posInBlock}`, - ); - } - - const types = [ - { - type: info.bnBlock.node.type, // always keep blockcontainer type - attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {}, - }, - { - type: keepType - ? info.blockContent.node.type - : state.schema.nodes["paragraph"], - attrs: keepProps ? { ...info.blockContent.node.attrs } : {}, - }, - ]; - if (dispatch) { - state.tr.split(posInBlock, 2, types); + return splitBlockTr(state.tr, posInBlock, keepType, keepProps); } return true; }; }; + +export const splitBlockTr = ( + tr: Transaction, + posInBlock: number, + keepType?: boolean, + keepProps?: boolean, +): boolean => { + const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock); + + const info = getBlockInfo(nearestBlockContainerPos); + + if (!info.isBlockContainer) { + return false; + } + const schema = getPmSchema(tr); + + const types = [ + { + type: info.bnBlock.node.type, // always keep blockcontainer type + attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {}, + }, + { + type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"], + attrs: keepProps ? { ...info.blockContent.node.attrs } : {}, + }, + ]; + + tr.split(posInBlock, 2, types); + + return true; +}; diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index d164dcb762..ce49383b86 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -2,7 +2,6 @@ import { Block, PartialBlock } from "../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; import { BlockSchema, - FileBlockConfig, InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; @@ -106,15 +105,11 @@ export async function handleFileInsertion< event.preventDefault(); - const fileBlockConfigs = Object.values(editor.schema.blockSchema).filter( - (blockConfig) => blockConfig.isFileBlock, - ) as FileBlockConfig[]; - for (let i = 0; i < items.length; i++) { // Gets file block corresponding to MIME type. let fileBlockType = "file"; - for (const fileBlockConfig of fileBlockConfigs) { - for (const mimeType of fileBlockConfig.fileBlockAccept || []) { + for (const fileBlockConfig of Object.values(editor.schema.blockSchema)) { + for (const mimeType of fileBlockConfig.meta?.fileBlockAccept || []) { const isFileExtension = mimeType.startsWith("."); const file = items[i].getAsFile(); diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index c768f32d3b..d92b99fdcd 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -95,7 +95,7 @@ function fragmentToExternalHTML< ); externalHTML = externalHTMLExporter.exportInlineContent(ic, {}); } else { - const blocks = fragmentToBlocks(selectedFragment); + const blocks = fragmentToBlocks(selectedFragment); externalHTML = externalHTMLExporter.exportBlocks(blocks, {}); } return externalHTML; diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index f74757c8d7..2b5637cbc3 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -112,12 +112,19 @@ function serializeBlock< // we should change toExternalHTML so that this is not necessary const attrs = Array.from(bc.dom.attributes); - const ret = editor.blockImplementations[ - block.type as any - ].implementation.toExternalHTML({ ...block, props } as any, editor as any); + const blockImplementation = + editor.blockImplementations[block.type as any].implementation; + const ret = + blockImplementation.toExternalHTML?.( + { ...block, props } as any, + editor as any, + ) || blockImplementation.render({ ...block, props } as any, editor as any); const elementFragment = doc.createDocumentFragment(); - if (ret.dom.classList.contains("bn-block-content")) { + if ( + ret.dom instanceof HTMLElement && + ret.dom.classList.contains("bn-block-content") + ) { const blockContentDataAttributes = [ ...attrs, ...Array.from(ret.dom.attributes), diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0bd7722172..9bf4ecdc19 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -74,7 +74,7 @@ function serializeBlock< } const impl = editor.blockImplementations[block.type as any].implementation; - const ret = impl.toInternalHTML({ ...block, props } as any, editor as any); + const ret = impl.render({ ...block, props } as any, editor as any); if (block.type === "numberedListItem") { // This is a workaround to make sure there's a list index set. @@ -83,7 +83,9 @@ function serializeBlock< // - (a) this information is not available on the Blocks passed to the serializer. (we only have access to BlockNote Blocks) // - (b) the NumberedListIndexingPlugin might not even have run, because we can manually call blocksToFullHTML // with blocks that are not part of the active document - ret.dom.setAttribute("data-index", listIndex.toString()); + if (ret.dom instanceof HTMLElement) { + ret.dom.setAttribute("data-index", listIndex.toString()); + } } if (ret.contentDOM && block.content) { diff --git a/packages/core/src/blocks/Audio/block.ts b/packages/core/src/blocks/Audio/block.ts new file mode 100644 index 0000000000..5a2c7e718b --- /dev/null +++ b/packages/core/src/blocks/Audio/block.ts @@ -0,0 +1,172 @@ +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + BlockNoDefaults, + createBlockConfig, + createBlockSpec, +} from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; +import { parseFigureElement } from "../File/helpers/parse/parseFigureElement.js"; +import { createFileBlockWrapper } from "../File/helpers/render/createFileBlockWrapper.js"; +import { createFigureWithCaption } from "../File/helpers/toExternalHTML/createFigureWithCaption.js"; +import { createLinkWithCaption } from "../File/helpers/toExternalHTML/createLinkWithCaption.js"; +import { parseAudioElement } from "./parseAudioElement.js"; + +export const FILE_AUDIO_ICON_SVG = + ''; + +export interface AudioOptions { + icon?: string; +} + +export const createAudioBlockConfig = createBlockConfig( + (_ctx: AudioOptions) => + ({ + type: "audio" as const, + propSchema: { + backgroundColor: defaultProps.backgroundColor, + // File name. + name: { + default: "" as const, + }, + // File url. + url: { + default: "" as const, + }, + // File caption. + caption: { + default: "" as const, + }, + + showPreview: { + default: true, + }, + }, + content: "none", + meta: { + fileBlockAccept: ["audio/*"], + }, + }) as const, +); + +export const audioParse = + (_config: AudioOptions = {}) => + (element: HTMLElement) => { + if (element.tagName === "AUDIO") { + // Ignore if parent figure has already been parsed. + if (element.closest("figure")) { + return undefined; + } + + return parseAudioElement(element as HTMLAudioElement); + } + + if (element.tagName === "FIGURE") { + const parsedFigure = parseFigureElement(element, "audio"); + if (!parsedFigure) { + return undefined; + } + + const { targetElement, caption } = parsedFigure; + + return { + ...parseAudioElement(targetElement as HTMLAudioElement), + caption, + }; + } + + return undefined; + }; + +export const audioRender = + (config: AudioOptions = {}) => + ( + block: BlockNoDefaults< + Record<"audio", ReturnType>, + any, + any + >, + editor: BlockNoteEditor< + Record<"audio", ReturnType>, + any, + any + >, + ) => { + const icon = document.createElement("div"); + icon.innerHTML = config.icon ?? FILE_AUDIO_ICON_SVG; + + const audio = document.createElement("audio"); + audio.className = "bn-audio"; + if (editor.resolveFileUrl) { + editor.resolveFileUrl(block.props.url).then((downloadUrl) => { + audio.src = downloadUrl; + }); + } else { + audio.src = block.props.url; + } + audio.controls = true; + audio.contentEditable = "false"; + audio.draggable = false; + + return createFileBlockWrapper( + block, + editor, + { dom: audio }, + editor.dictionary.file_blocks.audio.add_button_text, + icon.firstElementChild as HTMLElement, + ); + }; + +export const audioToExternalHTML = + (_config: AudioOptions = {}) => + ( + block: BlockNoDefaults< + Record<"audio", ReturnType>, + any, + any + >, + _editor: BlockNoteEditor< + Record<"audio", ReturnType>, + any, + any + >, + ) => { + if (!block.props.url) { + const div = document.createElement("p"); + div.textContent = "Add audio"; + + return { + dom: div, + }; + } + + let audio; + if (block.props.showPreview) { + audio = document.createElement("audio"); + audio.src = block.props.url; + } else { + audio = document.createElement("a"); + audio.href = block.props.url; + audio.textContent = block.props.name || block.props.url; + } + + if (block.props.caption) { + if (block.props.showPreview) { + return createFigureWithCaption(audio, block.props.caption); + } else { + return createLinkWithCaption(audio, block.props.caption); + } + } + + return { + dom: audio, + }; + }; + +export const createAudioBlockSpec = createBlockSpec( + createAudioBlockConfig, +).implementation((config = {}) => ({ + parse: audioParse(config), + render: audioRender(config), + toExternalHTML: audioToExternalHTML(config), + runsBefore: ["file"], +})); diff --git a/packages/core/src/blocks/AudioBlockContent/parseAudioElement.ts b/packages/core/src/blocks/Audio/parseAudioElement.ts similarity index 100% rename from packages/core/src/blocks/AudioBlockContent/parseAudioElement.ts rename to packages/core/src/blocks/Audio/parseAudioElement.ts diff --git a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts deleted file mode 100644 index 7a3e0101fe..0000000000 --- a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { - BlockFromConfig, - createBlockSpec, - FileBlockConfig, - Props, - PropSchema, -} from "../../schema/index.js"; -import { defaultProps } from "../defaultProps.js"; - -import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js"; -import { createFileBlockWrapper } from "../FileBlockContent/helpers/render/createFileBlockWrapper.js"; -import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; -import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { parseAudioElement } from "./parseAudioElement.js"; - -export const FILE_AUDIO_ICON_SVG = - ''; - -export const audioPropSchema = { - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, - - showPreview: { - default: true, - }, -} satisfies PropSchema; - -export const audioBlockConfig = { - type: "audio" as const, - propSchema: audioPropSchema, - content: "none", - isFileBlock: true, - fileBlockAccept: ["audio/*"], -} satisfies FileBlockConfig; - -export const audioRender = ( - block: BlockFromConfig, - editor: BlockNoteEditor, -) => { - const icon = document.createElement("div"); - icon.innerHTML = FILE_AUDIO_ICON_SVG; - - const audio = document.createElement("audio"); - audio.className = "bn-audio"; - if (editor.resolveFileUrl) { - editor.resolveFileUrl(block.props.url).then((downloadUrl) => { - audio.src = downloadUrl; - }); - } else { - audio.src = block.props.url; - } - audio.controls = true; - audio.contentEditable = "false"; - audio.draggable = false; - - return createFileBlockWrapper( - block, - editor, - { dom: audio }, - editor.dictionary.file_blocks.audio.add_button_text, - icon.firstElementChild as HTMLElement, - ); -}; - -export const audioParse = ( - element: HTMLElement, -): Partial> | undefined => { - if (element.tagName === "AUDIO") { - // Ignore if parent figure has already been parsed. - if (element.closest("figure")) { - return undefined; - } - - return parseAudioElement(element as HTMLAudioElement); - } - - if (element.tagName === "FIGURE") { - const parsedFigure = parseFigureElement(element, "audio"); - if (!parsedFigure) { - return undefined; - } - - const { targetElement, caption } = parsedFigure; - - return { - ...parseAudioElement(targetElement as HTMLAudioElement), - caption, - }; - } - - return undefined; -}; - -export const audioToExternalHTML = ( - block: BlockFromConfig, -) => { - if (!block.props.url) { - const div = document.createElement("p"); - div.textContent = "Add audio"; - - return { - dom: div, - }; - } - - let audio; - if (block.props.showPreview) { - audio = document.createElement("audio"); - audio.src = block.props.url; - } else { - audio = document.createElement("a"); - audio.href = block.props.url; - audio.textContent = block.props.name || block.props.url; - } - - if (block.props.caption) { - if (block.props.showPreview) { - return createFigureWithCaption(audio, block.props.caption); - } else { - return createLinkWithCaption(audio, block.props.caption); - } - } - - return { - dom: audio, - }; -}; - -export const AudioBlock = createBlockSpec(audioBlockConfig, { - render: audioRender, - parse: audioParse, - toExternalHTML: audioToExternalHTML, -}); diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts new file mode 100644 index 0000000000..3adef6a51d --- /dev/null +++ b/packages/core/src/blocks/Code/block.ts @@ -0,0 +1,270 @@ +import type { HighlighterGeneric } from "@shikijs/types"; +import { + createBlockConfig, + createBlockSpec, + createBlockNoteExtension, +} from "../../schema/index.js"; +import { lazyShikiPlugin } from "./shiki.js"; + +export type CodeBlockOptions = { + /** + * Whether to indent lines with a tab when the user presses `Tab` in a code block. + * + * @default true + */ + indentLineWithTab?: boolean; + /** + * The default language to use for code blocks. + * + * @default "text" + */ + defaultLanguage?: string; + /** + * The languages that are supported in the editor. + * + * @example + * { + * javascript: { + * name: "JavaScript", + * aliases: ["js"], + * }, + * typescript: { + * name: "TypeScript", + * aliases: ["ts"], + * }, + * } + */ + supportedLanguages?: Record< + string, + { + /** + * The display name of the language. + */ + name: string; + /** + * Aliases for this language. + */ + aliases?: string[]; + } + >; + /** + * The highlighter to use for code blocks. + */ + createHighlighter?: () => Promise>; +}; + +export const createCodeBlockConfig = createBlockConfig( + ({ defaultLanguage = "text" }: CodeBlockOptions = {}) => + ({ + type: "codeBlock" as const, + propSchema: { + language: { + default: defaultLanguage, + }, + }, + content: "inline", + meta: { + code: true, + defining: true, + }, + }) as const, +); + +export const createCodeBlockSpec = createBlockSpec( + createCodeBlockConfig, +).implementation( + (options = {}) => ({ + parse: (e) => { + const pre = e.querySelector("pre"); + if (!pre) { + return undefined; + } + + return {}; + }, + + render(block, editor) { + const wrapper = document.createDocumentFragment(); + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = block.content as unknown as string; + pre.appendChild(code); + const select = document.createElement("select"); + const selectWrapper = document.createElement("div"); + const handleLanguageChange = (event: Event) => { + const language = (event.target as HTMLSelectElement).value; + + editor.updateBlock(block.id, { props: { language } }); + }; + + Object.entries(options.supportedLanguages ?? {}).forEach( + ([id, { name }]) => { + const option = document.createElement("option"); + + option.value = id; + option.text = name; + select.appendChild(option); + }, + ); + + selectWrapper.contentEditable = "false"; + select.value = block.props.language || options.defaultLanguage || "text"; + wrapper.appendChild(selectWrapper); + wrapper.appendChild(pre); + selectWrapper.appendChild(select); + select.addEventListener("change", handleLanguageChange); + return { + dom: wrapper, + contentDOM: code, + destroy: () => { + select.removeEventListener("change", handleLanguageChange); + }, + }; + }, + toExternalHTML(block) { + const pre = document.createElement("pre"); + pre.className = `language-${block.props.language}`; + pre.dataset.language = block.props.language; + const code = document.createElement("code"); + code.textContent = block.content as unknown as string; + pre.appendChild(code); + return { + dom: pre, + }; + }, + }), + (options = {}) => { + return [ + createBlockNoteExtension({ + key: "code-block-highlighter", + plugins: [lazyShikiPlugin(options)], + }), + createBlockNoteExtension({ + key: "code-block-keyboard-shortcuts", + keyboardShortcuts: { + Delete: ({ editor }) => { + return editor.transact((tr) => { + const { block } = editor.getTextCursorPosition(); + if (block.type !== "codeBlock") { + return false; + } + const { $from } = tr.selection; + + // When inside empty codeblock, on `DELETE` key press, delete the codeblock + if (!$from.parent.textContent) { + editor.removeBlocks([block]); + + return true; + } + + return false; + }); + }, + Tab: ({ editor }) => { + if (options.indentLineWithTab === false) { + return false; + } + + return editor.transact((tr) => { + const { block } = editor.getTextCursorPosition(); + if (block.type === "codeBlock") { + // TODO should probably only tab when at a line start or already tabbed in + tr.insertText(" "); + return true; + } + + return false; + }); + }, + Enter: ({ editor }) => { + return editor.transact((tr) => { + const { block, nextBlock } = editor.getTextCursorPosition(); + if (block.type !== "codeBlock") { + return false; + } + const { $from } = tr.selection; + + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + const endsWithDoubleNewline = + $from.parent.textContent.endsWith("\n\n"); + + // The user is trying to exit the code block by pressing enter at the end of the code block + if (isAtEnd && endsWithDoubleNewline) { + // Remove the double newline + tr.delete($from.pos - 2, $from.pos); + + // If there is a next block, move the cursor to it + if (nextBlock) { + editor.setTextCursorPosition(nextBlock, "start"); + return true; + } + + // If there is no next block, insert a new paragraph + const [newBlock] = editor.insertBlocks( + [{ type: "paragraph" }], + block, + "after", + ); + // Move the cursor to the new block + editor.setTextCursorPosition(newBlock, "start"); + + return true; + } + + tr.insertText("\n"); + return true; + }); + }, + "Shift-Enter": ({ editor }) => { + return editor.transact(() => { + const { block } = editor.getTextCursorPosition(); + if (block.type !== "codeBlock") { + return false; + } + + const [newBlock] = editor.insertBlocks( + // insert a new paragraph + [{ type: "paragraph" }], + block, + "after", + ); + // move the cursor to the new block + editor.setTextCursorPosition(newBlock, "start"); + return true; + }); + }, + }, + inputRules: [ + { + find: /^```(.*?)\s$/, + replace: ({ match }) => { + const languageName = match[1].trim(); + const attributes = { + language: getLanguageId(options, languageName) ?? languageName, + }; + + return { + type: "codeBlock", + props: { + language: attributes.language, + }, + content: [], + }; + }, + }, + ], + }), + ]; + }, +); + +export function getLanguageId( + options: CodeBlockOptions, + languageName: string, +): string | undefined { + return Object.entries(options.supportedLanguages ?? {}).find( + ([id, { aliases }]) => { + return aliases?.includes(languageName) || id === languageName; + }, + )?.[0]; +} diff --git a/packages/core/src/blocks/Code/shiki.ts b/packages/core/src/blocks/Code/shiki.ts new file mode 100644 index 0000000000..bb101abdcc --- /dev/null +++ b/packages/core/src/blocks/Code/shiki.ts @@ -0,0 +1,73 @@ +import type { HighlighterGeneric } from "@shikijs/types"; +import { Parser, createHighlightPlugin } from "prosemirror-highlight"; +import { createParser } from "prosemirror-highlight/shiki"; +import { CodeBlockOptions, getLanguageId } from "./block.js"; + +export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); +export const shikiHighlighterPromiseSymbol = Symbol.for( + "blocknote.shikiHighlighterPromise", +); + +export function lazyShikiPlugin(options: CodeBlockOptions) { + const globalThisForShiki = globalThis as { + [shikiHighlighterPromiseSymbol]?: Promise>; + [shikiParserSymbol]?: Parser; + }; + + let highlighter: HighlighterGeneric | undefined; + let parser: Parser | undefined; + let hasWarned = false; + const lazyParser: Parser = (parserOptions) => { + if (!options.createHighlighter) { + if (process.env.NODE_ENV === "development" && !hasWarned) { + // eslint-disable-next-line no-console + console.log( + "For syntax highlighting of code blocks, you must provide a `codeBlock.createHighlighter` function", + ); + hasWarned = true; + } + return []; + } + if (!highlighter) { + globalThisForShiki[shikiHighlighterPromiseSymbol] = + globalThisForShiki[shikiHighlighterPromiseSymbol] || + options.createHighlighter(); + + return globalThisForShiki[shikiHighlighterPromiseSymbol].then( + (createdHighlighter) => { + highlighter = createdHighlighter; + }, + ); + } + const language = getLanguageId(options, parserOptions.language!); + + if ( + !language || + language === "text" || + language === "none" || + language === "plaintext" || + language === "txt" + ) { + return []; + } + + if (!highlighter.getLoadedLanguages().includes(language)) { + return highlighter.loadLanguage(language); + } + + if (!parser) { + parser = + globalThisForShiki[shikiParserSymbol] || + createParser(highlighter as any); + globalThisForShiki[shikiParserSymbol] = parser; + } + + return parser(parserOptions); + }; + + return createHighlightPlugin({ + parser: lazyParser, + languageExtractor: (node) => node.attrs.language, + nodeTypes: ["codeBlock"], + }); +} diff --git a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts deleted file mode 100644 index e322a83be9..0000000000 --- a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts +++ /dev/null @@ -1,445 +0,0 @@ -import type { HighlighterGeneric } from "@shikijs/types"; -import { InputRule, isTextSelection } from "@tiptap/core"; -import { TextSelection } from "@tiptap/pm/state"; -import { Parser, createHighlightPlugin } from "prosemirror-highlight"; -import { createParser } from "prosemirror-highlight/shiki"; -import { BlockNoteEditor } from "../../index.js"; -import { - PropSchema, - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, -} from "../../schema/index.js"; -import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; - -export type CodeBlockOptions = { - /** - * Whether to indent lines with a tab when the user presses `Tab` in a code block. - * - * @default true - */ - indentLineWithTab?: boolean; - /** - * The default language to use for code blocks. - * - * @default "text" - */ - defaultLanguage?: string; - /** - * The languages that are supported in the editor. - * - * @example - * { - * javascript: { - * name: "JavaScript", - * aliases: ["js"], - * }, - * typescript: { - * name: "TypeScript", - * aliases: ["ts"], - * }, - * } - */ - supportedLanguages: Record< - string, - { - /** - * The display name of the language. - */ - name: string; - /** - * Aliases for this language. - */ - aliases?: string[]; - } - >; - /** - * The highlighter to use for code blocks. - */ - createHighlighter?: () => Promise>; -}; - -type CodeBlockConfigOptions = { - editor: BlockNoteEditor; -}; - -export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); -export const shikiHighlighterPromiseSymbol = Symbol.for( - "blocknote.shikiHighlighterPromise", -); -export const defaultCodeBlockPropSchema = { - language: { - default: "text", - }, -} satisfies PropSchema; - -const CodeBlockContent = createStronglyTypedTiptapNode({ - name: "codeBlock", - content: "inline*", - group: "blockContent", - marks: "insertion deletion modification", - code: true, - defining: true, - addOptions() { - return { - defaultLanguage: "text", - indentLineWithTab: true, - supportedLanguages: {}, - }; - }, - addAttributes() { - const options = this.options as CodeBlockConfigOptions; - - return { - language: { - default: options.editor.settings.codeBlock.defaultLanguage, - parseHTML: (inputElement) => { - let element = inputElement as HTMLElement | null; - let language: string | null = null; - - if ( - element?.tagName === "DIV" && - element?.dataset.contentType === "codeBlock" - ) { - element = element.children[0] as HTMLElement | null; - } - - if (element?.tagName === "PRE") { - element = element?.children[0] as HTMLElement | null; - } - - const dataLanguage = element?.getAttribute("data-language"); - - if (dataLanguage) { - language = dataLanguage.toLowerCase(); - } else { - const classNames = [...(element?.className.split(" ") || [])]; - const languages = classNames - .filter((className) => className.startsWith("language-")) - .map((className) => className.replace("language-", "")); - - if (languages.length > 0) { - language = languages[0].toLowerCase(); - } - } - - if (!language) { - return null; - } - - return ( - getLanguageId(options.editor.settings.codeBlock, language) ?? - language - ); - }, - renderHTML: (attributes) => { - return attributes.language - ? { - class: `language-${attributes.language}`, - "data-language": attributes.language, - } - : {}; - }, - }, - }; - }, - parseHTML() { - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - // Parse from external HTML. - { - tag: "pre", - // contentElement: "code", - preserveWhitespace: "full", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - const pre = document.createElement("pre"); - const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( - this.name, - "code", - this.options.domAttributes?.blockContent || {}, - { - ...(this.options.domAttributes?.inlineContent || {}), - ...HTMLAttributes, - }, - ); - - dom.removeChild(contentDOM); - dom.appendChild(pre); - pre.appendChild(contentDOM); - - return { - dom, - contentDOM, - }; - }, - addNodeView() { - const options = this.options as CodeBlockConfigOptions; - - return ({ editor, node, getPos, HTMLAttributes }) => { - const pre = document.createElement("pre"); - const select = document.createElement("select"); - const selectWrapper = document.createElement("div"); - const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( - this.name, - "code", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - const handleLanguageChange = (event: Event) => { - const language = (event.target as HTMLSelectElement).value; - - editor.commands.command(({ tr }) => { - tr.setNodeAttribute(getPos(), "language", language); - - return true; - }); - }; - - Object.entries( - options.editor.settings.codeBlock.supportedLanguages, - ).forEach(([id, { name }]) => { - const option = document.createElement("option"); - - option.value = id; - option.text = name; - select.appendChild(option); - }); - - selectWrapper.contentEditable = "false"; - select.value = - node.attrs.language || - options.editor.settings.codeBlock.defaultLanguage; - dom.removeChild(contentDOM); - dom.appendChild(selectWrapper); - dom.appendChild(pre); - pre.appendChild(contentDOM); - selectWrapper.appendChild(select); - select.addEventListener("change", handleLanguageChange); - - return { - dom, - contentDOM, - update: (newNode) => { - if (newNode.type !== this.type) { - return false; - } - - return true; - }, - destroy: () => { - select.removeEventListener("change", handleLanguageChange); - }, - }; - }; - }, - addProseMirrorPlugins() { - const options = this.options as CodeBlockConfigOptions; - const globalThisForShiki = globalThis as { - [shikiHighlighterPromiseSymbol]?: Promise>; - [shikiParserSymbol]?: Parser; - }; - - let highlighter: HighlighterGeneric | undefined; - let parser: Parser | undefined; - let hasWarned = false; - const lazyParser: Parser = (parserOptions) => { - if (!options.editor.settings.codeBlock.createHighlighter) { - if (process.env.NODE_ENV === "development" && !hasWarned) { - // eslint-disable-next-line no-console - console.log( - "For syntax highlighting of code blocks, you must provide a `codeBlock.createHighlighter` function", - ); - hasWarned = true; - } - return []; - } - if (!highlighter) { - globalThisForShiki[shikiHighlighterPromiseSymbol] = - globalThisForShiki[shikiHighlighterPromiseSymbol] || - options.editor.settings.codeBlock.createHighlighter(); - - return globalThisForShiki[shikiHighlighterPromiseSymbol].then( - (createdHighlighter) => { - highlighter = createdHighlighter; - }, - ); - } - const language = getLanguageId( - options.editor.settings.codeBlock, - parserOptions.language!, - ); - - if ( - !language || - language === "text" || - language === "none" || - language === "plaintext" || - language === "txt" - ) { - return []; - } - - if (!highlighter.getLoadedLanguages().includes(language)) { - return highlighter.loadLanguage(language); - } - - if (!parser) { - parser = - globalThisForShiki[shikiParserSymbol] || - createParser(highlighter as any); - globalThisForShiki[shikiParserSymbol] = parser; - } - - return parser(parserOptions); - }; - - const shikiLazyPlugin = createHighlightPlugin({ - parser: lazyParser, - languageExtractor: (node) => node.attrs.language, - nodeTypes: [this.name], - }); - - return [shikiLazyPlugin]; - }, - addInputRules() { - const options = this.options as CodeBlockConfigOptions; - - return [ - new InputRule({ - find: /^```(.*?)\s$/, - handler: ({ state, range, match }) => { - const $start = state.doc.resolve(range.from); - const languageName = match[1].trim(); - const attributes = { - language: - getLanguageId(options.editor.settings.codeBlock, languageName) ?? - languageName, - }; - - if ( - !$start - .node(-1) - .canReplaceWith( - $start.index(-1), - $start.indexAfter(-1), - this.type, - ) - ) { - return null; - } - - state.tr - .delete(range.from, range.to) - .setBlockType(range.from, range.from, this.type, attributes) - .setSelection(TextSelection.create(state.tr.doc, range.from)); - - return; - }, - }), - ]; - }, - addKeyboardShortcuts() { - return { - Delete: ({ editor }) => { - const { selection } = editor.state; - const { $from } = selection; - - // When inside empty codeblock, on `DELETE` key press, delete the codeblock - if ( - editor.isActive(this.name) && - !$from.parent.textContent && - isTextSelection(selection) - ) { - // Get the start position of the codeblock for node selection - const from = $from.pos - $from.parentOffset - 2; - - editor.chain().setNodeSelection(from).deleteSelection().run(); - - return true; - } - - return false; - }, - Tab: ({ editor }) => { - if (!this.options.indentLineWithTab) { - return false; - } - if (editor.isActive(this.name)) { - editor.commands.insertContent(" "); - return true; - } - - return false; - }, - Enter: ({ editor }) => { - const { $from } = editor.state.selection; - - if (!editor.isActive(this.name)) { - return false; - } - - const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; - const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n"); - - if (!isAtEnd || !endsWithDoubleNewline) { - editor.commands.insertContent("\n"); - return true; - } - - return editor - .chain() - .command(({ tr }) => { - tr.delete($from.pos - 2, $from.pos); - - return true; - }) - .exitCode() - .run(); - }, - "Shift-Enter": ({ editor }) => { - const { $from } = editor.state.selection; - - if (!editor.isActive(this.name)) { - return false; - } - - editor - .chain() - .insertContentAt( - $from.pos - $from.parentOffset + $from.parent.nodeSize, - { - type: "paragraph", - }, - ) - .run(); - - return true; - }, - }; - }, -}); - -export const CodeBlock = createBlockSpecFromStronglyTypedTiptapNode( - CodeBlockContent, - defaultCodeBlockPropSchema, -); - -function getLanguageId( - options: CodeBlockOptions, - languageName: string, -): string | undefined { - return Object.entries(options.supportedLanguages).find( - ([id, { aliases }]) => { - return aliases?.includes(languageName) || id === languageName; - }, - )?.[0]; -} diff --git a/packages/core/src/blocks/File/block.ts b/packages/core/src/blocks/File/block.ts new file mode 100644 index 0000000000..76541621b6 --- /dev/null +++ b/packages/core/src/blocks/File/block.ts @@ -0,0 +1,124 @@ +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + BlockNoDefaults, + createBlockConfig, + createBlockSpec, +} from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; +import { parseEmbedElement } from "./helpers/parse/parseEmbedElement.js"; +import { parseFigureElement } from "./helpers/parse/parseFigureElement.js"; +import { createFileBlockWrapper } from "./helpers/render/createFileBlockWrapper.js"; +import { createLinkWithCaption } from "./helpers/toExternalHTML/createLinkWithCaption.js"; + +export const createFileBlockConfig = createBlockConfig( + () => + ({ + type: "file" as const, + propSchema: { + backgroundColor: defaultProps.backgroundColor, + // File name. + name: { + default: "" as const, + }, + // File url. + url: { + default: "" as const, + }, + // File caption. + caption: { + default: "" as const, + }, + }, + content: "none" as const, + meta: { + fileBlockAccept: ["*/*"], + }, + }) as const, +); + +export const fileParse = () => (element: HTMLElement) => { + if (element.tagName === "EMBED") { + // Ignore if parent figure has already been parsed. + if (element.closest("figure")) { + return undefined; + } + + return parseEmbedElement(element as HTMLEmbedElement); + } + + if (element.tagName === "FIGURE") { + const parsedFigure = parseFigureElement(element, "embed"); + if (!parsedFigure) { + return undefined; + } + + const { targetElement, caption } = parsedFigure; + + return { + ...parseEmbedElement(targetElement as HTMLEmbedElement), + caption, + }; + } + + return undefined; +}; + +export const fileRender = + () => + ( + block: BlockNoDefaults< + Record<"file", ReturnType>, + any, + any + >, + editor: BlockNoteEditor< + Record<"file", ReturnType>, + any, + any + >, + ) => + createFileBlockWrapper(block, editor); + +export const fileToExternalHTML = + () => + ( + block: BlockNoDefaults< + Record<"file", ReturnType>, + any, + any + >, + _editor: BlockNoteEditor< + Record<"file", ReturnType>, + any, + any + >, + ) => { + if (!block.props.url) { + const div = document.createElement("p"); + div.textContent = "Add file"; + + return { + dom: div, + }; + } + + const fileSrcLink = document.createElement("a"); + fileSrcLink.href = block.props.url; + fileSrcLink.textContent = block.props.name || block.props.url; + + if (block.props.caption) { + return createLinkWithCaption(fileSrcLink, block.props.caption); + } + + return { + dom: fileSrcLink, + }; + }; + +export const createFileBlockSpec = createBlockSpec( + createFileBlockConfig, +).implementation(() => ({ + parse: fileParse(), + render: fileRender(), + toExternalHTML: fileToExternalHTML(), +})); diff --git a/packages/core/src/blocks/FileBlockContent/helpers/parse/parseEmbedElement.ts b/packages/core/src/blocks/File/helpers/parse/parseEmbedElement.ts similarity index 100% rename from packages/core/src/blocks/FileBlockContent/helpers/parse/parseEmbedElement.ts rename to packages/core/src/blocks/File/helpers/parse/parseEmbedElement.ts diff --git a/packages/core/src/blocks/FileBlockContent/helpers/parse/parseFigureElement.ts b/packages/core/src/blocks/File/helpers/parse/parseFigureElement.ts similarity index 100% rename from packages/core/src/blocks/FileBlockContent/helpers/parse/parseFigureElement.ts rename to packages/core/src/blocks/File/helpers/parse/parseFigureElement.ts diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts b/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts similarity index 92% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts rename to packages/core/src/blocks/File/helpers/render/createAddFileButton.ts index 0c026257ea..028d69a6a5 100644 --- a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts +++ b/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts @@ -1,8 +1,11 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; -import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js"; +import { + BlockConfig, + BlockFromConfigNoChildren, +} from "../../../../schema/index.js"; export const createAddFileButton = ( - block: BlockFromConfig, + block: BlockFromConfigNoChildren, any, any>, editor: BlockNoteEditor, buttonText?: string, buttonIcon?: HTMLElement, diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts b/packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts similarity index 84% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts rename to packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts index 02af916f2d..5e9da7932d 100644 --- a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts +++ b/packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts @@ -1,19 +1,28 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { - BlockFromConfig, - BlockSchemaWithBlock, - FileBlockConfig, + BlockConfig, + BlockFromConfigNoChildren, } from "../../../../schema/index.js"; import { createAddFileButton } from "./createAddFileButton.js"; import { createFileNameWithIcon } from "./createFileNameWithIcon.js"; export const createFileBlockWrapper = ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock, + block: BlockFromConfigNoChildren< + BlockConfig< + string, + { + backgroundColor: { default: "default" }; + name: { default: "" }; + url: { default: "" }; + caption: { default: "" }; + showPreview?: { default: true }; + }, + "none" + >, any, any >, + editor: BlockNoteEditor, element?: { dom: HTMLElement; destroy?: () => void }, buttonText?: string, buttonIcon?: HTMLElement, diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts b/packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts similarity index 76% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts rename to packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts index dded8dbde6..05ba0d6281 100644 --- a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts +++ b/packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts @@ -1,9 +1,22 @@ -import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js"; +import { + BlockConfig, + BlockFromConfigNoChildren, +} from "../../../../schema/index.js"; export const FILE_ICON_SVG = ``; export const createFileNameWithIcon = ( - block: BlockFromConfig, + block: BlockFromConfigNoChildren< + BlockConfig< + string, + { + name: { default: "" }; + }, + "none" + >, + any, + any + >, ): { dom: HTMLElement; destroy?: () => void } => { const file = document.createElement("div"); file.className = "bn-file-name-with-icon"; diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts b/packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts similarity index 93% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts rename to packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts index b4c4ae95d7..12ee16ff67 100644 --- a/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts +++ b/packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts @@ -1,9 +1,28 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; -import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js"; +import { + BlockConfig, + BlockFromConfigNoChildren, +} from "../../../../schema/index.js"; import { createFileBlockWrapper } from "./createFileBlockWrapper.js"; export const createResizableFileBlockWrapper = ( - block: BlockFromConfig, + block: BlockFromConfigNoChildren< + BlockConfig< + string, + { + backgroundColor: { default: "default" }; + name: { default: "" }; + url: { default: "" }; + caption: { default: "" }; + showPreview?: { default: true }; + previewWidth?: { default: number }; + textAlignment?: { default: "left" }; + }, + "none" + >, + any, + any + >, editor: BlockNoteEditor, element: { dom: HTMLElement; destroy?: () => void }, resizeHandlesContainerElement: HTMLElement, diff --git a/packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.ts b/packages/core/src/blocks/File/helpers/toExternalHTML/createFigureWithCaption.ts similarity index 100% rename from packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.ts rename to packages/core/src/blocks/File/helpers/toExternalHTML/createFigureWithCaption.ts diff --git a/packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.ts b/packages/core/src/blocks/File/helpers/toExternalHTML/createLinkWithCaption.ts similarity index 100% rename from packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.ts rename to packages/core/src/blocks/File/helpers/toExternalHTML/createLinkWithCaption.ts diff --git a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts deleted file mode 100644 index 433487d8e0..0000000000 --- a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { - BlockFromConfig, - FileBlockConfig, - PropSchema, - createBlockSpec, -} from "../../schema/index.js"; -import { defaultProps } from "../defaultProps.js"; -import { parseEmbedElement } from "./helpers/parse/parseEmbedElement.js"; -import { parseFigureElement } from "./helpers/parse/parseFigureElement.js"; -import { createFileBlockWrapper } from "./helpers/render/createFileBlockWrapper.js"; -import { createLinkWithCaption } from "./helpers/toExternalHTML/createLinkWithCaption.js"; - -export const filePropSchema = { - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, -} satisfies PropSchema; - -export const fileBlockConfig = { - type: "file" as const, - propSchema: filePropSchema, - content: "none", - isFileBlock: true, -} satisfies FileBlockConfig; - -export const fileRender = ( - block: BlockFromConfig, - editor: BlockNoteEditor, -) => { - return createFileBlockWrapper(block, editor); -}; - -export const fileParse = (element: HTMLElement) => { - if (element.tagName === "EMBED") { - // Ignore if parent figure has already been parsed. - if (element.closest("figure")) { - return undefined; - } - - return parseEmbedElement(element as HTMLEmbedElement); - } - - if (element.tagName === "FIGURE") { - const parsedFigure = parseFigureElement(element, "embed"); - if (!parsedFigure) { - return undefined; - } - - const { targetElement, caption } = parsedFigure; - - return { - ...parseEmbedElement(targetElement as HTMLEmbedElement), - caption, - }; - } - - return undefined; -}; - -export const fileToExternalHTML = ( - block: BlockFromConfig, -) => { - if (!block.props.url) { - const div = document.createElement("p"); - div.textContent = "Add file"; - - return { - dom: div, - }; - } - - const fileSrcLink = document.createElement("a"); - fileSrcLink.href = block.props.url; - fileSrcLink.textContent = block.props.name || block.props.url; - - if (block.props.caption) { - return createLinkWithCaption(fileSrcLink, block.props.caption); - } - - return { - dom: fileSrcLink, - }; -}; - -export const FileBlock = createBlockSpec(fileBlockConfig, { - render: fileRender, - parse: fileParse, - toExternalHTML: fileToExternalHTML, -}); diff --git a/packages/core/src/blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts b/packages/core/src/blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts deleted file mode 100644 index ab0a686e82..0000000000 --- a/packages/core/src/blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Uploads a file to tmpfiles.org and returns the URL to the uploaded file. - * - * @warning This function should only be used for development purposes, replace with your own backend! - */ -export const uploadToTmpFilesDotOrg_DEV_ONLY = async ( - file: File, -): Promise => { - const body = new FormData(); - body.append("file", file); - - const ret = await fetch("https://tmpfiles.org/api/v1/upload", { - method: "POST", - body: body, - }); - return (await ret.json()).data.url.replace( - "tmpfiles.org/", - "tmpfiles.org/dl/", - ); -}; diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts new file mode 100644 index 0000000000..e20cd7908f --- /dev/null +++ b/packages/core/src/blocks/Heading/block.ts @@ -0,0 +1,128 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { + createBlockConfig, + createBlockNoteExtension, + createBlockSpec, +} from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; +import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js"; + +const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; + +export interface HeadingOptions { + defaultLevel?: (typeof HEADING_LEVELS)[number]; + levels?: readonly number[]; + // TODO should probably use composition instead of this + allowToggleHeadings?: boolean; +} + +export const createHeadingBlockConfig = createBlockConfig( + ({ + defaultLevel = 1, + levels = HEADING_LEVELS, + allowToggleHeadings = true, + }: HeadingOptions = {}) => + ({ + type: "heading" as const, + propSchema: { + ...defaultProps, + level: { default: defaultLevel, values: levels }, + ...(allowToggleHeadings + ? { isToggleable: { default: false, optional: true } as const } + : {}), + }, + content: "inline", + }) as const, +); + +export const createHeadingBlockSpec = createBlockSpec( + createHeadingBlockConfig, +).implementation( + ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({ + parse(e) { + let level: number; + switch (e.tagName) { + case "H1": + level = 1; + break; + case "H2": + level = 2; + break; + case "H3": + level = 3; + break; + case "H4": + level = 4; + break; + case "H5": + level = 5; + break; + case "H6": + level = 6; + break; + default: + return undefined; + } + + return { + level, + }; + }, + render(block, editor) { + const dom = document.createElement(`h${block.props.level}`); + + if (allowToggleHeadings) { + const toggleWrapper = createToggleWrapper(block, editor, dom); + return { ...toggleWrapper, contentDOM: dom }; + } + + return { + dom, + contentDOM: dom, + }; + }, + }), + ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [ + createBlockNoteExtension({ + key: "heading-shortcuts", + keyboardShortcuts: Object.fromEntries( + levels.map((level) => [ + `Mod-Alt-${level}`, + ({ editor }) => + editor.transact((tr) => { + // TODO this is weird, why do we need it? + // https://github.com/TypeCellOS/BlockNote/pull/561 + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return true; + } + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, { + type: "heading", + props: { + level: level as any, + }, + }); + return true; + }), + ]) ?? [], + ), + inputRules: levels.map((level) => ({ + find: new RegExp(`^(#{${level}})\\s$`), + replace({ match }: { match: RegExpMatchArray }) { + return { + type: "heading", + props: { + level: match[1].length, + }, + }; + }, + })), + }), + ], +); diff --git a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts deleted file mode 100644 index 5b8d7003d0..0000000000 --- a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { InputRule } from "@tiptap/core"; -import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { - PropSchema, - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, - getBlockFromPos, - propsToAttributes, -} from "../../schema/index.js"; -import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; -import { defaultProps } from "../defaultProps.js"; -import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; - -const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; - -export const headingPropSchema = { - ...defaultProps, - level: { default: 1, values: HEADING_LEVELS }, - isToggleable: { default: false }, -} satisfies PropSchema; - -const HeadingBlockContent = createStronglyTypedTiptapNode({ - name: "heading", - content: "inline*", - group: "blockContent", - - addAttributes() { - return propsToAttributes(headingPropSchema); - }, - - addInputRules() { - const editor = this.options.editor as BlockNoteEditor; - return [ - ...editor.settings.heading.levels.map((level) => { - // Creates a heading of appropriate level when starting with "#", "##", or "###". - return new InputRule({ - find: new RegExp(`^(#{${level}})\\s$`), - handler: ({ state, chain, range }) => { - const blockInfo = getBlockInfoFromSelection(state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return; - } - - chain() - .command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "heading", - props: { - level: level as any, - }, - }), - ) - // Removes the "#" character(s) used to set the heading. - .deleteRange({ from: range.from, to: range.to }) - .run(); - }, - }); - }), - ]; - }, - - addKeyboardShortcuts() { - const editor = this.options.editor as BlockNoteEditor; - - return Object.fromEntries( - editor.settings.heading.levels.map((level) => [ - `Mod-Alt-${level}`, - () => { - const blockInfo = getBlockInfoFromSelection(this.editor.state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return true; - } - - return this.editor.commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "heading", - props: { - level: level as any, - }, - }), - ); - }, - ]), - ); - }, - parseHTML() { - const editor = this.options.editor as BlockNoteEditor; - - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - ...editor.settings.heading.levels.map((level) => ({ - tag: `h${level}`, - attrs: { level }, - node: "heading", - })), - ]; - }, - - renderHTML({ node, HTMLAttributes }) { - return createDefaultBlockDOMOutputSpec( - this.name, - `h${node.attrs.level}`, - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - }, - - addNodeView() { - return ({ node, HTMLAttributes, getPos }) => { - const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( - this.name, - `h${node.attrs.level}`, - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - dom.removeChild(contentDOM); - - const editor = this.options.editor; - const block = getBlockFromPos(getPos, editor, this.editor, this.name); - - const toggleWrapper = createToggleWrapper( - block as any, - editor, - contentDOM, - ); - dom.appendChild(toggleWrapper.dom); - - return { - dom, - contentDOM, - ignoreMutation: toggleWrapper.ignoreMutation, - destroy: toggleWrapper.destroy, - }; - }; - }, -}); - -export const Heading = createBlockSpecFromStronglyTypedTiptapNode( - HeadingBlockContent, - headingPropSchema, -); diff --git a/packages/core/src/blocks/Image/block.ts b/packages/core/src/blocks/Image/block.ts new file mode 100644 index 0000000000..2a05a1a05d --- /dev/null +++ b/packages/core/src/blocks/Image/block.ts @@ -0,0 +1,187 @@ +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + BlockNoDefaults, + createBlockConfig, + createBlockSpec, +} from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; +import { parseFigureElement } from "../File/helpers/parse/parseFigureElement.js"; +import { createResizableFileBlockWrapper } from "../File/helpers/render/createResizableFileBlockWrapper.js"; +import { createFigureWithCaption } from "../File/helpers/toExternalHTML/createFigureWithCaption.js"; +import { createLinkWithCaption } from "../File/helpers/toExternalHTML/createLinkWithCaption.js"; +import { parseImageElement } from "./parseImageElement.js"; + +export const FILE_IMAGE_ICON_SVG = + ''; + +export interface ImageOptions { + icon?: string; +} +export const createImageBlockConfig = createBlockConfig( + (_ctx: ImageOptions = {}) => + ({ + type: "image" as const, + propSchema: { + textAlignment: defaultProps.textAlignment, + backgroundColor: defaultProps.backgroundColor, + // File name. + name: { + default: "" as const, + }, + // File url. + url: { + default: "" as const, + }, + // File caption. + caption: { + default: "" as const, + }, + + showPreview: { + default: true, + }, + // File preview width in px. + previewWidth: { + default: undefined, + type: "number" as const, + }, + }, + content: "none" as const, + meta: { + fileBlockAccept: ["image/*"], + }, + }) as const, +); + +export const imageParse = + (_config: ImageOptions = {}) => + (element: HTMLElement) => { + if (element.tagName === "IMG") { + // Ignore if parent figure has already been parsed. + if (element.closest("figure")) { + return undefined; + } + + return parseImageElement(element as HTMLImageElement); + } + + if (element.tagName === "FIGURE") { + const parsedFigure = parseFigureElement(element, "img"); + if (!parsedFigure) { + return undefined; + } + + const { targetElement, caption } = parsedFigure; + + return { + ...parseImageElement(targetElement as HTMLImageElement), + caption, + }; + } + + return undefined; + }; + +export const imageRender = + (config: ImageOptions = {}) => + ( + block: BlockNoDefaults< + Record<"image", ReturnType>, + any, + any + >, + editor: BlockNoteEditor< + Record<"image", ReturnType>, + any, + any + >, + ) => { + const icon = document.createElement("div"); + icon.innerHTML = config.icon ?? FILE_IMAGE_ICON_SVG; + + const imageWrapper = document.createElement("div"); + imageWrapper.className = "bn-visual-media-wrapper"; + + const image = document.createElement("img"); + image.className = "bn-visual-media"; + if (editor.resolveFileUrl) { + editor.resolveFileUrl(block.props.url).then((downloadUrl) => { + image.src = downloadUrl; + }); + } else { + image.src = block.props.url; + } + + image.alt = block.props.name || block.props.caption || "BlockNote image"; + image.contentEditable = "false"; + image.draggable = false; + imageWrapper.appendChild(image); + + return createResizableFileBlockWrapper( + block, + editor, + { dom: imageWrapper }, + imageWrapper, + editor.dictionary.file_blocks.image.add_button_text, + icon.firstElementChild as HTMLElement, + ); + }; + +export const imageToExternalHTML = + (_config: ImageOptions = {}) => + ( + block: BlockNoDefaults< + Record<"image", ReturnType>, + any, + any + >, + _editor: BlockNoteEditor< + Record<"image", ReturnType>, + any, + any + >, + ) => { + if (!block.props.url) { + const div = document.createElement("p"); + div.textContent = "Add image"; + + return { + dom: div, + }; + } + + let image; + if (block.props.showPreview) { + image = document.createElement("img"); + image.src = block.props.url; + image.alt = block.props.name || block.props.caption || "BlockNote image"; + if (block.props.previewWidth) { + image.width = block.props.previewWidth; + } + } else { + image = document.createElement("a"); + image.href = block.props.url; + image.textContent = block.props.name || block.props.url; + } + + if (block.props.caption) { + if (block.props.showPreview) { + return createFigureWithCaption(image, block.props.caption); + } else { + return createLinkWithCaption(image, block.props.caption); + } + } + + return { + dom: image, + }; + }; + +export const createImageBlockSpec = createBlockSpec( + createImageBlockConfig, +).implementation((config = {}) => ({ + parse: imageParse(config), + render: imageRender(config), + toExternalHTML: imageToExternalHTML(config), + runsBefore: ["file"], +})); diff --git a/packages/core/src/blocks/ImageBlockContent/parseImageElement.ts b/packages/core/src/blocks/Image/parseImageElement.ts similarity index 100% rename from packages/core/src/blocks/ImageBlockContent/parseImageElement.ts rename to packages/core/src/blocks/Image/parseImageElement.ts diff --git a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts deleted file mode 100644 index 32b7338640..0000000000 --- a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { - BlockFromConfig, - createBlockSpec, - FileBlockConfig, - Props, - PropSchema, -} from "../../schema/index.js"; -import { defaultProps } from "../defaultProps.js"; -import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js"; -import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; -import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; -import { parseImageElement } from "./parseImageElement.js"; - -export const FILE_IMAGE_ICON_SVG = - ''; - -export const imagePropSchema = { - textAlignment: defaultProps.textAlignment, - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, - - showPreview: { - default: true, - }, - // File preview width in px. - previewWidth: { - default: undefined, - type: "number", - }, -} satisfies PropSchema; - -export const imageBlockConfig = { - type: "image" as const, - propSchema: imagePropSchema, - content: "none", - isFileBlock: true, - fileBlockAccept: ["image/*"], -} satisfies FileBlockConfig; - -export const imageRender = ( - block: BlockFromConfig, - editor: BlockNoteEditor, -) => { - const icon = document.createElement("div"); - icon.innerHTML = FILE_IMAGE_ICON_SVG; - - const imageWrapper = document.createElement("div"); - imageWrapper.className = "bn-visual-media-wrapper"; - - const image = document.createElement("img"); - image.className = "bn-visual-media"; - if (editor.resolveFileUrl) { - editor.resolveFileUrl(block.props.url).then((downloadUrl) => { - image.src = downloadUrl; - }); - } else { - image.src = block.props.url; - } - - image.alt = block.props.name || block.props.caption || "BlockNote image"; - image.contentEditable = "false"; - image.draggable = false; - imageWrapper.appendChild(image); - - return createResizableFileBlockWrapper( - block, - editor, - { dom: imageWrapper }, - imageWrapper, - editor.dictionary.file_blocks.image.add_button_text, - icon.firstElementChild as HTMLElement, - ); -}; - -export const imageParse = ( - element: HTMLElement, -): Partial> | undefined => { - if (element.tagName === "IMG") { - // Ignore if parent figure has already been parsed. - if (element.closest("figure")) { - return undefined; - } - - return parseImageElement(element as HTMLImageElement); - } - - if (element.tagName === "FIGURE") { - const parsedFigure = parseFigureElement(element, "img"); - if (!parsedFigure) { - return undefined; - } - - const { targetElement, caption } = parsedFigure; - - return { - ...parseImageElement(targetElement as HTMLImageElement), - caption, - }; - } - - return undefined; -}; - -export const imageToExternalHTML = ( - block: BlockFromConfig, -) => { - if (!block.props.url) { - const div = document.createElement("p"); - div.textContent = "Add image"; - - return { - dom: div, - }; - } - - let image; - if (block.props.showPreview) { - image = document.createElement("img"); - image.src = block.props.url; - image.alt = block.props.name || block.props.caption || "BlockNote image"; - if (block.props.previewWidth) { - image.width = block.props.previewWidth; - } - } else { - image = document.createElement("a"); - image.href = block.props.url; - image.textContent = block.props.name || block.props.url; - } - - if (block.props.caption) { - if (block.props.showPreview) { - return createFigureWithCaption(image, block.props.caption); - } else { - return createLinkWithCaption(image, block.props.caption); - } - } - - return { - dom: image, - }; -}; - -export const ImageBlock = createBlockSpec(imageBlockConfig, { - render: imageRender, - parse: imageParse, - toExternalHTML: imageToExternalHTML, -}); diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts new file mode 100644 index 0000000000..9f2658b13e --- /dev/null +++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts @@ -0,0 +1,102 @@ +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; +import { + createBlockConfig, + createBlockSpec, + createBlockNoteExtension, +} from "../../../schema/index.js"; +import { defaultProps } from "../../defaultProps.js"; +import { handleEnter } from "../../utils/listItemEnterHandler.js"; +import { getListItemContent } from "../getListItemContent.js"; + +export const createBulletListItemBlockConfig = createBlockConfig( + () => + ({ + type: "bulletListItem" as const, + propSchema: { + ...defaultProps, + }, + content: "inline", + }) as const, +); + +export const createBulletListItemBlockSpec = createBlockSpec( + createBulletListItemBlockConfig, +).implementation( + () => ({ + parse(element) { + if (element.tagName !== "LI") { + return false; + } + + const parent = element.parentElement; + + if (parent === null) { + return false; + } + + if ( + parent.tagName === "UL" || + (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL") + ) { + return {}; + } + + return false; + }, + // As `li` elements can contain multiple paragraphs, we need to merge their contents + // into a single one so that ProseMirror can parse everything correctly. + parseContent: ({ el, schema }) => + getListItemContent(el, schema, "bulletListItem"), + render() { + // We use a

tag, because for

  • tags we'd need a
      element to put + // them in to be semantically correct, which we can't have due to the + // schema. + const dom = document.createElement("p"); + + return { + dom, + contentDOM: dom, + }; + }, + }), + () => [ + createBlockNoteExtension({ + key: "bullet-list-item-shortcuts", + keyboardShortcuts: { + Enter: ({ editor }) => { + return handleEnter(editor, "bulletListItem"); + }, + "Mod-Shift-8": ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return true; + } + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, { + type: "bulletListItem", + props: {}, + }); + return true; + }), + }, + inputRules: [ + { + find: new RegExp(`^[-+*]\\s$`), + replace() { + return { + type: "bulletListItem", + props: {}, + content: [], + }; + }, + }, + ], + }), + ], +); diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts new file mode 100644 index 0000000000..3528eb7a1d --- /dev/null +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts @@ -0,0 +1,147 @@ +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; +import { + createBlockConfig, + createBlockSpec, + createBlockNoteExtension, +} from "../../../schema/index.js"; +import { defaultProps } from "../../defaultProps.js"; +import { handleEnter } from "../../utils/listItemEnterHandler.js"; +import { getListItemContent } from "../getListItemContent.js"; + +export const createCheckListItemConfig = createBlockConfig( + () => + ({ + type: "checkListItem" as const, + propSchema: { + ...defaultProps, + checked: { default: false, type: "boolean" }, + }, + content: "inline", + }) as const, +); + +export const createCheckListItemBlockSpec = createBlockSpec( + createCheckListItemConfig, +).implementation( + () => ({ + parse(element) { + if (element.tagName === "input") { + // Ignore if we already parsed an ancestor list item to avoid double-parsing. + if (element.closest("[data-content-type]") || element.closest("li")) { + return; + } + + if ((element as HTMLInputElement).type === "checkbox") { + return { checked: (element as HTMLInputElement).checked }; + } + return; + } + if (element.tagName !== "LI") { + return; + } + + const parent = element.parentElement; + + if (parent === null) { + return; + } + + if ( + parent.tagName === "UL" || + (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL") + ) { + const checkbox = + (element.querySelector("input[type=checkbox]") as HTMLInputElement) || + null; + + if (checkbox === null) { + return; + } + + return { checked: checkbox.checked }; + } + + return; + }, + // As `li` elements can contain multiple paragraphs, we need to merge their contents + // into a single one so that ProseMirror can parse everything correctly. + parseContent: ({ el, schema }) => + getListItemContent(el, schema, "checkListItem"), + render(block) { + const dom = document.createDocumentFragment(); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = block.props.checked; + if (block.props.checked) { + checkbox.setAttribute("checked", ""); + } + // We use a

      tag, because for

    • tags we'd need a
        element to put + // them in to be semantically correct, which we can't have due to the + // schema. + const paragraph = document.createElement("p"); + + dom.appendChild(checkbox); + dom.appendChild(paragraph); + + return { + dom, + contentDOM: paragraph, + }; + }, + runsBefore: ["bulletListItem"], + }), + () => [ + createBlockNoteExtension({ + key: "check-list-item-shortcuts", + keyboardShortcuts: { + Enter: ({ editor }) => { + return handleEnter(editor, "checkListItem"); + }, + "Mod-Shift-9": ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return true; + } + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, { + type: "checkListItem", + props: {}, + }); + return true; + }), + }, + inputRules: [ + { + find: new RegExp(`\\[\\s*\\]\\s$`), + replace() { + return { + type: "checkListItem", + props: { + checked: false, + }, + content: [], + }; + }, + }, + { + find: new RegExp(`\\[[Xx]\\]\\s$`), + replace() { + return { + type: "checkListItem", + props: { + checked: true, + }, + content: [], + }; + }, + }, + ], + }), + ], +); diff --git a/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts similarity index 100% rename from packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts rename to packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts new file mode 100644 index 0000000000..64a2e8620c --- /dev/null +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -0,0 +1,175 @@ +import type { Node } from "@tiptap/pm/model"; +import type { Transaction } from "@tiptap/pm/state"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js"; + +// Loosely based on https://github.com/ueberdosis/tiptap/blob/7ac01ef0b816a535e903b5ca92492bff110a71ae/packages/extension-mathematics/src/MathematicsPlugin.ts (MIT) + +type DecoSpec = { + index: number; + isFirst: boolean; + hasStart: boolean; + side: number; +}; + +type Deco = Omit & { spec: DecoSpec }; + +/** + * Calculate the index for a numbered list item based on its position and previous siblings + */ +function calculateListItemIndex( + node: Node, + pos: number, + tr: Transaction, + map: Map, +): { index: number; isFirst: boolean; hasStart: boolean } { + let index: number = node.firstChild!.attrs["start"] || 1; + let isFirst = true; + const hasStart = !!node.firstChild!.attrs["start"]; + + const blockInfo = getBlockInfo({ + posBeforeNode: pos, + node, + }); + + if (!blockInfo.isBlockContainer) { + throw new Error("impossible"); + } + + // Check if this block is the start of a new ordered list + const prevBlock = tr.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore; + const prevBlockIndex = prevBlock ? map.get(prevBlock) : undefined; + + if (prevBlockIndex !== undefined) { + index = prevBlockIndex + 1; + isFirst = false; + } else if (prevBlock) { + // Because we only check the affected ranges, we may need to walk backwards to find the previous block's index + // We can't just rely on the map, because the map is reset every `apply` call + const prevBlockInfo = getBlockInfo({ + posBeforeNode: blockInfo.bnBlock.beforePos - prevBlock.nodeSize, + node: prevBlock, + }); + + const isPrevBlockOrderedListItem = + prevBlockInfo.blockNoteType === "numberedListItem"; + if (isPrevBlockOrderedListItem) { + // recurse to get the index of the previous block + const itemIndex = calculateListItemIndex( + prevBlock, + blockInfo.bnBlock.beforePos - prevBlock.nodeSize, + tr, + map, + ); + index = itemIndex.index + 1; + isFirst = false; + } + } + // Note: we set the map late, so that when we recurse, we can rely on the map to get the previous block's index in one lookup + map.set(node, index); + + return { index, isFirst, hasStart }; +} + +/** + * Get the decorations for the current state based on the previous state, + * and the transaction that was applied to get to the current state + */ +function getDecorations( + tr: Transaction, + previousPluginState: { decorations: DecorationSet }, +) { + const map = new Map(); + + const nextDecorationSet = previousPluginState.decorations.map( + tr.mapping, + tr.doc, + ); + const decorationsToAdd = [] as Deco[]; + + tr.doc.nodesBetween(0, tr.doc.nodeSize - 2, (node, pos) => { + if ( + node.type.name === "blockContainer" && + node.firstChild!.type.name === "numberedListItem" + ) { + const { index, isFirst, hasStart } = calculateListItemIndex( + node, + pos, + tr, + map, + ); + + // Check if decoration already exists with the same properties (for perf reasons) + const existingDecorations = nextDecorationSet.find( + pos, + pos + node.nodeSize, + (deco: DecoSpec) => + deco.index === index && + deco.isFirst === isFirst && + deco.hasStart === hasStart, + ); + + if (existingDecorations.length === 0) { + // Create a widget decoration to display the index + decorationsToAdd.push( + // move in by 1 to account for the block container + Decoration.node(pos + 1, pos + node.nodeSize - 1, { + "data-index": index.toString(), + // TODO figure out start? is this needed? + // "data-start": hasStart ? index.toString() : undefined, + }), + ); + } + } + }); + + // Remove any decorations that exist at the same position, they will be replaced by the new decorations + const decorationsToRemove = decorationsToAdd.flatMap((deco) => + nextDecorationSet.find(deco.from, deco.to), + ); + + return { + decorations: nextDecorationSet + // Remove existing decorations that are going to be replaced + .remove(decorationsToRemove) + // Add any new decorations + .add(tr.doc, decorationsToAdd), + }; +} + +/** + * This plugin adds decorations to numbered list items to display their index. + */ +export const NumberedListIndexingDecorationPlugin = () => { + return new Plugin<{ decorations: DecorationSet }>({ + key: new PluginKey("numbered-list-indexing-decorations"), + + state: { + init(_config, state) { + // We create an empty transaction to get the decorations for the initial state based on the initial content + return getDecorations(state.tr, { + decorations: DecorationSet.empty, + }); + }, + apply(tr, previousPluginState) { + if ( + !tr.docChanged && + !tr.selectionSet && + previousPluginState.decorations + ) { + // Just reuse the existing decorations, since nothing should have changed + return previousPluginState; + } + return getDecorations(tr, previousPluginState); + }, + }, + + props: { + decorations(state) { + return this.getState(state)?.decorations ?? DecorationSet.empty; + }, + }, + }); +}; diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts new file mode 100644 index 0000000000..5032e261b4 --- /dev/null +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -0,0 +1,107 @@ +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; +import { + createBlockConfig, + createBlockSpec, + createBlockNoteExtension, +} from "../../../schema/index.js"; +import { defaultProps } from "../../defaultProps.js"; +import { handleEnter } from "../../utils/listItemEnterHandler.js"; +import { getListItemContent } from "../getListItemContent.js"; +import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; + +export const createNumberedListItemBlockConfig = createBlockConfig( + () => + ({ + type: "numberedListItem" as const, + propSchema: { + ...defaultProps, + start: { default: undefined, type: "number" } as const, + }, + content: "inline", + }) as const, +); + +export const createNumberedListItemBlockSpec = createBlockSpec( + createNumberedListItemBlockConfig, +).implementation( + () => ({ + parse(element) { + if (element.tagName !== "LI") { + return false; + } + + const parent = element.parentElement; + + if (parent === null) { + return false; + } + + if ( + parent.tagName === "UL" || + (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL") + ) { + return {}; + } + + return false; + }, + // As `li` elements can contain multiple paragraphs, we need to merge their contents + // into a single one so that ProseMirror can parse everything correctly. + parseContent: ({ el, schema }) => + getListItemContent(el, schema, "numberedListItem"), + render() { + // We use a

        tag, because for

      • tags we'd need a
          element to put + // them in to be semantically correct, which we can't have due to the + // schema. + const dom = document.createElement("p"); + + return { + dom, + contentDOM: dom, + }; + }, + }), + () => [ + createBlockNoteExtension({ + key: "numbered-list-item-shortcuts", + inputRules: [ + { + find: new RegExp(`^(\\d+)\\.\\s$`), + replace({ match }) { + return { + type: "numberedListItem", + props: { + start: parseInt(match[1]), + }, + content: [], + }; + }, + }, + ], + keyboardShortcuts: { + Enter: ({ editor }) => { + return handleEnter(editor, "numberedListItem"); + }, + "Mod-Shift-7": ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return true; + } + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, { + type: "numberedListItem", + props: {}, + }); + return true; + }), + }, + plugins: [NumberedListIndexingDecorationPlugin()], + }), + ], +); diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts new file mode 100644 index 0000000000..b2f2d31b04 --- /dev/null +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -0,0 +1,64 @@ +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; +import { + createBlockConfig, + createBlockSpec, + createBlockNoteExtension, +} from "../../../schema/index.js"; +import { defaultProps } from "../../defaultProps.js"; +import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js"; +import { handleEnter } from "../../utils/listItemEnterHandler.js"; + +export const createToggleListItemBlockConfig = createBlockConfig( + () => + ({ + type: "toggleListItem" as const, + propSchema: { + ...defaultProps, + }, + content: "inline" as const, + }) as const, +); + +export const createToggleListItemBlockSpec = createBlockSpec( + createToggleListItemBlockConfig, +).implementation( + () => ({ + render(block, editor) { + const paragraphEl = document.createElement("p"); + const toggleWrapper = createToggleWrapper( + block as any, + editor, + paragraphEl, + ); + return { ...toggleWrapper, contentDOM: paragraphEl }; + }, + }), + () => [ + createBlockNoteExtension({ + key: "toggle-list-item-shortcuts", + keyboardShortcuts: { + Enter: ({ editor }) => { + return handleEnter(editor, "toggleListItem"); + }, + "Mod-Shift-6": ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return true; + } + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, { + type: "toggleListItem", + props: {}, + }); + return true; + }), + }, + }), + ], +); diff --git a/packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts b/packages/core/src/blocks/ListItem/getListItemContent.ts similarity index 100% rename from packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts rename to packages/core/src/blocks/ListItem/getListItemContent.ts diff --git a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts deleted file mode 100644 index 767558095d..0000000000 --- a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { InputRule } from "@tiptap/core"; -import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; -import { - PropSchema, - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, -} from "../../../schema/index.js"; -import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js"; -import { defaultProps } from "../../defaultProps.js"; -import { getListItemContent } from "../getListItemContent.js"; -import { handleEnter } from "../ListItemKeyboardShortcuts.js"; - -export const bulletListItemPropSchema = { - ...defaultProps, -} satisfies PropSchema; - -const BulletListItemBlockContent = createStronglyTypedTiptapNode({ - name: "bulletListItem", - content: "inline*", - group: "blockContent", - // This is to make sure that check list parse rules run before, since they - // both parse `li` elements but check lists are more specific. - priority: 90, - addInputRules() { - return [ - // Creates an unordered list when starting with "-", "+", or "*". - new InputRule({ - find: new RegExp(`^[-+*]\\s$`), - handler: ({ state, chain, range }) => { - const blockInfo = getBlockInfoFromSelection(state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" || - blockInfo.blockNoteType === "heading" - ) { - return; - } - - chain() - .command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "bulletListItem", - props: {}, - }), - ) - // Removes the "-", "+", or "*" character used to set the list. - .deleteRange({ from: range.from, to: range.to }); - }, - }), - ]; - }, - - addKeyboardShortcuts() { - return { - Enter: () => handleEnter(this.options.editor), - "Mod-Shift-8": () => { - const blockInfo = getBlockInfoFromSelection(this.editor.state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return true; - } - - return this.editor.commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "bulletListItem", - props: {}, - }), - ); - }, - }; - }, - - parseHTML() { - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - // Parse from external HTML. - { - tag: "li", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - const parent = element.parentElement; - - if (parent === null) { - return false; - } - - if ( - parent.tagName === "UL" || - (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL") - ) { - return {}; - } - - return false; - }, - // As `li` elements can contain multiple paragraphs, we need to merge their contents - // into a single one so that ProseMirror can parse everything correctly. - getContent: (node, schema) => - getListItemContent(node, schema, this.name), - node: "bulletListItem", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return createDefaultBlockDOMOutputSpec( - this.name, - // We use a

          tag, because for

        • tags we'd need a
            element to put - // them in to be semantically correct, which we can't have due to the - // schema. - "p", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - }, -}); - -export const BulletListItem = createBlockSpecFromStronglyTypedTiptapNode( - BulletListItemBlockContent, - bulletListItemPropSchema, -); diff --git a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts deleted file mode 100644 index 8ebf62aa63..0000000000 --- a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { InputRule } from "@tiptap/core"; -import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { - getBlockInfoFromSelection, - getNearestBlockPos, -} from "../../../api/getBlockInfoFromPos.js"; -import { - PropSchema, - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, - propsToAttributes, -} from "../../../schema/index.js"; -import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js"; -import { defaultProps } from "../../defaultProps.js"; -import { getListItemContent } from "../getListItemContent.js"; -import { handleEnter } from "../ListItemKeyboardShortcuts.js"; - -export const checkListItemPropSchema = { - ...defaultProps, - checked: { - default: false, - }, -} satisfies PropSchema; - -const checkListItemBlockContent = createStronglyTypedTiptapNode({ - name: "checkListItem", - content: "inline*", - group: "blockContent", - - addAttributes() { - return propsToAttributes(checkListItemPropSchema); - }, - - addInputRules() { - return [ - // Creates a checklist when starting with "[]" or "[X]". - new InputRule({ - find: new RegExp(`\\[\\s*\\]\\s$`), - handler: ({ state, chain, range }) => { - const blockInfo = getBlockInfoFromSelection(state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return; - } - - chain() - .command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "checkListItem", - props: { - checked: false as any, - }, - }), - ) - // Removes the characters used to set the list. - .deleteRange({ from: range.from, to: range.to }); - }, - }), - new InputRule({ - find: new RegExp(`\\[[Xx]\\]\\s$`), - handler: ({ state, chain, range }) => { - const blockInfo = getBlockInfoFromSelection(state); - - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return; - } - - chain() - .command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "checkListItem", - props: { - checked: true as any, - }, - }), - ) - // Removes the characters used to set the list. - .deleteRange({ from: range.from, to: range.to }); - }, - }), - ]; - }, - - addKeyboardShortcuts() { - return { - Enter: () => handleEnter(this.options.editor), - "Mod-Shift-9": () => { - const blockInfo = getBlockInfoFromSelection(this.editor.state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return true; - } - - return this.editor.commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "checkListItem", - props: {}, - }), - ); - }, - }; - }, - - parseHTML() { - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - // Parse from external HTML. - { - tag: "input", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - // Ignore if we already parsed an ancestor list item to avoid double-parsing. - if (element.closest("[data-content-type]") || element.closest("li")) { - return false; - } - - if ((element as HTMLInputElement).type === "checkbox") { - return { checked: (element as HTMLInputElement).checked }; - } - - return false; - }, - node: "checkListItem", - }, - { - tag: "li", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - const parent = element.parentElement; - - if (parent === null) { - return false; - } - - if ( - parent.tagName === "UL" || - (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL") - ) { - const checkbox = - (element.querySelector( - "input[type=checkbox]", - ) as HTMLInputElement) || null; - - if (checkbox === null) { - return false; - } - - return { checked: checkbox.checked }; - } - - return false; - }, - // As `li` elements can contain multiple paragraphs, we need to merge their contents - // into a single one so that ProseMirror can parse everything correctly. - getContent: (node, schema) => - getListItemContent(node, schema, this.name), - node: "checkListItem", - }, - ]; - }, - - // Since there is no HTML checklist element, there isn't really any - // standardization for what checklists should look like in the DOM. GDocs' - // and Notion's aren't cross compatible, for example. This implementation - // has a semantically correct DOM structure (though missing a label for the - // checkbox) which is also converted correctly to Markdown by remark. - renderHTML({ node, HTMLAttributes }) { - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = node.attrs.checked; - if (node.attrs.checked) { - checkbox.setAttribute("checked", ""); - } - - const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( - this.name, - "p", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - - dom.insertBefore(checkbox, contentDOM); - - return { dom, contentDOM }; - }, - - // Need to render node view since the checkbox needs to be able to update the - // node. This is only possible with a node view as it exposes `getPos`. - addNodeView() { - return ({ node, getPos, editor, HTMLAttributes }) => { - // Need to wrap certain elements in a div or keyboard navigation gets - // confused. - const wrapper = document.createElement("div"); - const checkboxWrapper = document.createElement("div"); - checkboxWrapper.contentEditable = "false"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = node.attrs.checked; - if (node.attrs.checked) { - checkbox.setAttribute("checked", ""); - } - - const changeHandler = () => { - if (!editor.isEditable) { - // This seems like the most effective way of blocking the checkbox - // from being toggled, as event.preventDefault() does not stop it for - // "click" or "change" events. - checkbox.checked = !checkbox.checked; - return; - } - - // TODO: test - if (typeof getPos !== "boolean") { - const beforeBlockContainerPos = getNearestBlockPos( - editor.state.doc, - getPos(), - ); - - if (beforeBlockContainerPos.node.type.name !== "blockContainer") { - throw new Error( - `Expected blockContainer node, got ${beforeBlockContainerPos.node.type.name}`, - ); - } - - this.editor.commands.command( - updateBlockCommand(beforeBlockContainerPos.posBeforeNode, { - type: "checkListItem", - props: { - checked: checkbox.checked as any, - }, - }), - ); - } - }; - checkbox.addEventListener("change", changeHandler); - - const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( - this.name, - "p", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - - if (typeof getPos !== "boolean") { - // Since `node` is a blockContent node, we have to get the block ID from - // the parent blockContainer node. This means we can't add the label in - // `renderHTML` as we can't use `getPos` and therefore can't get the - // parent blockContainer node. - const blockID = this.editor.state.doc.resolve(getPos()).node().attrs.id; - const label = "label-" + blockID; - checkbox.setAttribute("aria-labelledby", label); - contentDOM.id = label; - } - - dom.removeChild(contentDOM); - dom.appendChild(wrapper); - wrapper.appendChild(checkboxWrapper); - wrapper.appendChild(contentDOM); - checkboxWrapper.appendChild(checkbox); - - return { - dom, - contentDOM, - destroy: () => { - checkbox.removeEventListener("change", changeHandler); - }, - }; - }; - }, -}); - -export const CheckListItem = createBlockSpecFromStronglyTypedTiptapNode( - checkListItemBlockContent, - checkListItemPropSchema, -); diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts deleted file mode 100644 index a17024647c..0000000000 --- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Plugin, PluginKey } from "prosemirror-state"; -import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js"; - -// ProseMirror Plugin which automatically assigns indices to ordered list items per nesting level. -const PLUGIN_KEY = new PluginKey(`numbered-list-indexing`); -export const NumberedListIndexingPlugin = () => { - return new Plugin({ - key: PLUGIN_KEY, - appendTransaction: (_transactions, _oldState, newState) => { - const tr = newState.tr; - tr.setMeta("numberedListIndexing", true); - - let modified = false; - - // Traverses each node the doc using DFS, so blocks which are on the same nesting level will be traversed in the - // same order they appear. This means the index of each list item block can be calculated by incrementing the - // index of the previous list item block. - newState.doc.descendants((node, pos) => { - if ( - node.type.name === "blockContainer" && - node.firstChild!.type.name === "numberedListItem" - ) { - let newIndex = `${node.firstChild!.attrs["start"] || 1}`; - - const blockInfo = getBlockInfo({ - posBeforeNode: pos, - node, - }); - - if (!blockInfo.isBlockContainer) { - throw new Error("impossible"); - } - - // Checks if this block is the start of a new ordered list, i.e. if it's the first block in the document, the - // first block in its nesting level, or the previous block is not an ordered list item. - - const prevBlock = tr.doc.resolve( - blockInfo.bnBlock.beforePos, - ).nodeBefore; - - if (prevBlock) { - const prevBlockInfo = getBlockInfo({ - posBeforeNode: blockInfo.bnBlock.beforePos - prevBlock.nodeSize, - node: prevBlock, - }); - - const isPrevBlockOrderedListItem = - prevBlockInfo.blockNoteType === "numberedListItem"; - - if (isPrevBlockOrderedListItem) { - if (!prevBlockInfo.isBlockContainer) { - throw new Error("impossible"); - } - const prevBlockIndex = - prevBlockInfo.blockContent.node.attrs["index"]; - - newIndex = (parseInt(prevBlockIndex) + 1).toString(); - } - } - - const contentNode = blockInfo.blockContent.node; - const index = contentNode.attrs["index"]; - const isFirst = - prevBlock?.firstChild?.type.name !== "numberedListItem"; - - if (index !== newIndex || (contentNode.attrs.start && !isFirst)) { - modified = true; - - const { start, ...attrs } = contentNode.attrs; - - tr.setNodeMarkup(blockInfo.blockContent.beforePos, undefined, { - ...attrs, - index: newIndex, - ...(typeof start === "number" && - isFirst && { - start, - }), - }); - } - } - }); - - return modified ? tr : null; - }, - }); -}; diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts deleted file mode 100644 index 03866d4cee..0000000000 --- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { InputRule } from "@tiptap/core"; -import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; -import { - PropSchema, - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, - propsToAttributes, -} from "../../../schema/index.js"; -import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js"; -import { defaultProps } from "../../defaultProps.js"; -import { getListItemContent } from "../getListItemContent.js"; -import { handleEnter } from "../ListItemKeyboardShortcuts.js"; -import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin.js"; - -export const numberedListItemPropSchema = { - ...defaultProps, - start: { default: undefined, type: "number" }, -} satisfies PropSchema; - -const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ - name: "numberedListItem", - content: "inline*", - group: "blockContent", - priority: 90, - addAttributes() { - return { - ...propsToAttributes(numberedListItemPropSchema), - // the index attribute is only used internally (it's not part of the blocknote schema) - // that's why it's defined explicitly here, and not part of the prop schema - 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(`^(\\d+)\\.\\s$`), - handler: ({ state, chain, range, match }) => { - const blockInfo = getBlockInfoFromSelection(state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" || - blockInfo.blockNoteType === "numberedListItem" || - blockInfo.blockNoteType === "heading" - ) { - return; - } - const startIndex = parseInt(match[1]); - - chain() - .command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "numberedListItem", - props: - (startIndex === 1 && {}) || - ({ - start: startIndex, - } as any), - }), - ) - // Removes the "1." characters used to set the list. - .deleteRange({ from: range.from, to: range.to }); - }, - }), - ]; - }, - - addKeyboardShortcuts() { - return { - Enter: () => handleEnter(this.options.editor), - "Mod-Shift-7": () => { - const blockInfo = getBlockInfoFromSelection(this.editor.state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return true; - } - - return this.editor.commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "numberedListItem", - props: {}, - }), - ); - }, - }; - }, - - addProseMirrorPlugins() { - return [NumberedListIndexingPlugin()]; - }, - - parseHTML() { - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - // Parse from external HTML. - { - tag: "li", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - const parent = element.parentElement; - - if (parent === null) { - return false; - } - - if ( - parent.tagName === "OL" || - (parent.tagName === "DIV" && parent.parentElement?.tagName === "OL") - ) { - const startIndex = - parseInt(parent.getAttribute("start") || "1") || 1; - - if (element.previousSibling || startIndex === 1) { - return {}; - } - - return { - start: startIndex, - }; - } - - return false; - }, - // As `li` elements can contain multiple paragraphs, we need to merge their contents - // into a single one so that ProseMirror can parse everything correctly. - getContent: (node, schema) => - getListItemContent(node, schema, this.name), - priority: 300, - node: "numberedListItem", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return createDefaultBlockDOMOutputSpec( - this.name, - // We use a

            tag, because for

          • tags we'd need an
              element to - // put them in to be semantically correct, which we can't have due to the - // schema. - "p", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - }, -}); - -export const NumberedListItem = createBlockSpecFromStronglyTypedTiptapNode( - NumberedListItemBlockContent, - numberedListItemPropSchema, -); diff --git a/packages/core/src/blocks/ListItemBlockContent/ToggleListItemBlockContent/ToggleListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/ToggleListItemBlockContent/ToggleListItemBlockContent.ts deleted file mode 100644 index 3924a0ca09..0000000000 --- a/packages/core/src/blocks/ListItemBlockContent/ToggleListItemBlockContent/ToggleListItemBlockContent.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; -import { - PropSchema, - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, - getBlockFromPos, -} from "../../../schema/index.js"; -import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js"; -import { defaultProps } from "../../defaultProps.js"; -import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js"; -import { handleEnter } from "../ListItemKeyboardShortcuts.js"; - -export const toggleListItemPropSchema = { - ...defaultProps, -} satisfies PropSchema; - -const ToggleListItemBlockContent = createStronglyTypedTiptapNode({ - name: "toggleListItem", - content: "inline*", - group: "blockContent", - // This is to make sure that the list item Enter keyboard handler takes - // priority over the default one. - priority: 90, - addKeyboardShortcuts() { - return { - Enter: () => handleEnter(this.options.editor), - "Mod-Shift-6": () => { - const blockInfo = getBlockInfoFromSelection(this.editor.state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return true; - } - - return this.editor.commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "toggleListItem", - props: {}, - }), - ); - }, - }; - }, - - parseHTML() { - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return createDefaultBlockDOMOutputSpec( - this.name, - "p", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - }, - - addNodeView() { - return ({ HTMLAttributes, getPos }) => { - const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( - this.name, - "p", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - - const editor = this.options.editor; - const block = getBlockFromPos(getPos, editor, this.editor, this.name); - - const toggleWrapper = createToggleWrapper( - block as any, - editor, - contentDOM, - ); - dom.appendChild(toggleWrapper.dom); - - return { - dom, - contentDOM, - ignoreMutation: toggleWrapper.ignoreMutation, - destroy: toggleWrapper.destroy, - }; - }; - }, -}); - -export const ToggleListItem = createBlockSpecFromStronglyTypedTiptapNode( - ToggleListItemBlockContent, - toggleListItemPropSchema, -); diff --git a/packages/core/src/blocks/PageBreak/block.ts b/packages/core/src/blocks/PageBreak/block.ts new file mode 100644 index 0000000000..3bc0365c04 --- /dev/null +++ b/packages/core/src/blocks/PageBreak/block.ts @@ -0,0 +1,41 @@ +import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; + +export const createPageBreakBlockConfig = createBlockConfig( + () => + ({ + type: "pageBreak" as const, + propSchema: {}, + content: "none", + }) as const, +); + +export const createPageBreakBlockSpec = createBlockSpec( + createPageBreakBlockConfig, +).implementation(() => ({ + parse(element) { + if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) { + return {}; + } + + return undefined; + }, + render() { + const pageBreak = document.createElement("div"); + + pageBreak.className = "bn-page-break"; + pageBreak.setAttribute("data-page-break", ""); + + return { + dom: pageBreak, + }; + }, + toExternalHTML() { + const pageBreak = document.createElement("div"); + + pageBreak.setAttribute("data-page-break", ""); + + return { + dom: pageBreak, + }; + }, +})); diff --git a/packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts b/packages/core/src/blocks/PageBreak/getPageBreakSlashMenuItems.ts similarity index 100% rename from packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts rename to packages/core/src/blocks/PageBreak/getPageBreakSlashMenuItems.ts diff --git a/packages/core/src/blocks/PageBreakBlockContent/schema.ts b/packages/core/src/blocks/PageBreak/schema.ts similarity index 79% rename from packages/core/src/blocks/PageBreakBlockContent/schema.ts rename to packages/core/src/blocks/PageBreak/schema.ts index 15ea54cbe5..449dc19eae 100644 --- a/packages/core/src/blocks/PageBreakBlockContent/schema.ts +++ b/packages/core/src/blocks/PageBreak/schema.ts @@ -4,11 +4,14 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; -import { PageBreak } from "./PageBreakBlockContent.js"; +import { + createPageBreakBlockConfig, + createPageBreakBlockSpec, +} from "./block.js"; export const pageBreakSchema = BlockNoteSchema.create({ blockSpecs: { - pageBreak: PageBreak, + pageBreak: createPageBreakBlockSpec(), }, }); @@ -32,7 +35,7 @@ export const withPageBreak = < }) as any as BlockNoteSchema< // typescript needs some help here B & { - pageBreak: typeof PageBreak.config; + pageBreak: ReturnType; }, I, S diff --git a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts deleted file mode 100644 index c8343a30f3..0000000000 --- a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - createBlockSpec, - CustomBlockConfig, - Props, -} from "../../schema/index.js"; - -export const pageBreakConfig = { - type: "pageBreak" as const, - propSchema: {}, - content: "none", - isFileBlock: false, - isSelectable: false, -} satisfies CustomBlockConfig; -export const pageBreakRender = () => { - const pageBreak = document.createElement("div"); - - pageBreak.className = "bn-page-break"; - pageBreak.setAttribute("data-page-break", ""); - - return { - dom: pageBreak, - }; -}; -export const pageBreakParse = ( - element: HTMLElement, -): Partial> | undefined => { - if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) { - return { - type: "pageBreak", - }; - } - - return undefined; -}; -export const pageBreakToExternalHTML = () => { - const pageBreak = document.createElement("div"); - - pageBreak.setAttribute("data-page-break", ""); - - return { - dom: pageBreak, - }; -}; - -export const PageBreak = createBlockSpec(pageBreakConfig, { - render: pageBreakRender, - parse: pageBreakParse, - toExternalHTML: pageBreakToExternalHTML, -}); diff --git a/packages/core/src/blocks/Paragraph/block.ts b/packages/core/src/blocks/Paragraph/block.ts new file mode 100644 index 0000000000..41a5be97fb --- /dev/null +++ b/packages/core/src/blocks/Paragraph/block.ts @@ -0,0 +1,64 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { + createBlockConfig, + createBlockNoteExtension, + createBlockSpec, +} from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; + +export const createParagraphBlockConfig = createBlockConfig( + () => + ({ + type: "paragraph" as const, + propSchema: defaultProps, + content: "inline" as const, + }) as const, +); + +export const createParagraphBlockSpec = createBlockSpec( + createParagraphBlockConfig, +).implementation( + () => ({ + parse: (e) => { + const paragraph = e.querySelector("p"); + if (!paragraph) { + return undefined; + } + + return {}; + }, + render: () => { + const dom = document.createElement("p"); + return { + dom, + contentDOM: dom, + }; + }, + runsBefore: ["default"], + }), + () => [ + createBlockNoteExtension({ + key: "paragraph-shortcuts", + keyboardShortcuts: { + "Mod-Alt-0": ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return true; + } + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, { + type: "paragraph", + props: {}, + }); + return true; + }), + }, + }), + ], +); diff --git a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts deleted file mode 100644 index 0c35c117a7..0000000000 --- a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, -} from "../../schema/index.js"; -import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; -import { defaultProps } from "../defaultProps.js"; - -export const paragraphPropSchema = { - ...defaultProps, -}; - -export const ParagraphBlockContent = createStronglyTypedTiptapNode({ - name: "paragraph", - content: "inline*", - group: "blockContent", - - addKeyboardShortcuts() { - return { - "Mod-Alt-0": () => { - const blockInfo = getBlockInfoFromSelection(this.editor.state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return true; - } - - return this.editor.commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "paragraph", - props: {}, - }), - ); - }, - }; - }, - - parseHTML() { - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - // Parse from external HTML. - { - tag: "p", - getAttrs: (element) => { - if (typeof element === "string" || !element.textContent?.trim()) { - return false; - } - - return {}; - }, - node: "paragraph", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return createDefaultBlockDOMOutputSpec( - this.name, - "p", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - }, -}); - -export const Paragraph = createBlockSpecFromStronglyTypedTiptapNode( - ParagraphBlockContent, - paragraphPropSchema, -); diff --git a/packages/core/src/blocks/Quote/block.ts b/packages/core/src/blocks/Quote/block.ts new file mode 100644 index 0000000000..c813938622 --- /dev/null +++ b/packages/core/src/blocks/Quote/block.ts @@ -0,0 +1,73 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { + createBlockConfig, + createBlockNoteExtension, + createBlockSpec, +} from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; + +export const createQuoteBlockConfig = createBlockConfig( + () => + ({ + type: "quote" as const, + propSchema: { ...defaultProps }, + content: "inline" as const, + }) as const, +); + +export const createQuoteBlockSpec = createBlockSpec( + createQuoteBlockConfig, +).implementation( + () => ({ + parse(element) { + if (element.querySelector("blockquote")) { + return {}; + } + + return undefined; + }, + render() { + const quote = document.createElement("blockquote"); + + return { + dom: quote, + contentDOM: quote, + }; + }, + }), + () => [ + createBlockNoteExtension({ + key: "quote-block-shortcuts", + keyboardShortcuts: { + "Mod-Alt-q": ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return true; + } + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, { + type: "quote", + }); + return true; + }), + }, + inputRules: [ + { + find: new RegExp(`^>\\s$`), + replace() { + return { + type: "quote", + props: {}, + }; + }, + }, + ], + }), + ], +); diff --git a/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts b/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts deleted file mode 100644 index f6ad08a3c4..0000000000 --- a/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, -} from "../../schema/index.js"; -import { - createDefaultBlockDOMOutputSpec, - mergeParagraphs, -} from "../defaultBlockHelpers.js"; -import { defaultProps } from "../defaultProps.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { InputRule } from "@tiptap/core"; -import { DOMParser } from "prosemirror-model"; - -export const quotePropSchema = { - ...defaultProps, -}; - -export const QuoteBlockContent = createStronglyTypedTiptapNode({ - name: "quote", - content: "inline*", - group: "blockContent", - - addInputRules() { - return [ - // Creates a block quote when starting with ">". - new InputRule({ - find: new RegExp(`^>\\s$`), - handler: ({ state, chain, range }) => { - const blockInfo = getBlockInfoFromSelection(state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return; - } - - chain() - .command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "quote", - props: {}, - }), - ) - // Removes the ">" character used to set the list. - .deleteRange({ from: range.from, to: range.to }); - }, - }), - ]; - }, - - addKeyboardShortcuts() { - return { - "Mod-Alt-q": () => { - const blockInfo = getBlockInfoFromSelection(this.editor.state); - if ( - !blockInfo.isBlockContainer || - blockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return true; - } - - return this.editor.commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { - type: "quote", - }), - ); - }, - }; - }, - - parseHTML() { - return [ - // Parse from internal HTML. - { - tag: "div[data-content-type=" + this.name + "]", - contentElement: ".bn-inline-content", - }, - // Parse from external HTML. - { - tag: "blockquote", - node: "quote", - getContent: (node, schema) => { - // Parse the blockquote content as inline content - const element = node as HTMLElement; - - // Clone to avoid modifying the original - const clone = element.cloneNode(true) as HTMLElement; - - // Merge multiple paragraphs into one with line breaks - mergeParagraphs(clone); - - // Parse the content directly as a paragraph to extract inline content - const parser = DOMParser.fromSchema(schema); - const parsed = parser.parse(clone, { - topNode: schema.nodes.paragraph.create(), - }); - - return parsed.content; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return createDefaultBlockDOMOutputSpec( - this.name, - "blockquote", - { - ...(this.options.domAttributes?.blockContent || {}), - ...HTMLAttributes, - }, - this.options.domAttributes?.inlineContent || {}, - ); - }, -}); - -export const Quote = createBlockSpecFromStronglyTypedTiptapNode( - QuoteBlockContent, - quotePropSchema, -); diff --git a/packages/core/src/blocks/TableBlockContent/TableExtension.ts b/packages/core/src/blocks/Table/TableExtension.ts similarity index 100% rename from packages/core/src/blocks/TableBlockContent/TableExtension.ts rename to packages/core/src/blocks/Table/TableExtension.ts diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/Table/block.ts similarity index 93% rename from packages/core/src/blocks/TableBlockContent/TableBlockContent.ts rename to packages/core/src/blocks/Table/block.ts index 6d26b8ec54..6383ee0298 100644 --- a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/blocks/Table/block.ts @@ -5,6 +5,7 @@ import { DOMParser, Fragment, Node as PMNode, Schema } from "prosemirror-model"; import { TableView } from "prosemirror-tables"; import { NodeView } from "prosemirror-view"; import { + BlockSpec, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, } from "../../schema/index.js"; @@ -17,7 +18,7 @@ export const tablePropSchema = { textColor: defaultProps.textColor, }; -export const TableBlockContent = createStronglyTypedTiptapNode({ +export const TableNode = createStronglyTypedTiptapNode({ name: "table", content: "tableRow+", group: "blockContent", @@ -110,7 +111,7 @@ export const TableBlockContent = createStronglyTypedTiptapNode({ }, }); -const TableParagraph = createStronglyTypedTiptapNode({ +const TableParagraphNode = createStronglyTypedTiptapNode({ name: "tableParagraph", group: "tableContent", content: "inline*", @@ -155,7 +156,9 @@ const TableParagraph = createStronglyTypedTiptapNode({ * This extension allows you to create table rows. * @see https://www.tiptap.dev/api/nodes/table-row */ -export const TableRow = Node.create<{ HTMLAttributes: Record }>({ +export const TableRowNode = Node.create<{ + HTMLAttributes: Record; +}>({ name: "tableRow", addOptions() { @@ -217,12 +220,10 @@ function parseTableContent(node: HTMLElement, schema: Schema) { return Fragment.fromArray(extractedContent); } -export const Table = createBlockSpecFromStronglyTypedTiptapNode( - TableBlockContent, - tablePropSchema, - [ +export const createTableBlockSpec = () => + createBlockSpecFromStronglyTypedTiptapNode(TableNode, tablePropSchema, [ TableExtension, - TableParagraph, + TableParagraphNode, TableHeader.extend({ /** * We allow table headers and cells to have multiple tableContent nodes because @@ -258,6 +259,16 @@ export const Table = createBlockSpecFromStronglyTypedTiptapNode( ]; }, }), - TableRow, - ], -); + TableRowNode, + ]) as unknown as BlockSpec< + "table", + { + textColor: { + default: "default"; + }; + } + > & { + config: { + content: "table"; + }; + }; diff --git a/packages/core/src/blocks/Video/block.ts b/packages/core/src/blocks/Video/block.ts new file mode 100644 index 0000000000..36a5ec1bfb --- /dev/null +++ b/packages/core/src/blocks/Video/block.ts @@ -0,0 +1,169 @@ +import { defaultProps } from "../defaultProps.js"; +import { parseFigureElement } from "../File/helpers/parse/parseFigureElement.js"; +import { createResizableFileBlockWrapper } from "../File/helpers/render/createResizableFileBlockWrapper.js"; +import { createFigureWithCaption } from "../File/helpers/toExternalHTML/createFigureWithCaption.js"; +import { createLinkWithCaption } from "../File/helpers/toExternalHTML/createLinkWithCaption.js"; +import { parseVideoElement } from "./parseVideoElement.js"; +import { + BlockNoDefaults, + createBlockConfig, + createBlockSpec, +} from "../../schema/index.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; + +export const FILE_VIDEO_ICON_SVG = + ''; + +export interface VideoOptions { + icon?: string; +} +export const createVideoBlockConfig = createBlockConfig( + (_ctx: VideoOptions) => ({ + type: "video" as const, + propSchema: { + textAlignment: defaultProps.textAlignment, + backgroundColor: defaultProps.backgroundColor, + name: { default: "" as const }, + url: { default: "" as const }, + caption: { default: "" as const }, + showPreview: { default: true }, + previewWidth: { default: undefined, type: "number" as const }, + }, + content: "none" as const, + meta: { + fileBlockAccept: ["video/*"], + }, + }), +); + +export const videoParse = + (_config: VideoOptions = {}) => + (element: HTMLElement) => { + if (element.tagName === "VIDEO") { + // Ignore if parent figure has already been parsed. + if (element.closest("figure")) { + return undefined; + } + + return parseVideoElement(element as HTMLVideoElement); + } + + if (element.tagName === "FIGURE") { + const parsedFigure = parseFigureElement(element, "video"); + if (!parsedFigure) { + return undefined; + } + + const { targetElement, caption } = parsedFigure; + + return { + ...parseVideoElement(targetElement as HTMLVideoElement), + caption, + }; + } + + return undefined; + }; + +export const videoRender = + (config: VideoOptions = {}) => + ( + block: BlockNoDefaults< + Record<"video", ReturnType>, + any, + any + >, + editor: BlockNoteEditor< + Record<"video", ReturnType>, + any, + any + >, + ) => { + const icon = document.createElement("div"); + icon.innerHTML = config.icon ?? FILE_VIDEO_ICON_SVG; + + const videoWrapper = document.createElement("div"); + videoWrapper.className = "bn-visual-media-wrapper"; + + const video = document.createElement("video"); + video.className = "bn-visual-media"; + if (editor.resolveFileUrl) { + editor.resolveFileUrl(block.props.url).then((downloadUrl) => { + video.src = downloadUrl; + }); + } else { + video.src = block.props.url; + } + video.controls = true; + video.contentEditable = "false"; + video.draggable = false; + video.width = block.props.previewWidth; + videoWrapper.appendChild(video); + + return createResizableFileBlockWrapper( + block, + editor, + { dom: videoWrapper }, + videoWrapper, + editor.dictionary.file_blocks.video.add_button_text, + icon.firstElementChild as HTMLElement, + ); + }; + +export const videoToExternalHTML = + (_config: VideoOptions = {}) => + ( + block: BlockNoDefaults< + Record<"video", ReturnType>, + any, + any + >, + _editor: BlockNoteEditor< + Record<"video", ReturnType>, + any, + any + >, + ) => { + if (!block.props.url) { + const div = document.createElement("p"); + div.textContent = "Add video"; + + return { + dom: div, + }; + } + + let video; + if (block.props.showPreview) { + video = document.createElement("video"); + video.src = block.props.url; + if (block.props.previewWidth) { + video.width = block.props.previewWidth; + } + } else { + video = document.createElement("a"); + video.href = block.props.url; + video.textContent = block.props.name || block.props.url; + } + + if (block.props.caption) { + if (block.props.showPreview) { + return createFigureWithCaption(video, block.props.caption); + } else { + return createLinkWithCaption(video, block.props.caption); + } + } + + return { + dom: video, + }; + }; + +export const createVideoBlockSpec = createBlockSpec( + createVideoBlockConfig, +).implementation((config = {}) => ({ + parse: videoParse(config), + render: videoRender(config), + toExternalHTML: videoToExternalHTML(config), + runsBefore: ["file"], +})); diff --git a/packages/core/src/blocks/VideoBlockContent/parseVideoElement.ts b/packages/core/src/blocks/Video/parseVideoElement.ts similarity index 100% rename from packages/core/src/blocks/VideoBlockContent/parseVideoElement.ts rename to packages/core/src/blocks/Video/parseVideoElement.ts diff --git a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts deleted file mode 100644 index af65e3d0df..0000000000 --- a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { - BlockFromConfig, - createBlockSpec, - FileBlockConfig, - Props, - PropSchema, -} from "../../schema/index.js"; -import { defaultProps } from "../defaultProps.js"; -import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js"; -import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; -import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; -import { parseVideoElement } from "./parseVideoElement.js"; - -export const FILE_VIDEO_ICON_SVG = - ''; - -export const videoPropSchema = { - textAlignment: defaultProps.textAlignment, - backgroundColor: defaultProps.backgroundColor, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, - - showPreview: { - default: true, - }, - // File preview width in px. - previewWidth: { - default: undefined, - type: "number", - }, -} satisfies PropSchema; - -export const videoBlockConfig = { - type: "video" as const, - propSchema: videoPropSchema, - content: "none", - isFileBlock: true, - fileBlockAccept: ["video/*"], -} satisfies FileBlockConfig; - -export const videoRender = ( - block: BlockFromConfig, - editor: BlockNoteEditor, -) => { - const icon = document.createElement("div"); - icon.innerHTML = FILE_VIDEO_ICON_SVG; - - const videoWrapper = document.createElement("div"); - videoWrapper.className = "bn-visual-media-wrapper"; - - const video = document.createElement("video"); - video.className = "bn-visual-media"; - if (editor.resolveFileUrl) { - editor.resolveFileUrl(block.props.url).then((downloadUrl) => { - video.src = downloadUrl; - }); - } else { - video.src = block.props.url; - } - video.controls = true; - video.contentEditable = "false"; - video.draggable = false; - video.width = block.props.previewWidth; - videoWrapper.appendChild(video); - - return createResizableFileBlockWrapper( - block, - editor, - { dom: videoWrapper }, - videoWrapper, - editor.dictionary.file_blocks.video.add_button_text, - icon.firstElementChild as HTMLElement, - ); -}; - -export const videoParse = ( - element: HTMLElement, -): Partial> | undefined => { - if (element.tagName === "VIDEO") { - // Ignore if parent figure has already been parsed. - if (element.closest("figure")) { - return undefined; - } - - return parseVideoElement(element as HTMLVideoElement); - } - - if (element.tagName === "FIGURE") { - const parsedFigure = parseFigureElement(element, "video"); - if (!parsedFigure) { - return undefined; - } - - const { targetElement, caption } = parsedFigure; - - return { - ...parseVideoElement(targetElement as HTMLVideoElement), - caption, - }; - } - - return undefined; -}; - -export const videoToExternalHTML = ( - block: BlockFromConfig, -) => { - if (!block.props.url) { - const div = document.createElement("p"); - div.textContent = "Add video"; - - return { - dom: div, - }; - } - - let video; - if (block.props.showPreview) { - video = document.createElement("video"); - video.src = block.props.url; - if (block.props.previewWidth) { - video.width = block.props.previewWidth; - } - } else { - video = document.createElement("a"); - video.href = block.props.url; - video.textContent = block.props.name || block.props.url; - } - - if (block.props.caption) { - if (block.props.showPreview) { - return createFigureWithCaption(video, block.props.caption); - } else { - return createLinkWithCaption(video, block.props.caption); - } - } - - return { - dom: video, - }; -}; - -export const VideoBlock = createBlockSpec(videoBlockConfig, { - render: videoRender, - parse: videoParse, - toExternalHTML: videoToExternalHTML, -}); diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts index 5f19cc052f..ccadf93e11 100644 --- a/packages/core/src/blocks/defaultBlockHelpers.ts +++ b/packages/core/src/blocks/defaultBlockHelpers.ts @@ -100,13 +100,13 @@ export const defaultBlockToHTML = < // This is used when parsing blocks like list items and table cells, as they may // contain multiple paragraphs that ProseMirror will not be able to handle // properly. -export function mergeParagraphs(element: HTMLElement) { +export function mergeParagraphs(element: HTMLElement, separator = "
              ") { const paragraphs = element.querySelectorAll("p"); if (paragraphs.length > 1) { const firstParagraph = paragraphs[0]; for (let i = 1; i < paragraphs.length; i++) { const paragraph = paragraphs[i]; - firstParagraph.innerHTML += "
              " + paragraph.innerHTML; + firstParagraph.innerHTML += separator + paragraph.innerHTML; paragraph.remove(); } } diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 69ac5fdae9..c3db69fdaf 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -1,201 +1,169 @@ import { CellSelection } from "prosemirror-tables"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; -import { - BlockConfig, - BlockFromConfig, - BlockSchema, - FileBlockConfig, - InlineContentConfig, - InlineContentSchema, - StyleSchema, -} from "../schema/index.js"; -import { - Block, - DefaultBlockSchema, - DefaultInlineContentSchema, - defaultBlockSchema, - defaultInlineContentSchema, -} from "./defaultBlocks.js"; -import { defaultProps } from "./defaultProps.js"; +import { BlockConfig, PropSchema, PropSpec } from "../schema/index.js"; +import { Block } from "./defaultBlocks.js"; import { Selection } from "prosemirror-state"; -export function checkDefaultBlockTypeInSchema< - BlockType extends keyof DefaultBlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, +export function editorHasBlockWithType< + BType extends string, + Props extends + | PropSchema + | Record + | undefined = undefined, >( - blockType: BlockType, - editor: BlockNoteEditor, + editor: BlockNoteEditor, + blockType: BType, + props?: Props, ): editor is BlockNoteEditor< - { [K in BlockType]: DefaultBlockSchema[BlockType] }, - I, - S + { + [BT in BType]: Props extends PropSchema + ? BlockConfig + : Props extends Record + ? BlockConfig< + BT, + { + [PN in keyof Props]: { + default: undefined; + type: Props[PN]; + }; + } + > + : BlockConfig; + }, + any, + any > { - return ( - blockType in editor.schema.blockSchema && - editor.schema.blockSchema[blockType] === defaultBlockSchema[blockType] - ); -} + if (!(blockType in editor.schema.blockSpecs)) { + return false; + } -export function checkBlockTypeInSchema< - BlockType extends string, - Config extends BlockConfig, ->( - blockType: BlockType, - blockConfig: Config, - editor: BlockNoteEditor, -): editor is BlockNoteEditor<{ [T in BlockType]: Config }, any, any> { - return ( - blockType in editor.schema.blockSchema && - editor.schema.blockSchema[blockType] === blockConfig - ); -} + if (!props) { + return true; + } -export function checkDefaultInlineContentTypeInSchema< - InlineContentType extends keyof DefaultInlineContentSchema, - B extends BlockSchema, - S extends StyleSchema, ->( - inlineContentType: InlineContentType, - editor: BlockNoteEditor, -): editor is BlockNoteEditor< - B, - { [K in InlineContentType]: DefaultInlineContentSchema[InlineContentType] }, - S -> { - return ( - inlineContentType in editor.schema.inlineContentSchema && - editor.schema.inlineContentSchema[inlineContentType] === - defaultInlineContentSchema[inlineContentType] - ); -} + for (const [propName, propSpec] of Object.entries(props)) { + if (!(propName in editor.schema.blockSpecs[blockType].config.propSchema)) { + return false; + } -export function checkInlineContentTypeInSchema< - InlineContentType extends string, - Config extends InlineContentConfig, ->( - inlineContentType: InlineContentType, - inlineContentConfig: Config, - editor: BlockNoteEditor, -): editor is BlockNoteEditor { - return ( - inlineContentType in editor.schema.inlineContentSchema && - editor.schema.inlineContentSchema[inlineContentType] === inlineContentConfig - ); -} + if (typeof propSpec === "string") { + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName] + .default && + typeof editor.schema.blockSpecs[blockType].config.propSchema[propName] + .default !== propSpec + ) { + return false; + } -export function checkBlockIsDefaultType< - BlockType extends keyof DefaultBlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - blockType: BlockType, - block: Block, - editor: BlockNoteEditor, -): block is BlockFromConfig { - return ( - block.type === blockType && - block.type in editor.schema.blockSchema && - checkDefaultBlockTypeInSchema(block.type, editor) - ); -} + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName].type && + editor.schema.blockSpecs[blockType].config.propSchema[propName].type !== + propSpec + ) { + return false; + } + } else { + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName] + .default !== propSpec.default + ) { + return false; + } -export function checkBlockIsFileBlock< - B extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - block: Block, - editor: BlockNoteEditor, -): block is BlockFromConfig { - return ( - (block.type in editor.schema.blockSchema && - editor.schema.blockSchema[block.type].isFileBlock) || - false - ); -} + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName] + .default === undefined && + propSpec.default === undefined + ) { + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName] + .type !== propSpec.type + ) { + return false; + } + } -export function checkBlockIsFileBlockWithPreview< - B extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - block: Block, - editor: BlockNoteEditor, -): block is BlockFromConfig< - FileBlockConfig & { - propSchema: Required; - }, - I, - S -> { - return ( - (block.type in editor.schema.blockSchema && - editor.schema.blockSchema[block.type].isFileBlock && - "showPreview" in editor.schema.blockSchema[block.type].propSchema) || - false - ); -} + if ( + typeof editor.schema.blockSpecs[blockType].config.propSchema[propName] + .values !== typeof propSpec.values + ) { + return false; + } + + if ( + typeof editor.schema.blockSpecs[blockType].config.propSchema[propName] + .values === "object" && + typeof propSpec.values === "object" + ) { + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName].values + .length !== propSpec.values.length + ) { + return false; + } + + for ( + let i = 0; + i < + editor.schema.blockSpecs[blockType].config.propSchema[propName].values + .length; + i++ + ) { + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName] + .values[i] !== propSpec.values[i] + ) { + return false; + } + } + } + } + } -export function checkBlockIsFileBlockWithPlaceholder< - B extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->(block: Block, editor: BlockNoteEditor) { - const config = editor.schema.blockSchema[block.type]; - return config.isFileBlock && !block.props.url; + return true; } -export function checkBlockTypeHasDefaultProp< - Prop extends keyof typeof defaultProps, - I extends InlineContentSchema, - S extends StyleSchema, +export function blockHasType< + BType extends string, + Props extends + | PropSchema + | Record + | undefined = undefined, >( - prop: Prop, - blockType: string, - editor: BlockNoteEditor, -): editor is BlockNoteEditor< + block: Block, + editor: BlockNoteEditor, + blockType: BType, + props?: Props, +): block is Block< { - [BT in string]: { - type: BT; - propSchema: { - [P in Prop]: (typeof defaultProps)[P]; - }; - content: "table" | "inline" | "none"; - }; + [BT in BType]: Props extends PropSchema + ? BlockConfig + : Props extends Record + ? BlockConfig< + BT, + { + [PN in keyof Props]: PropSpec< + Props[PN] extends "boolean" + ? boolean + : Props[PN] extends "number" + ? number + : Props[PN] extends "string" + ? string + : never + >; + } + > + : BlockConfig; }, - I, - S + any, + any > { return ( - blockType in editor.schema.blockSchema && - prop in editor.schema.blockSchema[blockType].propSchema && - editor.schema.blockSchema[blockType].propSchema[prop] === defaultProps[prop] + editorHasBlockWithType(editor, blockType, props) && block.type === blockType ); } -export function checkBlockHasDefaultProp< - Prop extends keyof typeof defaultProps, - I extends InlineContentSchema, - S extends StyleSchema, ->( - prop: Prop, - block: Block, - editor: BlockNoteEditor, -): block is BlockFromConfig< - { - type: string; - propSchema: { - [P in Prop]: (typeof defaultProps)[P]; - }; - content: "table" | "inline" | "none"; - }, - I, - S -> { - return checkBlockTypeHasDefaultProp(prop, block.type, editor); -} - export function isTableCellSelection( selection: Selection, ): selection is CellSelection { diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 5c1c74da3e..90732880b7 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -3,58 +3,73 @@ import Code from "@tiptap/extension-code"; import Italic from "@tiptap/extension-italic"; import Strike from "@tiptap/extension-strike"; import Underline from "@tiptap/extension-underline"; +import { + createAudioBlockSpec, + createBulletListItemBlockSpec, + createCheckListItemBlockSpec, + createCodeBlockSpec, + createFileBlockSpec, + createHeadingBlockSpec, + createImageBlockSpec, + createNumberedListItemBlockSpec, + createPageBreakBlockSpec, + createParagraphBlockSpec, + createQuoteBlockSpec, + createToggleListItemBlockSpec, + createVideoBlockSpec, +} from "./index.js"; import { BackgroundColor } from "../extensions/BackgroundColor/BackgroundColorMark.js"; import { TextColor } from "../extensions/TextColor/TextColorMark.js"; import { + BlockConfig, BlockNoDefaults, BlockSchema, - BlockSpecs, InlineContentSchema, InlineContentSpecs, PartialBlockNoDefaults, StyleSchema, StyleSpecs, createStyleSpecFromTipTapMark, - getBlockSchemaFromSpecs, getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; - -import { AudioBlock } from "./AudioBlockContent/AudioBlockContent.js"; -import { CodeBlock } from "./CodeBlockContent/CodeBlockContent.js"; -import { FileBlock } from "./FileBlockContent/FileBlockContent.js"; -import { Heading } from "./HeadingBlockContent/HeadingBlockContent.js"; -import { ImageBlock } from "./ImageBlockContent/ImageBlockContent.js"; -import { ToggleListItem } from "./ListItemBlockContent/ToggleListItemBlockContent/ToggleListItemBlockContent.js"; -import { BulletListItem } from "./ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.js"; -import { CheckListItem } from "./ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.js"; -import { NumberedListItem } from "./ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.js"; -import { Paragraph } from "./ParagraphBlockContent/ParagraphBlockContent.js"; -import { Quote } from "./QuoteBlockContent/QuoteBlockContent.js"; -import { Table } from "./TableBlockContent/TableBlockContent.js"; -import { VideoBlock } from "./VideoBlockContent/VideoBlockContent.js"; +import { createTableBlockSpec } from "./Table/block.js"; export const defaultBlockSpecs = { - paragraph: Paragraph, - heading: Heading, - quote: Quote, - codeBlock: CodeBlock, - toggleListItem: ToggleListItem, - bulletListItem: BulletListItem, - numberedListItem: NumberedListItem, - checkListItem: CheckListItem, - table: Table, - file: FileBlock, - image: ImageBlock, - video: VideoBlock, - audio: AudioBlock, -} satisfies BlockSpecs; - -export const defaultBlockSchema = getBlockSchemaFromSpecs(defaultBlockSpecs); + audio: createAudioBlockSpec(), + bulletListItem: createBulletListItemBlockSpec(), + checkListItem: createCheckListItemBlockSpec(), + codeBlock: createCodeBlockSpec(), + file: createFileBlockSpec(), + heading: createHeadingBlockSpec(), + image: createImageBlockSpec(), + numberedListItem: createNumberedListItemBlockSpec(), + pageBreak: createPageBreakBlockSpec(), + paragraph: createParagraphBlockSpec(), + quote: createQuoteBlockSpec(), + table: createTableBlockSpec(), + toggleListItem: createToggleListItemBlockSpec(), + video: createVideoBlockSpec(), +} as const; // underscore is used that in case a user overrides DefaultBlockSchema, // they can still access the original default block schema -export type _DefaultBlockSchema = typeof defaultBlockSchema; +export type _DefaultBlockSchema = Omit< + { + [K in keyof typeof defaultBlockSpecs]: (typeof defaultBlockSpecs)[K]["config"]; + }, + "table" +> & { + table: BlockConfig< + "table", + { + textColor: { + default: "default"; + }; + }, + "table" + >; +}; export type DefaultBlockSchema = _DefaultBlockSchema; export const defaultStyleSpecs = { diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts new file mode 100644 index 0000000000..2535f83bba --- /dev/null +++ b/packages/core/src/blocks/index.ts @@ -0,0 +1,17 @@ +export * from "./Audio/block.js"; +export * from "./Audio/parseAudioElement.js"; +export * from "./Code/block.js"; +export * from "./File/block.js"; +export * from "./Heading/block.js"; +export * from "./Image/block.js"; +export * from "./ListItem/BulletListItem/block.js"; +export * from "./ListItem/CheckListItem/block.js"; +export * from "./ListItem/NumberedListItem/block.js"; +export * from "./ListItem/ToggleListItem/block.js"; +export * from "./PageBreak/block.js"; +export * from "./PageBreak/getPageBreakSlashMenuItems.js"; +export * from "./PageBreak/schema.js"; +export * from "./Paragraph/block.js"; +export * from "./Quote/block.js"; +export * from "./Table/block.js"; +export * from "./Video/block.js"; diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts new file mode 100644 index 0000000000..ceb383a611 --- /dev/null +++ b/packages/core/src/blocks/utils/listItemEnterHandler.ts @@ -0,0 +1,42 @@ +import { splitBlockTr } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js"; +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; + +export const handleEnter = ( + editor: BlockNoteEditor, + listItemType: string, +) => { + const { blockInfo, selectionEmpty } = editor.transact((tr) => { + return { + blockInfo: getBlockInfoFromTransaction(tr), + selectionEmpty: tr.selection.anchor === tr.selection.head, + }; + }); + + if (!blockInfo.isBlockContainer) { + return false; + } + const { bnBlock: blockContainer, blockContent } = blockInfo; + + if (!(blockContent.node.type.name === listItemType) || !selectionEmpty) { + return false; + } + + if (blockContent.node.childCount === 0) { + editor.transact((tr) => { + updateBlockTr(tr, blockContainer.beforePos, { + type: "paragraph", + props: {}, + }); + }); + return true; + } else if (blockContent.node.childCount > 0) { + return editor.transact((tr) => { + tr.deleteSelection(); + return splitBlockTr(tr, tr.selection.from, true); + }); + } + + return false; +}; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ecdabb4272..12d738abfe 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -3,6 +3,7 @@ import { EditorOptions, Extension, getSchema, + InputRule, isNodeSelection, Mark, posToDOMRect, @@ -23,7 +24,10 @@ import { unnestBlock, } from "../api/blockManipulation/commands/nestBlock/nestBlock.js"; import { removeAndInsertBlocks } from "../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; -import { updateBlock } from "../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { + updateBlock, + updateBlockTr, +} from "../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlock, getNextBlock, @@ -84,7 +88,7 @@ import { TextCursorPosition } from "./cursorPositionTypes.js"; import { Selection } from "./selectionTypes.js"; import { transformPasted } from "./transformPasted.js"; -import { checkDefaultBlockTypeInSchema } from "../blocks/defaultBlockTypeGuards.js"; +import { editorHasBlockWithType } from "../blocks/defaultBlockTypeGuards.js"; import { BlockNoteSchema } from "./BlockNoteSchema.js"; import { BlockNoteTipTapEditor, @@ -112,7 +116,7 @@ import { getBlocksChangedByTransaction, } from "../api/nodeUtil.js"; import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js"; -import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js"; +import { CodeBlockOptions } from "../blocks/Code/block.js"; import type { ThreadStore, User } from "../comments/index.js"; import type { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js"; import type { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js"; @@ -121,6 +125,7 @@ import { BlockNoteExtension } from "./BlockNoteExtension.js"; import "../style.css"; import { BlockChangePlugin } from "../extensions/BlockChange/BlockChangePlugin.js"; +import { getBlockInfoFromTransaction } from "../api/getBlockInfoFromPos.js"; /** * A factory function that returns a BlockNoteExtension @@ -271,11 +276,7 @@ export type BlockNoteEditorOptions< * * @remarks `PartialBlock[]` */ - initialContent?: PartialBlock< - NoInfer, - NoInfer, - NoInfer - >[]; + initialContent?: PartialBlock[]; /** * @deprecated, provide placeholders via dictionary instead @@ -662,14 +663,14 @@ export class BlockNoteEditor< // @ts-ignore this.schema = newOptions.schema; - this.blockImplementations = newOptions.schema.blockSpecs; + this.blockImplementations = newOptions.schema.blockSpecs as any; this.inlineContentImplementations = newOptions.schema.inlineContentSpecs; this.styleImplementations = newOptions.schema.styleSpecs; this.extensions = getBlockNoteExtensions({ editor: this, domAttributes: newOptions.domAttributes || {}, - blockSpecs: this.schema.blockSpecs, + blockSpecs: this.schema.blockSpecs as any, styleSpecs: this.schema.styleSpecs, inlineContentSpecs: this.schema.inlineContentSpecs, collaboration: newOptions.collaboration, @@ -677,7 +678,7 @@ export class BlockNoteEditor< disableExtensions: newOptions.disableExtensions, setIdAttribute: newOptions.setIdAttribute, animations: newOptions.animations ?? true, - tableHandles: checkDefaultBlockTypeInSchema("table", this), + tableHandles: editorHasBlockWithType(this, "table"), dropCursor: this.options.dropCursor ?? dropCursor, placeholders: newOptions.placeholders, tabBehavior: newOptions.tabBehavior, @@ -696,7 +697,7 @@ export class BlockNoteEditor< // factory ext = ext(this); } - const key = (ext.constructor as any).key(); + const key = (ext as any).key ?? (ext.constructor as any).key(); if (!key) { throw new Error( `Extension ${ext.constructor.name} does not have a key method`, @@ -798,31 +799,94 @@ export class BlockNoteEditor< initialContent, ); } - + const blockExtensions = Object.fromEntries( + Object.values(this.schema.blockSpecs) + .map((block) => (block as any).extensions as any) + .filter((ext) => ext !== undefined) + .flat() + .map((ext) => [ext.key ?? ext.constructor.key(), ext]), + ); const tiptapExtensions = [ - ...Object.entries(this.extensions).map(([key, ext]) => { - if ( - ext instanceof Extension || - ext instanceof TipTapNode || - ext instanceof Mark - ) { - // tiptap extension - return ext; - } + ...Object.entries({ ...this.extensions, ...blockExtensions }).map( + ([key, ext]) => { + if ( + ext instanceof Extension || + ext instanceof TipTapNode || + ext instanceof Mark + ) { + // tiptap extension + return ext; + } - if (ext instanceof BlockNoteExtension && !ext.plugins.length) { - return undefined; - } + if (ext instanceof BlockNoteExtension) { + if ( + !ext.plugins.length && + !ext.keyboardShortcuts && + !ext.inputRules + ) { + return undefined; + } + // "blocknote" extensions (prosemirror plugins) + return Extension.create({ + name: key, + priority: ext.priority, + addProseMirrorPlugins: () => ext.plugins, + // TODO maybe collect all input rules from all extensions into one plugin + // TODO consider using the prosemirror-inputrules package instead + addInputRules: ext.inputRules + ? () => + ext.inputRules!.map( + (inputRule) => + new InputRule({ + find: inputRule.find, + handler: ({ range, match, state }) => { + const replaceWith = inputRule.replace({ + match, + range, + editor: this, + }); + if (replaceWith) { + const blockInfo = getBlockInfoFromTransaction( + state.tr, + ); + + // TODO this is weird, why do we need it? + if ( + blockInfo.isBlockContainer && + blockInfo.blockContent.node.type.spec + .content === "inline*" + ) { + updateBlockTr( + state.tr, + blockInfo.bnBlock.beforePos, + replaceWith, + range.from, + range.to, + ); + return undefined; + } + } + return null; + }, + }), + ) + : undefined, + addKeyboardShortcuts: ext.keyboardShortcuts + ? () => { + return Object.fromEntries( + Object.entries(ext.keyboardShortcuts!).map( + ([key, value]) => [key, () => value({ editor: this })], + ), + ); + } + : undefined, + }); + } - // "blocknote" extensions (prosemirror plugins) - return Extension.create({ - name: key, - priority: ext.priority, - addProseMirrorPlugins: () => ext.plugins, - }); - }), + return undefined; + }, + ), ].filter((ext): ext is Extension => ext !== undefined); - const tiptapOptions: BlockNoteTipTapEditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index 0281943575..737f7e3fd1 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -1,6 +1,9 @@ import { Plugin } from "prosemirror-state"; import { EventEmitter } from "../util/EventEmitter.js"; +import { BlockNoteEditor } from "./BlockNoteEditor.js"; +import { PartialBlockNoDefaults } from "../schema/index.js"; + export abstract class BlockNoteExtension< TEvent extends Record = any, > extends EventEmitter { @@ -23,4 +26,43 @@ export abstract class BlockNoteExtension< // Allow subclasses to have constructors with parameters // without this, we can't easily implement BlockNoteEditor.extension(MyExtension) pattern } + + /** + * Input rules for the block + */ + public inputRules?: InputRule[]; + + public keyboardShortcuts?: Record< + string, + (ctx: { + // TODO types + editor: BlockNoteEditor; + }) => boolean + >; } + +export type InputRule = { + /** + * The regex to match when to trigger the input rule + */ + find: RegExp; + /** + * The function to call when the input rule is matched + * @returns undefined if the input rule should not be triggered, or an object with the type and props to update the block + */ + replace: (props: { + /** + * The result of the regex match + */ + match: RegExpMatchArray; + // TODO this will be a Point, when we have the Location API + /** + * The range of the text that was matched + */ + range: { from: number; to: number }; + /** + * The editor instance + */ + editor: BlockNoteEditor; + }) => undefined | PartialBlockNoDefaults; +}; diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index acb5078ca6..0486b70245 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -1,4 +1,4 @@ -import { AnyExtension, Extension, extensions } from "@tiptap/core"; +import { AnyExtension, Extension, extensions, Node } from "@tiptap/core"; import { Gapcursor } from "@tiptap/extension-gapcursor"; import { History } from "@tiptap/extension-history"; import { Link } from "@tiptap/extension-link"; @@ -274,17 +274,25 @@ const getTipTapExtensions = < ...Object.values(opts.blockSpecs).flatMap((blockSpec) => { return [ // dependent nodes (e.g.: tablecell / row) - ...(blockSpec.implementation.requiredExtensions || []).map((ext) => + ...( + ("requiredExtensions" in blockSpec.implementation && + (blockSpec.implementation.requiredExtensions as Extension[])) || + [] + ).map((ext) => ext.configure({ editor: opts.editor, domAttributes: opts.domAttributes, }), ), // the actual node itself - blockSpec.implementation.node.configure({ - editor: opts.editor, - domAttributes: opts.domAttributes, - }), + ...("node" in blockSpec.implementation + ? [ + (blockSpec.implementation.node as Node).configure({ + editor: opts.editor, + domAttributes: opts.domAttributes, + }), + ] + : []), ]; }), createCopyToClipboardExtension(opts.editor), diff --git a/packages/core/src/editor/BlockNoteSchema.ts b/packages/core/src/editor/BlockNoteSchema.ts index e9af493884..a92a79e508 100644 --- a/packages/core/src/editor/BlockNoteSchema.ts +++ b/packages/core/src/editor/BlockNoteSchema.ts @@ -3,53 +3,33 @@ import { defaultInlineContentSpecs, defaultStyleSpecs, } from "../blocks/defaultBlocks.js"; -import type { - BlockNoDefaults, - PartialBlockNoDefaults, -} from "../schema/blocks/types.js"; import { + BlockNoDefaults, BlockSchema, BlockSchemaFromSpecs, BlockSpecs, InlineContentSchema, InlineContentSchemaFromSpecs, InlineContentSpecs, + PartialBlockNoDefaults, StyleSchema, StyleSchemaFromSpecs, StyleSpecs, - getBlockSchemaFromSpecs, - getInlineContentSchemaFromSpecs, - getStyleSchemaFromSpecs, } from "../schema/index.js"; -import type { BlockNoteEditor } from "./BlockNoteEditor.js"; +import { BlockNoteEditor } from "./BlockNoteEditor.js"; -function removeUndefined | undefined>(obj: T): T { - if (!obj) { - return obj; - } - return Object.fromEntries( - Object.entries(obj).filter(([, value]) => value !== undefined), - ) as T; -} +import { CustomBlockNoteSchema } from "./CustomSchema.js"; export class BlockNoteSchema< BSchema extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema, -> { - public readonly blockSpecs: BlockSpecs; - public readonly inlineContentSpecs: InlineContentSpecs; - public readonly styleSpecs: StyleSpecs; - - public readonly blockSchema: BSchema; - public readonly inlineContentSchema: ISchema; - public readonly styleSchema: SSchema; - +> extends CustomBlockNoteSchema { // Helper so that you can use typeof schema.BlockNoteEditor - public readonly BlockNoteEditor: BlockNoteEditor = + public readonly BlockNoteEditor: BlockNoteEditor = "only for types" as any; - public readonly Block: BlockNoDefaults = + public readonly Block: BlockNoDefaults = "only for types" as any; public readonly PartialBlock: PartialBlockNoDefaults< @@ -80,28 +60,11 @@ export class BlockNoteSchema< BlockSchemaFromSpecs, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs - >(options); - // as BlockNoteSchema< - // BlockSchemaFromSpecs, - // InlineContentSchemaFromSpecs, - // StyleSchemaFromSpecs - // >; - } - - constructor(opts?: { - blockSpecs?: BlockSpecs; - inlineContentSpecs?: InlineContentSpecs; - styleSpecs?: StyleSpecs; - }) { - this.blockSpecs = removeUndefined(opts?.blockSpecs) || defaultBlockSpecs; - this.inlineContentSpecs = - removeUndefined(opts?.inlineContentSpecs) || defaultInlineContentSpecs; - this.styleSpecs = removeUndefined(opts?.styleSpecs) || defaultStyleSpecs; - - this.blockSchema = getBlockSchemaFromSpecs(this.blockSpecs) as any; - this.inlineContentSchema = getInlineContentSchemaFromSpecs( - this.inlineContentSpecs, - ) as any; - this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; + >({ + blockSpecs: options?.blockSpecs ?? defaultBlockSpecs, + inlineContentSpecs: + options?.inlineContentSpecs ?? defaultInlineContentSpecs, + styleSpecs: options?.styleSpecs ?? defaultStyleSpecs, + }); } } diff --git a/packages/core/src/editor/CustomSchema.ts b/packages/core/src/editor/CustomSchema.ts new file mode 100644 index 0000000000..212d96c1cb --- /dev/null +++ b/packages/core/src/editor/CustomSchema.ts @@ -0,0 +1,111 @@ +import { + BlockSchema, + BlockSpec, + BlockSpecs, + InlineContentSchema, + InlineContentSpecs, + StyleSchema, + StyleSpecs, + addNodeAndExtensionsToSpec, + getInlineContentSchemaFromSpecs, + getStyleSchemaFromSpecs, +} from "../schema/index.js"; +import { createDependencyGraph, toposortReverse } from "../util/topo-sort.js"; + +function removeUndefined | undefined>(obj: T): T { + if (!obj) { + return obj; + } + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), + ) as T; +} + +export class CustomBlockNoteSchema< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema, +> { + public readonly inlineContentSpecs: InlineContentSpecs; + public readonly styleSpecs: StyleSpecs; + public readonly blockSpecs: BlockSpecs; + + public readonly blockSchema: BSchema; + public readonly inlineContentSchema: ISchema; + public readonly styleSchema: SSchema; + + constructor(opts: { + blockSpecs: BlockSpecs; + inlineContentSpecs: InlineContentSpecs; + styleSpecs: StyleSpecs; + }) { + this.blockSpecs = this.initBlockSpecs(opts.blockSpecs); + this.blockSchema = Object.fromEntries( + Object.entries(this.blockSpecs).map(([key, blockDef]) => { + return [key, blockDef.config]; + }), + ) as any; + this.inlineContentSpecs = removeUndefined(opts.inlineContentSpecs); + this.styleSpecs = removeUndefined(opts.styleSpecs); + + this.inlineContentSchema = getInlineContentSchemaFromSpecs( + this.inlineContentSpecs, + ) as any; + this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; + } + + private initBlockSpecs(specs: BlockSpecs): BlockSpecs { + const dag = createDependencyGraph(); + const defaultSet = new Set(); + dag.set("default", defaultSet); + + for (const [key, specDef] of Object.entries(specs)) { + if (specDef.implementation.runsBefore) { + dag.set(key, new Set(specDef.implementation.runsBefore)); + } else { + defaultSet.add(key); + } + } + const sortedSpecs = toposortReverse(dag); + const defaultIndex = sortedSpecs.findIndex((set) => set.has("default")); + + /** + * The priority of a block is described relative to the "default" block (an arbitrary block which can be used as the reference) + * + * Since blocks are topologically sorted, we can see what their relative position is to the "default" block + * Each layer away from the default block is 10 priority points (arbitrarily chosen) + * The default block is fixed at 101 (1 point higher than any tiptap extension, giving priority to custom blocks than any defaults) + * + * This is a bit of a hack, but it's a simple way to ensure that custom blocks are always rendered with higher priority than default blocks + * and that custom blocks are rendered in the order they are defined in the schema + */ + const getPriority = (key: string) => { + const index = sortedSpecs.findIndex((set) => set.has(key)); + // the default index should map to 101 + // one before the default index is 91 + // one after is 111 + return 91 + (index + defaultIndex) * 10; + }; + + return Object.fromEntries( + Object.entries(specs).map(([key, blockSpec]: [string, BlockSpec]) => { + return [ + key, + Object.assign( + { + extensions: blockSpec.extensions, + }, + // TODO annoying hack to get tables to work + blockSpec.config.type === "table" + ? blockSpec + : addNodeAndExtensionsToSpec( + blockSpec.config, + blockSpec.implementation, + getPriority(key), + ), + ), + ]; + }), + ) as BlockSpecs; + } +} diff --git a/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts b/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts index 5b42b866d4..33b6e3e152 100644 --- a/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts +++ b/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts @@ -7,7 +7,7 @@ import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; import type { BlockFromConfig, - FileBlockConfig, + // FileBlockConfig, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; @@ -17,7 +17,7 @@ export type FilePanelState< S extends StyleSchema, > = UiElementPosition & { // TODO: This typing is not quite right (children should be from BSchema) - block: BlockFromConfig; + block: BlockFromConfig; }; export class FilePanelView @@ -27,11 +27,7 @@ export class FilePanelView public emitUpdate: () => void; constructor( - private readonly editor: BlockNoteEditor< - Record, - I, - S - >, + private readonly editor: BlockNoteEditor, I, S>, private readonly pluginKey: PluginKey>, private readonly pmView: EditorView, emitUpdate: (state: FilePanelState) => void, @@ -145,17 +141,17 @@ export class FilePanelProsemirrorPlugin< private view: FilePanelView | undefined; - constructor(editor: BlockNoteEditor, I, S>) { + constructor(editor: BlockNoteEditor, I, S>) { super(); this.addProsemirrorPlugin( new Plugin<{ - block: BlockFromConfig | undefined; + block: BlockFromConfig | undefined; }>({ key: filePanelPluginKey, view: (editorView) => { this.view = new FilePanelView( editor, - filePanelPluginKey, + filePanelPluginKey as any, editorView, (state) => { this.emit("update", state); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts index 4f02c94625..85ab84ded2 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts @@ -1,6 +1,6 @@ import type { Emoji, EmojiMartData } from "@emoji-mart/data"; -import { checkDefaultInlineContentTypeInSchema } from "../../blocks/defaultBlockTypeGuards.js"; +import { defaultInlineContentSchema } from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockSchema, @@ -54,7 +54,11 @@ export async function getDefaultEmojiPickerItems< editor: BlockNoteEditor, query: string, ): Promise { - if (!checkDefaultInlineContentTypeInSchema("text", editor)) { + if ( + !("text" in editor.schema.inlineContentSchema) || + editor.schema.inlineContentSchema["text"] !== + defaultInlineContentSchema["text"] + ) { return []; } diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index bcc95c83a7..3ba14d2b81 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -1,7 +1,7 @@ import { Block, PartialBlock } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { checkDefaultBlockTypeInSchema } from "../../blocks/defaultBlockTypeGuards.js"; +import { editorHasBlockWithType } from "../../blocks/defaultBlockTypeGuards.js"; import { BlockSchema, InlineContentSchema, @@ -87,7 +87,7 @@ export function getDefaultSlashMenuItems< >(editor: BlockNoteEditor) { const items: DefaultSuggestionItem[] = []; - if (checkDefaultBlockTypeInSchema("heading", editor)) { + if (editorHasBlockWithType(editor, "heading", { level: "number" })) { items.push( { onItemClick: () => { @@ -125,7 +125,7 @@ export function getDefaultSlashMenuItems< ); } - if (checkDefaultBlockTypeInSchema("quote", editor)) { + if (editorHasBlockWithType(editor, "quote")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -137,7 +137,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("toggleListItem", editor)) { + if (editorHasBlockWithType(editor, "toggleListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -150,7 +150,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("numberedListItem", editor)) { + if (editorHasBlockWithType(editor, "numberedListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -163,7 +163,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("bulletListItem", editor)) { + if (editorHasBlockWithType(editor, "bulletListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -176,7 +176,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("checkListItem", editor)) { + if (editorHasBlockWithType(editor, "checkListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -189,7 +189,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("paragraph", editor)) { + if (editorHasBlockWithType(editor, "paragraph")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -202,7 +202,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("codeBlock", editor)) { + if (editorHasBlockWithType(editor, "codeBlock")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -215,7 +215,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("table", editor)) { + if (editorHasBlockWithType(editor, "table")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -230,7 +230,7 @@ export function getDefaultSlashMenuItems< cells: ["", "", ""], }, ], - }, + } as any, }); }, badge: undefined, @@ -239,7 +239,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("image", editor)) { + if (editorHasBlockWithType(editor, "image", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -258,7 +258,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("video", editor)) { + if (editorHasBlockWithType(editor, "video", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -277,7 +277,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("audio", editor)) { + if (editorHasBlockWithType(editor, "audio", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -296,7 +296,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("file", editor)) { + if (editorHasBlockWithType(editor, "file", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -315,7 +315,12 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("heading", editor)) { + if ( + editorHasBlockWithType(editor, "heading", { + level: "number", + isToggleable: "boolean", + }) + ) { items.push( { onItemClick: () => { diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index 388cb47f36..a8e444db71 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -27,7 +27,7 @@ import { import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../api/nodeUtil.js"; import { - checkBlockIsDefaultType, + editorHasBlockWithType, isTableCellSelection, } from "../../blocks/defaultBlockTypeGuards.js"; import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js"; @@ -278,7 +278,7 @@ export class TableHandlesView< this.editor.schema.styleSchema, ); - if (checkBlockIsDefaultType("table", block, this.editor)) { + if (editorHasBlockWithType(this.editor, "table")) { this.tablePos = pmNodeInfo.posBeforeNode + 1; tableBlock = block; } diff --git a/packages/core/src/extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/UniqueID/UniqueID.ts index b0f5be9827..23f6591256 100644 --- a/packages/core/src/extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/UniqueID/UniqueID.ts @@ -134,19 +134,12 @@ const UniqueID = Extension.create({ new Plugin({ key: new PluginKey("uniqueID"), appendTransaction: (transactions, oldState, newState) => { - // console.log("appendTransaction"); const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); const filterTransactions = this.options.filterTransaction && - transactions.some((tr) => { - let _a, _b; - return !((_b = (_a = this.options).filterTransaction) === null || - _b === void 0 - ? void 0 - : _b.call(_a, tr)); - }); + transactions.some((tr) => !this.options.filterTransaction?.(tr)); if (!docChanges || filterTransactions) { return; } @@ -172,16 +165,14 @@ const UniqueID = Extension.create({ .map(({ node }) => node.attrs[attributeName]) .filter((id) => id !== null); const duplicatedNewIds = findDuplicates(newIds); + newNodes.forEach(({ node, pos }) => { - let _a; // instead of checking `node.attrs[attributeName]` directly // we look at the current state of the node within `tr.doc`. // this helps to prevent adding new ids to the same node // if the node changed multiple times within one transaction - const id = - (_a = tr.doc.nodeAt(pos)) === null || _a === void 0 - ? void 0 - : _a.attrs[attributeName]; + const id = tr.doc.nodeAt(pos)?.attrs[attributeName]; + if (id === null) { // edge case, when using collaboration, yjs will set the id to null in `_forceRerender` // when loading the editor @@ -230,6 +221,8 @@ const UniqueID = Extension.create({ if (!tr.steps.length) { return; } + // mark the transaction as having been processed by the uniqueID plugin + tr.setMeta("uniqueID", true); return tr; }, // we register a global drag handler to track the current drag source element diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 74f91bee5c..d74b7b3f8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,32 +6,16 @@ export * from "./api/exporters/html/internalHTMLSerializer.js"; export * from "./api/getBlockInfoFromPos.js"; export * from "./api/nodeUtil.js"; export * from "./api/pmUtil.js"; -export * from "./blocks/AudioBlockContent/AudioBlockContent.js"; -export * from "./blocks/CodeBlockContent/CodeBlockContent.js"; +export * from "./blocks/index.js"; export * from "./blocks/defaultBlockHelpers.js"; export * from "./blocks/defaultBlocks.js"; export * from "./blocks/defaultBlockTypeGuards.js"; export * from "./blocks/defaultProps.js"; -export * from "./blocks/FileBlockContent/FileBlockContent.js"; -export * from "./blocks/FileBlockContent/helpers/parse/parseEmbedElement.js"; -export * from "./blocks/FileBlockContent/helpers/parse/parseFigureElement.js"; -export * from "./blocks/FileBlockContent/helpers/render/createAddFileButton.js"; -export * from "./blocks/FileBlockContent/helpers/render/createFileBlockWrapper.js"; -export * from "./blocks/FileBlockContent/helpers/render/createFileNameWithIcon.js"; -export * from "./blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; -export * from "./blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; -export * from "./blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -export * from "./blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.js"; -export * from "./blocks/ImageBlockContent/ImageBlockContent.js"; -export * from "./blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.js"; -export * from "./blocks/PageBreakBlockContent/PageBreakBlockContent.js"; -export * from "./blocks/PageBreakBlockContent/schema.js"; export * from "./blocks/ToggleWrapper/createToggleWrapper.js"; export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH, -} from "./blocks/TableBlockContent/TableExtension.js"; -export * from "./blocks/VideoBlockContent/VideoBlockContent.js"; +} from "./blocks/Table/TableExtension.js"; export * from "./editor/BlockNoteEditor.js"; export * from "./editor/BlockNoteExtension.js"; export * from "./editor/BlockNoteExtensions.js"; @@ -60,7 +44,7 @@ export * from "./util/string.js"; export * from "./util/table.js"; export * from "./util/typescript.js"; -export type { CodeBlockOptions } from "./blocks/CodeBlockContent/CodeBlockContent.js"; +export type { CodeBlockOptions } from "./blocks/Code/block.js"; export { assertEmpty, UnreachableCaseError } from "./util/typescript.js"; export * from "./util/EventEmitter.js"; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 8a5bc3cb96..02b0ee72d7 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -1,68 +1,17 @@ import { Editor } from "@tiptap/core"; -import { TagParseRule } from "@tiptap/pm/model"; -import { NodeView, ViewMutationRecord } from "@tiptap/pm/view"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { InlineContentSchema } from "../inlineContent/types.js"; -import { StyleSchema } from "../styles/types.js"; +import { DOMParser, Fragment, TagParseRule } from "@tiptap/pm/model"; +import { NodeView } from "@tiptap/pm/view"; +import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { - createInternalBlockSpec, + createTypedBlockSpec, createStronglyTypedTiptapNode, getBlockFromPos, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; -import { - BlockConfig, - BlockFromConfig, - BlockSchemaWithBlock, - PartialBlockFromConfig, -} from "./types.js"; - -// restrict content to "inline" and "none" only -export type CustomBlockConfig = BlockConfig & { - content: "inline" | "none"; -}; - -export type CustomBlockImplementation< - T extends CustomBlockConfig, - I extends InlineContentSchema, - S extends StyleSchema, -> = { - render: ( - /** - * The custom block to render - */ - block: BlockFromConfig, - /** - * The BlockNote editor instance - * This is typed generically. If you want an editor with your custom schema, you need to - * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` - */ - editor: BlockNoteEditor, I, S>, - // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations - // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics - ) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - destroy?: () => void; - }; - // Exports block to external HTML. If not defined, the output will be the same - // as `render(...).dom`. Used to create clipboard data when pasting outside - // BlockNote. - // TODO: Maybe can return undefined to ignore when serializing? - toExternalHTML?: ( - block: BlockFromConfig, - editor: BlockNoteEditor, I, S>, - ) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; - - parse?: ( - el: HTMLElement, - ) => PartialBlockFromConfig["props"] | undefined; -}; +import { BlockConfig, BlockImplementation, BlockSpec } from "./types.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { PropSchema } from "../propTypes.js"; // Function that causes events within non-selectable blocks to be handled by the // browser instead of the editor. @@ -84,9 +33,18 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) { // Function that uses the 'parse' function of a blockConfig to create a // TipTap node's `parseHTML` property. This is only used for parsing content // from the clipboard. -export function getParseRules( - config: BlockConfig, - customParseFunction: CustomBlockImplementation["parse"], +export function getParseRules< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table", +>( + config: BlockConfig, + customParseFunction: BlockImplementation["parse"], + customParseContentFunction: BlockImplementation< + TName, + TProps, + TContent + >["parseContent"], ) { const rules: TagParseRule[] = [ { @@ -111,6 +69,37 @@ export function getParseRules( return props; }, + getContent: + config.content === "inline" || config.content === "none" + ? (node, schema) => { + if (customParseContentFunction) { + return customParseContentFunction({ + el: node as HTMLElement, + schema, + }); + } + + if (config.content === "inline") { + // Parse the blockquote content as inline content + const element = node as HTMLElement; + + // Clone to avoid modifying the original + const clone = element.cloneNode(true) as HTMLElement; + + // Merge multiple paragraphs into one with line breaks + mergeParagraphs(clone, config.meta?.code ? "\n" : "
              "); + + // Parse the content directly as a paragraph to extract inline content + const parser = DOMParser.fromSchema(schema); + const parsed = parser.parse(clone, { + topNode: schema.nodes.paragraph.create(), + }); + + return parsed.content; + } + return Fragment.empty; + } + : undefined, }); } // getContent(node, schema) { @@ -134,28 +123,38 @@ export function getParseRules( // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead -export function createBlockSpec< - T extends CustomBlockConfig, - I extends InlineContentSchema, - S extends StyleSchema, +export function addNodeAndExtensionsToSpec< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table", >( - blockConfig: T, - blockImplementation: CustomBlockImplementation, I, S>, + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + priority?: number, ) { const node = createStronglyTypedTiptapNode({ - name: blockConfig.type as T["type"], + name: blockConfig.type, content: (blockConfig.content === "inline" ? "inline*" - : "") as T["content"] extends "inline" ? "inline*" : "", + : blockConfig.content === "none" + ? "" + : blockConfig.content) as TContent extends "inline" ? "inline*" : "", group: "blockContent", - selectable: blockConfig.isSelectable ?? true, + selectable: blockConfig.meta?.selectable ?? true, isolating: true, + code: blockConfig.meta?.code ?? false, + defining: blockConfig.meta?.defining ?? true, + priority, addAttributes() { return propsToAttributes(blockConfig.propSchema); }, parseHTML() { - return getParseRules(blockConfig, blockImplementation.parse); + return getParseRules( + blockConfig, + blockImplementation.parse, + (blockImplementation as any).parseContent, + ); }, renderHTML({ HTMLAttributes }) { @@ -173,7 +172,7 @@ export function createBlockSpec< blockConfig.type, {}, blockConfig.propSchema, - blockConfig.isFileBlock, + blockConfig.meta?.fileBlockAccept !== undefined, HTMLAttributes, ); }, @@ -195,16 +194,16 @@ export function createBlockSpec< const output = blockImplementation.render(block as any, editor); - const nodeView: NodeView = wrapInBlockStructure( + const nodeView = wrapInBlockStructure( output, block.type, block.props, blockConfig.propSchema, - blockConfig.isFileBlock, + blockConfig.meta?.fileBlockAccept !== undefined, blockContentDOMAttributes, - ); + ) satisfies NodeView; - if (blockConfig.isSelectable === false) { + if (blockConfig.meta?.selectable === false) { applyNonSelectableBlockFix(nodeView, this.editor); } @@ -219,9 +218,9 @@ export function createBlockSpec< ); } - return createInternalBlockSpec(blockConfig, { + return createTypedBlockSpec(blockConfig, { node, - toInternalHTML: (block, editor) => { + render: (block, editor) => { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; @@ -232,7 +231,7 @@ export function createBlockSpec< block.type, block.props, blockConfig.propSchema, - blockConfig.isFileBlock, + blockConfig.meta?.fileBlockAccept !== undefined, blockContentDOMAttributes, ); }, @@ -242,7 +241,12 @@ export function createBlockSpec< const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - let output = blockImplementation.toExternalHTML?.( + let output: + | { + dom: HTMLElement | DocumentFragment; + contentDOM?: HTMLElement; + } + | undefined = blockImplementation.toExternalHTML?.( block as any, editor as any, ); @@ -257,5 +261,74 @@ export function createBlockSpec< blockContentDOMAttributes, ); }, + // Only needed for tables right now, remove later + requiredExtensions: (blockImplementation as any).requiredExtensions, }); } + +/** + * Helper function to create a block config. + */ +export function createBlockConfig< + TCallback extends ( + options: Partial>, + ) => BlockConfig, + TOptions extends Parameters[0], + TName extends ReturnType["type"], + TProps extends ReturnType["propSchema"], + TContent extends ReturnType["content"], +>( + callback: TCallback, +): (options: TOptions) => BlockConfig { + return callback; +} + +/** + * Helper function to create a block definition. + */ +export function createBlockSpec< + TCallback extends (options?: any) => BlockConfig, + TOptions extends Parameters[0], + TName extends ReturnType["type"], + TProps extends ReturnType["propSchema"], + TContent extends ReturnType["content"], +>( + createBlockConfig: TCallback, +): { + implementation: ( + createBlockImplementation: ( + options?: TOptions, + ) => BlockImplementation, + addExtensions?: (options?: TOptions) => BlockNoteExtension[], + ) => (options?: TOptions) => BlockSpec; +} { + return { + implementation: (createBlockImplementation, addExtensions) => (options) => { + const blockConfig = createBlockConfig(options); + const blockImplementation = createBlockImplementation(options); + const extensions = addExtensions?.(options); + + return { + config: blockConfig, + implementation: blockImplementation, + extensions: extensions, + }; + }, + }; +} +/** + * This creates an instance of a BlockNoteExtension that can be used to add to a schema. + * It is a bit of a hack, but it works. + */ +export function createBlockNoteExtension( + options: Partial< + Pick + > & { key: string }, +) { + const x = Object.create(BlockNoteExtension.prototype); + x.key = options.key; + x.inputRules = options.inputRules; + x.keyboardShortcuts = options.keyboardShortcuts; + x.plugins = options.plugins ?? []; + return x as BlockNoteExtension; +} diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index d3749ab53a..3777230075 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -16,12 +16,10 @@ import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { BlockConfig, - BlockSchemaFromSpecs, + BlockImplementation, BlockSchemaWithBlock, BlockSpec, - BlockSpecs, SpecificBlock, - TiptapBlockImplementation, } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap @@ -145,12 +143,12 @@ export function wrapInBlockStructure< PSchema extends PropSchema, >( element: { - dom: HTMLElement; + dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; destroy?: () => void; }, blockType: BType, - blockProps: Props, + blockProps: Partial>, propSchema: PSchema, isFileBlock = false, domAttributes?: Record, @@ -232,51 +230,42 @@ export function createStronglyTypedTiptapNode< // This helper function helps to instantiate a blockspec with a // config and implementation that conform to the type of Config -export function createInternalBlockSpec( +export function createTypedBlockSpec( config: T, - implementation: TiptapBlockImplementation< - T, - any, - InlineContentSchema, - StyleSchema - >, -) { + implementation: BlockImplementation< + T["type"], + T["propSchema"], + T["content"] + > & { + node: Node; + requiredExtensions?: Array; + }, +): BlockSpec { return { config, implementation, - } satisfies BlockSpec; + }; } export function createBlockSpecFromStronglyTypedTiptapNode< T extends Node, P extends PropSchema, >(node: T, propSchema: P, requiredExtensions?: Array) { - return createInternalBlockSpec( + return createTypedBlockSpec( { type: node.name as T["name"], content: (node.config.content === "inline*" ? "inline" : node.config.content === "tableRow+" ? "table" - : "none") as T["config"]["content"] extends "inline*" - ? "inline" - : T["config"]["content"] extends "tableRow+" - ? "table" - : "none", + : "none") as any, // TODO does this typing even matter? propSchema, }, { node, requiredExtensions, - toInternalHTML: defaultBlockToHTML, + render: defaultBlockToHTML, toExternalHTML: defaultBlockToHTML, - // parse: () => undefined, // parse rules are in node already }, ); } - -export function getBlockSchemaFromSpecs(specs: T) { - return Object.fromEntries( - Object.entries(specs).map(([key, value]) => [key, value.config]), - ) as BlockSchemaFromSpecs; -} diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 0f97205638..4806f9faa3 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,7 +1,9 @@ /** Define the main block types **/ -import type { Extension, Node } from "@tiptap/core"; - +// import { Extension, Node } from "@tiptap/core"; +import type { Fragment, Schema } from "prosemirror-model"; +import type { ViewMutationRecord } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import type { InlineContent, InlineContentSchema, @@ -21,129 +23,107 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; -export type FileBlockConfig = { - type: string; - readonly propSchema: PropSchema & { - caption: { - default: ""; - }; - name: { - default: ""; - }; - - // URL is optional, as we also want to accept files with no URL, but for example ids - // (ids can be used for files that are resolved on the backend) - url?: { - default: ""; - }; - - // Whether to show the file preview or the name only. - // This is useful for some file blocks, but not all - // (e.g.: not relevant for default "file" block which doesn;'t show previews) - showPreview?: { - default: boolean; - }; - // File preview width in px. - previewWidth?: { - default: undefined; - type: "number"; - }; - }; - content: "none"; - isSelectable?: boolean; - isFileBlock: true; +export interface BlockConfigMeta { + /** + * Whether the block is selectable + */ + selectable?: boolean; + + /** + * The accept mime types for the file block + */ fileBlockAccept?: string[]; -}; -// BlockConfig contains the "schema" info about a Block type -// i.e. what props it supports, what content it supports, etc. -export type BlockConfig = - | { - type: string; - readonly propSchema: PropSchema; - content: "inline" | "none" | "table"; - isSelectable?: boolean; - isFileBlock?: false; - hardBreakShortcut?: "shift+enter" | "enter" | "none"; - } - | FileBlockConfig; - -// Block implementation contains the "implementation" info about a Block -// such as the functions / Nodes required to render and / or serialize it -export type TiptapBlockImplementation< - T extends BlockConfig, - B extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, -> = { - requiredExtensions?: Array; - node: Node; - toInternalHTML: ( - block: BlockFromConfigNoChildren & { - children: BlockNoDefaults[]; - }, - editor: BlockNoteEditor, - ) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; - toExternalHTML: ( - block: BlockFromConfigNoChildren & { - children: BlockNoDefaults[]; - }, - editor: BlockNoteEditor, - ) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; -}; + /** + * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.code} block + */ + code?: boolean; + + /** + * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.defining} block + */ + defining?: boolean; +} + +/** + * BlockConfig contains the "schema" info about a Block type + * i.e. what props it supports, what content it supports, etc. + */ +export interface BlockConfig< + T extends string = string, + PS extends PropSchema = PropSchema, + C extends "inline" | "none" | "table" = "inline" | "none" | "table", +> { + /** + * The type of the block (unique identifier within a schema) + */ + type: T; + /** + * The properties that the block supports + * @todo will be zod schema in the future + */ + readonly propSchema: PS; + /** + * The content that the block supports + */ + content: C; + // TODO: how do you represent things that have nested content? + // e.g. tables, alerts (with title & content) + /** + * Metadata + */ + meta?: BlockConfigMeta; +} + +// restrict content to "inline" and "none" only +export type CustomBlockConfig< + T extends string = string, + PS extends PropSchema = PropSchema, + C extends "inline" | "none" = "inline" | "none", +> = BlockConfig; // A Spec contains both the Config and Implementation export type BlockSpec< - T extends BlockConfig, - B extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, + T extends string = string, + PS extends PropSchema = PropSchema, + C extends "inline" | "none" | "table" = "inline" | "none" | "table", > = { - config: T; - implementation: TiptapBlockImplementation, B, I, S>; + config: BlockConfig; + implementation: BlockImplementation; + extensions?: BlockNoteExtension[]; }; -// 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. -type NamesMatch> = Blocks extends { - [Type in keyof Blocks]: Type extends string - ? Blocks[Type] extends { type: Type } - ? Blocks[Type] - : never - : never; -} - ? Blocks - : never; - // A Schema contains all the types (Configs) supported in an editor // The keys are the "type" of a block -export type BlockSchema = NamesMatch>; +export type BlockSchema = Record; -export type BlockSpecs = Record< - string, - BlockSpec ->; +export type BlockSpecs = { + [k in string]: BlockSpec; +}; export type BlockImplementations = Record< string, - TiptapBlockImplementation + BlockImplementation >; -export type BlockSchemaFromSpecs = { - [K in keyof T]: T[K]["config"]; +export type BlockSchemaFromSpecs = { + [K in keyof BS]: BS[K]["config"]; }; -export type BlockSchemaWithBlock< - BType extends string, - C extends BlockConfig, -> = { - [k in BType]: C; +export type BlockSpecsFromSchema = { + [K in keyof BS]: { + config: BlockConfig; + implementation: BlockImplementation< + BS[K]["type"], + BS[K]["propSchema"], + BS[K]["content"] + >; + extensions?: BlockNoteExtension[]; + }; +}; + +export type BlockSchemaWithBlock = { + [k in T]: C; }; export type TableCellProps = { @@ -275,7 +255,9 @@ type PartialBlockFromConfigNoChildren< ? PartialInlineContent : B["content"] extends "table" ? PartialTableContent - : undefined; + : B["content"] extends "none" + ? undefined + : never; }; type PartialBlocksWithoutChildren< @@ -321,3 +303,78 @@ export type PartialBlockFromConfig< }; export type BlockIdentifier = { id: string } | string; + +export type BlockImplementation< + TName extends string = string, + TProps extends PropSchema = PropSchema, + TContent extends "inline" | "none" | "table" = "inline" | "none" | "table", +> = { + /** + * A function that converts the block into a DOM element + */ + render: ( + /** + * The custom block to render + */ + block: BlockNoDefaults< + Record>, + any, + any + >, + /** + * The BlockNote editor instance + */ + editor: BlockNoteEditor< + Record> + >, + ) => { + dom: HTMLElement | DocumentFragment; + contentDOM?: HTMLElement; + ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + destroy?: () => void; + }; + + /** + * Exports block to external HTML. If not defined, the output will be the same + * as `render(...).dom`. + */ + toExternalHTML?: ( + block: BlockNoDefaults< + Record>, + any, + any + >, + editor: BlockNoteEditor< + Record> + >, + ) => + | { + dom: HTMLElement | DocumentFragment; + contentDOM?: HTMLElement; + } + | undefined; + + /** + * Parses an external HTML element into a block of this type when it returns the block props object, otherwise undefined + */ + parse?: (el: HTMLElement) => Partial> | undefined; + + /** + * The blocks that this block should run before. + * This is used to determine the order in which blocks are rendered. + */ + runsBefore?: string[]; + + /** + * Advanced parsing function that controls how content within the block is parsed. + * This is not recommended to use, and is only useful for advanced use cases. + */ + parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment; +}; + +// restrict content to "inline" and "none" only +export type CustomBlockImplementation< + T extends string = string, + PS extends PropSchema = PropSchema, + C extends "inline" | "none" = "inline" | "none", +> = BlockImplementation; diff --git a/packages/core/src/schema/propTypes.ts b/packages/core/src/schema/propTypes.ts index 76a8df2769..23752f92ea 100644 --- a/packages/core/src/schema/propTypes.ts +++ b/packages/core/src/schema/propTypes.ts @@ -9,6 +9,7 @@ export type PropSpec = | { // We infer the type of the prop from the default value default: PType; + type?: "string" | "number" | "boolean"; // a list of possible values, for example for a string prop (this will then be used as a string union type) values?: readonly PType[]; } @@ -34,17 +35,23 @@ export type Props = { // for required props, get type from type of "default" value, // and if values are specified, get type from values [PName in keyof PSchema]: ( - PSchema[PName] extends { default: boolean } | { type: "boolean" } - ? PSchema[PName]["values"] extends readonly boolean[] - ? PSchema[PName]["values"][number] + NonNullable extends + | { default: boolean } + | { type: "boolean" } + ? NonNullable["values"] extends readonly boolean[] + ? NonNullable["values"][number] : boolean - : PSchema[PName] extends { default: number } | { type: "number" } - ? PSchema[PName]["values"] extends readonly number[] - ? PSchema[PName]["values"][number] + : NonNullable extends + | { default: number } + | { type: "number" } + ? NonNullable["values"] extends readonly number[] + ? NonNullable["values"][number] : number - : PSchema[PName] extends { default: string } | { type: "string" } - ? PSchema[PName]["values"] extends readonly string[] - ? PSchema[PName]["values"][number] + : NonNullable extends + | { default: string } + | { type: "string" } + ? NonNullable["values"] extends readonly string[] + ? NonNullable["values"][number] : string : never ) extends infer T diff --git a/packages/core/src/util/topo-sort.test.ts b/packages/core/src/util/topo-sort.test.ts new file mode 100644 index 0000000000..95753cd132 --- /dev/null +++ b/packages/core/src/util/topo-sort.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { toposort as batchingToposort, toposortReverse } from "./topo-sort.js"; + +describe("toposort", () => { + it("toposorts an empty graph", () => { + expect(batchingToposort(new Map())).toEqual([]); + }); + + it("toposorts a simple DAG", () => { + expect( + batchingToposort( + new Map([ + ["a", ["b"]], + ["b", ["c"]], + ["c", []], + ]), + ), + ).toEqual([new Set(["a"]), new Set(["b"]), new Set(["c"])]); + }); + + it("toposorts a richer DAG", () => { + expect( + batchingToposort( + new Map([ + ["a", ["c"]], + ["b", ["c"]], + ["c", []], + ]), + ), + ).toEqual([new Set(["a", "b"]), new Set(["c"])]); + }); + + it("toposorts a complex DAG", () => { + expect( + batchingToposort( + new Map([ + ["a", ["c", "f"]], + ["b", ["d", "e"]], + ["c", ["f"]], + ["d", ["f", "g"]], + ["e", ["h"]], + ["f", ["i"]], + ["g", ["j"]], + ["h", ["j"]], + ["i", []], + ["j", []], + ]), + ), + ).toEqual([ + new Set(["a", "b"]), + new Set(["c", "d", "e"]), + new Set(["f", "g", "h"]), + new Set(["i", "j"]), + ]); + }); + + it("errors on a small cyclic graph", () => { + const dg = new Map([ + ["a", ["b"]], + ["b", ["a"]], + ["c", []], + ]); + const sortCyclicGraph = () => { + batchingToposort(dg); + }; + expect(sortCyclicGraph).toThrowError(Error); + }); + + it("errors on a larger cyclic graph", () => { + const dg = new Map([ + ["a", ["b", "c"]], + ["b", ["c"]], + ["c", ["d", "e"]], + ["d", ["b"]], + ["e", []], + ]); + const sortCyclicGraph = () => { + batchingToposort(dg); + }; + expect(sortCyclicGraph).toThrowError(Error); + }); + + it("can sort a graph with missing dependencies", () => { + const dg = new Map([ + ["a", ["non-existent-node"]], + ["b", ["c"]], + ["c", []], + ]); + const result = batchingToposort(dg); + expect(result).toEqual([ + new Set(["a", "b"]), + new Set(["non-existent-node", "c"]), + ]); + }); +}); + +describe("toposortReverse", () => { + it("can sort stuff", () => { + const graph = new Map([ + ["floss", ["brushTeeth"]], + ["drinkCoffee", ["wakeUp"]], + ["wakeUp", []], + ["brushTeeth", ["drinkCoffee", "eatBreakfast"]], + ["eatBreakfast", ["wakeUp"]], + ]); + const result = toposortReverse(graph); + expect(result).toMatchInlineSnapshot(` + [ + Set { + "wakeUp", + }, + Set { + "drinkCoffee", + "eatBreakfast", + }, + Set { + "brushTeeth", + }, + Set { + "floss", + }, + ] + `); + }); +}); diff --git a/packages/core/src/util/topo-sort.ts b/packages/core/src/util/topo-sort.ts new file mode 100644 index 0000000000..fb09fc2075 --- /dev/null +++ b/packages/core/src/util/topo-sort.ts @@ -0,0 +1,160 @@ +/** + * Instead of depending on the NPM package, we vendor this file from https://github.com/n1ru4l/toposort/blob/main/src/toposort.ts (MIT) + * + * There was a recent publish, despite not having been updated in 2 years, which is suspicious. + * + * This file is also simple enough that we can maintain it ourselves. + */ + +export type DirectedAcyclicGraph = Map>; +export type DependencyGraph = DirectedAcyclicGraph; + +export type TaskList = Array>; + +// Add more specific types for better type safety +export type NodeId = string; +export type DependencyMap = Map>; + +export function toposort(dag: DirectedAcyclicGraph): TaskList { + const inDegrees = countInDegrees(dag); + + let { roots, nonRoots } = getRootsAndNonRoots(inDegrees); + + const sorted: TaskList = []; + + while (roots.size) { + sorted.push(roots); + + const newRoots = new Set(); + for (const root of roots) { + const dependents = dag.get(root); + if (!dependents) { + // Handle case where node has no dependents + continue; + } + + for (const dependent of dependents) { + const currentDegree = inDegrees.get(dependent); + if (currentDegree === undefined) { + // Handle case where dependent node is not in inDegrees + continue; + } + + const newDegree = currentDegree - 1; + inDegrees.set(dependent, newDegree); + + if (newDegree === 0) { + newRoots.add(dependent); + } + } + } + + roots = newRoots; + } + nonRoots = getRootsAndNonRoots(inDegrees).nonRoots; + + if (nonRoots.size) { + throw new Error( + `Cycle(s) detected; toposort only works on acyclic graphs. Cyclic nodes: ${Array.from(nonRoots).join(", ")}`, + ); + } + + return sorted; +} + +export function toposortReverse(deps: DependencyGraph): TaskList { + const dag = reverse(deps); + return toposort(dag); +} + +type InDegrees = Map; + +function countInDegrees(dag: DirectedAcyclicGraph): InDegrees { + const counts: InDegrees = new Map(); + + for (const [vx, dependents] of dag.entries()) { + // Initialize count for current node if not present + if (!counts.has(vx)) { + counts.set(vx, 0); + } + + for (const dependent of dependents) { + const currentCount = counts.get(dependent) ?? 0; + counts.set(dependent, currentCount + 1); + } + } + + return counts; +} + +function getRootsAndNonRoots(counts: InDegrees) { + const roots = new Set(); + const nonRoots = new Set(); + + for (const [id, deg] of counts.entries()) { + if (deg === 0) { + roots.add(id); + } else { + nonRoots.add(id); + } + } + + return { roots, nonRoots }; +} + +function reverse(deps: DirectedAcyclicGraph): DependencyGraph { + const reversedDeps: DependencyMap = new Map(); + + for (const [name, dependsOn] of deps.entries()) { + // Ensure the source node exists in the reversed map + if (!reversedDeps.has(name)) { + reversedDeps.set(name, new Set()); + } + + for (const dependsOnName of dependsOn) { + if (!reversedDeps.has(dependsOnName)) { + reversedDeps.set(dependsOnName, new Set()); + } + reversedDeps.get(dependsOnName)!.add(name); + } + } + + return reversedDeps; +} + +export function createDependencyGraph(): DependencyMap { + return new Map(); +} + +export function addDependency( + graph: DependencyMap, + from: NodeId, + to: NodeId, +): DependencyMap { + if (!graph.has(from)) { + graph.set(from, new Set()); + } + graph.get(from)!.add(to); + return graph; +} + +export function removeDependency( + graph: DependencyMap, + from: NodeId, + to: NodeId, +): boolean { + const dependents = graph.get(from); + if (!dependents) { + return false; + } + return dependents.delete(to); +} + +export function hasDependency( + graph: DependencyMap, + from: NodeId, + to: NodeId, +): boolean { + const dependents = graph.get(from); + return dependents ? dependents.has(to) : false; +} diff --git a/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx b/packages/react/src/blocks/Audio/block.tsx similarity index 57% rename from packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx rename to packages/react/src/blocks/Audio/block.tsx index b602aaa971..1ced738e4c 100644 --- a/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx +++ b/packages/react/src/blocks/Audio/block.tsx @@ -1,4 +1,4 @@ -import { FileBlockConfig, audioBlockConfig, audioParse } from "@blocknote/core"; +import { createAudioBlockConfig, audioParse } from "@blocknote/core"; import { RiVolumeUpFill } from "react-icons/ri"; @@ -6,14 +6,18 @@ import { ReactCustomBlockRenderProps, createReactBlockSpec, } from "../../schema/ReactBlockSpec.js"; -import { useResolveUrl } from "../FileBlockContent/useResolveUrl.js"; -import { FigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/FigureWithCaption.js"; -import { FileBlockWrapper } from "../FileBlockContent/helpers/render/FileBlockWrapper.js"; -import { LinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/LinkWithCaption.js"; +import { useResolveUrl } from "../File/useResolveUrl.js"; +import { FigureWithCaption } from "../File/helpers/toExternalHTML/FigureWithCaption.js"; +import { FileBlockWrapper } from "../File/helpers/render/FileBlockWrapper.js"; +import { LinkWithCaption } from "../File/helpers/toExternalHTML/LinkWithCaption.js"; export const AudioPreview = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" >, ) => { @@ -36,7 +40,11 @@ export const AudioPreview = ( export const AudioToExternalHTML = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" >, ) => { @@ -68,7 +76,11 @@ export const AudioToExternalHTML = ( }; export const AudioBlock = ( - props: ReactCustomBlockRenderProps, + props: ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, ) => { return ( ({ render: AudioBlock, - parse: audioParse, + parse: audioParse(config), toExternalHTML: AudioToExternalHTML, -}); + runsBefore: ["file"], +})); diff --git a/packages/react/src/blocks/FileBlockContent/FileBlockContent.tsx b/packages/react/src/blocks/File/block.tsx similarity index 74% rename from packages/react/src/blocks/FileBlockContent/FileBlockContent.tsx rename to packages/react/src/blocks/File/block.tsx index 78bac8230b..7c18d3feb0 100644 --- a/packages/react/src/blocks/FileBlockContent/FileBlockContent.tsx +++ b/packages/react/src/blocks/File/block.tsx @@ -1,4 +1,4 @@ -import { fileBlockConfig, fileParse } from "@blocknote/core"; +import { createFileBlockConfig, fileParse } from "@blocknote/core"; import { createReactBlockSpec, @@ -9,7 +9,7 @@ import { LinkWithCaption } from "./helpers/toExternalHTML/LinkWithCaption.js"; export const FileToExternalHTML = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps, "contentRef" >, ) => { @@ -35,12 +35,12 @@ export const FileToExternalHTML = ( }; export const FileBlock = ( - props: ReactCustomBlockRenderProps, + props: ReactCustomBlockRenderProps, ) => { return ; }; -export const ReactFileBlock = createReactBlockSpec(fileBlockConfig, { +export const ReactFileBlock = createReactBlockSpec(createFileBlockConfig, { render: FileBlock, parse: fileParse, toExternalHTML: FileToExternalHTML, diff --git a/packages/react/src/blocks/FileBlockContent/helpers/render/AddFileButton.tsx b/packages/react/src/blocks/File/helpers/render/AddFileButton.tsx similarity index 82% rename from packages/react/src/blocks/FileBlockContent/helpers/render/AddFileButton.tsx rename to packages/react/src/blocks/File/helpers/render/AddFileButton.tsx index d0d28829a7..f5e8d8e468 100644 --- a/packages/react/src/blocks/FileBlockContent/helpers/render/AddFileButton.tsx +++ b/packages/react/src/blocks/File/helpers/render/AddFileButton.tsx @@ -1,4 +1,4 @@ -import { FileBlockConfig } from "@blocknote/core"; +import { createFileBlockConfig } from "@blocknote/core"; import { ReactNode, useCallback } from "react"; import { RiFile2Line } from "react-icons/ri"; @@ -7,7 +7,11 @@ import { ReactCustomBlockRenderProps } from "../../../../schema/ReactBlockSpec.j export const AddFileButton = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" > & { buttonText?: string; diff --git a/packages/react/src/blocks/FileBlockContent/helpers/render/FileBlockWrapper.tsx b/packages/react/src/blocks/File/helpers/render/FileBlockWrapper.tsx similarity index 86% rename from packages/react/src/blocks/FileBlockContent/helpers/render/FileBlockWrapper.tsx rename to packages/react/src/blocks/File/helpers/render/FileBlockWrapper.tsx index dc47c288b4..d9fac8cfd0 100644 --- a/packages/react/src/blocks/FileBlockContent/helpers/render/FileBlockWrapper.tsx +++ b/packages/react/src/blocks/File/helpers/render/FileBlockWrapper.tsx @@ -1,4 +1,4 @@ -import { FileBlockConfig } from "@blocknote/core"; +import { blockHasType, createFileBlockConfig } from "@blocknote/core"; import { CSSProperties, ReactNode } from "react"; import { useUploadLoading } from "../../../../hooks/useUploadLoading.js"; @@ -8,7 +8,11 @@ import { FileNameWithIcon } from "./FileNameWithIcon.js"; export const FileBlockWrapper = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" > & { buttonText?: string; diff --git a/packages/react/src/blocks/FileBlockContent/helpers/render/FileNameWithIcon.tsx b/packages/react/src/blocks/File/helpers/render/FileNameWithIcon.tsx similarity index 63% rename from packages/react/src/blocks/FileBlockContent/helpers/render/FileNameWithIcon.tsx rename to packages/react/src/blocks/File/helpers/render/FileNameWithIcon.tsx index 0c5ad37d90..962ca02e30 100644 --- a/packages/react/src/blocks/FileBlockContent/helpers/render/FileNameWithIcon.tsx +++ b/packages/react/src/blocks/File/helpers/render/FileNameWithIcon.tsx @@ -1,11 +1,15 @@ -import { FileBlockConfig } from "@blocknote/core"; +import { createFileBlockConfig } from "@blocknote/core"; import { RiFile2Line } from "react-icons/ri"; import { ReactCustomBlockRenderProps } from "../../../../schema/ReactBlockSpec.js"; export const FileNameWithIcon = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "editor" | "contentRef" >, ) => ( diff --git a/packages/react/src/blocks/FileBlockContent/helpers/render/ResizableFileBlockWrapper.tsx b/packages/react/src/blocks/File/helpers/render/ResizableFileBlockWrapper.tsx similarity index 95% rename from packages/react/src/blocks/FileBlockContent/helpers/render/ResizableFileBlockWrapper.tsx rename to packages/react/src/blocks/File/helpers/render/ResizableFileBlockWrapper.tsx index c5631dac5f..ff50864da5 100644 --- a/packages/react/src/blocks/FileBlockContent/helpers/render/ResizableFileBlockWrapper.tsx +++ b/packages/react/src/blocks/File/helpers/render/ResizableFileBlockWrapper.tsx @@ -1,4 +1,4 @@ -import { FileBlockConfig } from "@blocknote/core"; +import { createFileBlockConfig } from "@blocknote/core"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { useUploadLoading } from "../../../../hooks/useUploadLoading.js"; @@ -7,7 +7,11 @@ import { FileBlockWrapper } from "./FileBlockWrapper.js"; export const ResizableFileBlockWrapper = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" > & { buttonText: string; diff --git a/packages/react/src/blocks/FileBlockContent/helpers/toExternalHTML/FigureWithCaption.tsx b/packages/react/src/blocks/File/helpers/toExternalHTML/FigureWithCaption.tsx similarity index 100% rename from packages/react/src/blocks/FileBlockContent/helpers/toExternalHTML/FigureWithCaption.tsx rename to packages/react/src/blocks/File/helpers/toExternalHTML/FigureWithCaption.tsx diff --git a/packages/react/src/blocks/FileBlockContent/helpers/toExternalHTML/LinkWithCaption.tsx b/packages/react/src/blocks/File/helpers/toExternalHTML/LinkWithCaption.tsx similarity index 100% rename from packages/react/src/blocks/FileBlockContent/helpers/toExternalHTML/LinkWithCaption.tsx rename to packages/react/src/blocks/File/helpers/toExternalHTML/LinkWithCaption.tsx diff --git a/packages/react/src/blocks/FileBlockContent/useResolveUrl.tsx b/packages/react/src/blocks/File/useResolveUrl.tsx similarity index 100% rename from packages/react/src/blocks/FileBlockContent/useResolveUrl.tsx rename to packages/react/src/blocks/File/useResolveUrl.tsx diff --git a/packages/react/src/blocks/ImageBlockContent/ImageBlockContent.tsx b/packages/react/src/blocks/Image/block.tsx similarity index 60% rename from packages/react/src/blocks/ImageBlockContent/ImageBlockContent.tsx rename to packages/react/src/blocks/Image/block.tsx index 3e7d6e7a67..b12a3753b6 100644 --- a/packages/react/src/blocks/ImageBlockContent/ImageBlockContent.tsx +++ b/packages/react/src/blocks/Image/block.tsx @@ -1,18 +1,22 @@ -import { FileBlockConfig, imageBlockConfig, imageParse } from "@blocknote/core"; +import { createImageBlockConfig, imageParse } from "@blocknote/core"; import { RiImage2Fill } from "react-icons/ri"; import { createReactBlockSpec, ReactCustomBlockRenderProps, } from "../../schema/ReactBlockSpec.js"; -import { useResolveUrl } from "../FileBlockContent/useResolveUrl.js"; -import { FigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/FigureWithCaption.js"; -import { ResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/ResizableFileBlockWrapper.js"; -import { LinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/LinkWithCaption.js"; +import { useResolveUrl } from "../File/useResolveUrl.js"; +import { FigureWithCaption } from "../File/helpers/toExternalHTML/FigureWithCaption.js"; +import { ResizableFileBlockWrapper } from "../File/helpers/render/ResizableFileBlockWrapper.js"; +import { LinkWithCaption } from "../File/helpers/toExternalHTML/LinkWithCaption.js"; export const ImagePreview = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" >, ) => { @@ -35,7 +39,11 @@ export const ImagePreview = ( export const ImageToExternalHTML = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" >, ) => { @@ -73,7 +81,11 @@ export const ImageToExternalHTML = ( }; export const ImageBlock = ( - props: ReactCustomBlockRenderProps, + props: ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, ) => { return ( ({ render: ImageBlock, - parse: imageParse, + parse: imageParse(config), toExternalHTML: ImageToExternalHTML, -}); +})); diff --git a/packages/react/src/blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.tsx b/packages/react/src/blocks/PageBreak/getPageBreakReactSlashMenuItems.tsx similarity index 100% rename from packages/react/src/blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.tsx rename to packages/react/src/blocks/PageBreak/getPageBreakReactSlashMenuItems.tsx diff --git a/packages/react/src/blocks/VideoBlockContent/VideoBlockContent.tsx b/packages/react/src/blocks/Video/block.tsx similarity index 57% rename from packages/react/src/blocks/VideoBlockContent/VideoBlockContent.tsx rename to packages/react/src/blocks/Video/block.tsx index 8ad81f11e6..395fde6fc5 100644 --- a/packages/react/src/blocks/VideoBlockContent/VideoBlockContent.tsx +++ b/packages/react/src/blocks/Video/block.tsx @@ -1,18 +1,22 @@ -import { FileBlockConfig, videoBlockConfig, videoParse } from "@blocknote/core"; +import { createVideoBlockConfig, videoParse } from "@blocknote/core"; import { RiVideoFill } from "react-icons/ri"; import { createReactBlockSpec, ReactCustomBlockRenderProps, } from "../../schema/ReactBlockSpec.js"; -import { useResolveUrl } from "../FileBlockContent/useResolveUrl.js"; -import { FigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/FigureWithCaption.js"; -import { ResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/ResizableFileBlockWrapper.js"; -import { LinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/LinkWithCaption.js"; +import { useResolveUrl } from "../File/useResolveUrl.js"; +import { FigureWithCaption } from "../File/helpers/toExternalHTML/FigureWithCaption.js"; +import { ResizableFileBlockWrapper } from "../File/helpers/render/ResizableFileBlockWrapper.js"; +import { LinkWithCaption } from "../File/helpers/toExternalHTML/LinkWithCaption.js"; export const VideoPreview = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" >, ) => { @@ -35,7 +39,11 @@ export const VideoPreview = ( export const VideoToExternalHTML = ( props: Omit< - ReactCustomBlockRenderProps, + ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, "contentRef" >, ) => { @@ -67,7 +75,11 @@ export const VideoToExternalHTML = ( }; export const VideoBlock = ( - props: ReactCustomBlockRenderProps, + props: ReactCustomBlockRenderProps< + ReturnType["type"], + ReturnType["propSchema"], + ReturnType["content"] + >, ) => { return ( ({ render: VideoBlock, - parse: videoParse, + parse: videoParse(config), toExternalHTML: VideoToExternalHTML, -}); +})); diff --git a/packages/react/src/components/Comments/schema.ts b/packages/react/src/components/Comments/schema.ts index 576aa2c3ef..b4307fedb7 100644 --- a/packages/react/src/components/Comments/schema.ts +++ b/packages/react/src/components/Comments/schema.ts @@ -1,20 +1,8 @@ -import { - BlockNoteSchema, - createBlockSpecFromStronglyTypedTiptapNode, - createStronglyTypedTiptapNode, - defaultBlockSpecs, - defaultStyleSpecs, -} from "@blocknote/core"; +import { BlockNoteSchema, defaultStyleSpecs } from "@blocknote/core"; +import { createParagraphBlockSpec } from "@blocknote/core"; // this is quite convoluted. we'll clean this up when we make // it easier to extend / customize the default blocks -const paragraph = createBlockSpecFromStronglyTypedTiptapNode( - createStronglyTypedTiptapNode<"paragraph", "inline*">( - defaultBlockSpecs.paragraph.implementation.node.config as any, - ), - // disable default props on paragraph (such as textalignment and colors) - {}, -); // remove textColor, backgroundColor from styleSpecs const { textColor, backgroundColor, ...styleSpecs } = defaultStyleSpecs; @@ -22,7 +10,7 @@ const { textColor, backgroundColor, ...styleSpecs } = defaultStyleSpecs; // the schema to use for comments export const schema = BlockNoteSchema.create({ blockSpecs: { - paragraph, + paragraph: createParagraphBlockSpec(), }, styleSpecs, }); diff --git a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx index e650875720..f1ca112f0a 100644 --- a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx +++ b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx @@ -75,10 +75,9 @@ export const UploadTab = < ); const config = editor.schema.blockSchema[block.type]; - const accept = - config.isFileBlock && config.fileBlockAccept?.length - ? config.fileBlockAccept.join(",") - : "*/*"; + const accept = config.meta?.fileBlockAccept?.length + ? config.meta.fileBlockAccept.join(",") + : "*/*"; return ( diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 204e5bc5dc..38b1fef90c 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -1,7 +1,7 @@ import { + blockHasType, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +41,12 @@ export const FileCaptionButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if ( + blockHasType(block, editor, block.type, { + url: "string", + caption: "string", + }) + ) { setCurrentEditingCaption(block.props.caption); return block; } @@ -51,11 +56,17 @@ export const FileCaptionButton = () => { const handleEnter = useCallback( (event: KeyboardEvent) => { - if (fileBlock && event.key === "Enter") { + if ( + fileBlock && + editorHasBlockWithType(editor, fileBlock.type, { + caption: "string", + }) && + event.key === "Enter" + ) { event.preventDefault(); editor.updateBlock(fileBlock, { props: { - caption: currentEditingCaption as any, // TODO + caption: currentEditingCaption, }, }); } @@ -69,11 +80,7 @@ export const FileCaptionButton = () => { [], ); - if ( - !fileBlock || - checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) || - !editor.isEditable - ) { + if (!fileBlock || fileBlock.props.url === "" || !editor.isEditable) { return null; } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx index 747e5942a0..2ea096de36 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx @@ -1,7 +1,6 @@ import { + blockHasType, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -33,7 +32,7 @@ export const FileDeleteButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if (blockHasType(block, editor, block.type, { url: "string" })) { return block; } @@ -45,11 +44,7 @@ export const FileDeleteButton = () => { editor.removeBlocks([fileBlock!]); }, [editor, fileBlock]); - if ( - !fileBlock || - checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) || - !editor.isEditable - ) { + if (!fileBlock || fileBlock.props.url === "" || !editor.isEditable) { return null; } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx index a80eb5e50e..d19e202564 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx @@ -1,7 +1,6 @@ import { + blockHasType, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -34,7 +33,7 @@ export const FileDownloadButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if (blockHasType(block, editor, block.type, { url: "string" })) { return block; } @@ -57,7 +56,7 @@ export const FileDownloadButton = () => { } }, [editor, fileBlock]); - if (!fileBlock || checkBlockIsFileBlockWithPlaceholder(fileBlock, editor)) { + if (!fileBlock || fileBlock.props.url === "") { return null; } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx index c52159c3d7..9e8fcb0425 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx @@ -1,7 +1,7 @@ import { + blockHasType, BlockSchema, - checkBlockIsFileBlockWithPlaceholder, - checkBlockIsFileBlockWithPreview, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -33,7 +33,12 @@ export const FilePreviewButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlockWithPreview(block, editor)) { + if ( + blockHasType(block, editor, block.type, { + url: "string", + showPreview: "boolean", + }) + ) { return block; } @@ -41,20 +46,21 @@ export const FilePreviewButton = () => { }, [editor, selectedBlocks]); const onClick = useCallback(() => { - if (fileBlock) { + if ( + fileBlock && + editorHasBlockWithType(editor, fileBlock.type, { + showPreview: "boolean", + }) + ) { editor.updateBlock(fileBlock, { props: { - showPreview: !fileBlock.props.showPreview as any, // TODO + showPreview: !fileBlock.props.showPreview, }, }); } }, [editor, fileBlock]); - if ( - !fileBlock || - checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) || - !editor.isEditable - ) { + if (!fileBlock || fileBlock.props.url === "" || !editor.isEditable) { return null; } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index 595b9c5271..583494917f 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -1,7 +1,7 @@ import { + blockHasType, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +41,12 @@ export const FileRenameButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if ( + blockHasType(block, editor, block.type, { + url: "string", + name: "string", + }) + ) { setCurrentEditingName(block.props.name); return block; } @@ -51,11 +56,17 @@ export const FileRenameButton = () => { const handleEnter = useCallback( (event: KeyboardEvent) => { - if (fileBlock && event.key === "Enter") { + if ( + fileBlock && + editorHasBlockWithType(editor, fileBlock.type, { + name: "string", + }) && + event.key === "Enter" + ) { event.preventDefault(); editor.updateBlock(fileBlock, { props: { - name: currentEditingName as any, // TODO + name: currentEditingName, }, }); } @@ -69,11 +80,7 @@ export const FileRenameButton = () => { [], ); - if ( - !fileBlock || - checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) || - !editor.isEditable - ) { + if (!fileBlock || fileBlock.props.name === "" || !editor.isEditable) { return null; } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index 7770cd4fd3..ed68593186 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -1,6 +1,6 @@ import { + blockHasType, BlockSchema, - checkBlockIsFileBlock, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -35,7 +35,9 @@ export const FileReplaceButton = () => { if ( block === undefined || - !checkBlockIsFileBlock(block, editor) || + !blockHasType(block, editor, block.type, { + url: "string", + }) || !editor.isEditable ) { return null; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx index 934fc3a32a..05b38f6a7a 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx @@ -1,8 +1,9 @@ import { + blockHasType, BlockSchema, - checkBlockHasDefaultProp, - checkBlockTypeHasDefaultProp, + defaultProps, DefaultProps, + editorHasBlockWithType, InlineContentSchema, mapTableCell, StyleSchema, @@ -46,7 +47,11 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => { const textAlignment = useMemo(() => { const block = selectedBlocks[0]; - if (checkBlockHasDefaultProp("textAlignment", block, editor)) { + if ( + blockHasType(block, editor, block.type, { + textAlignment: defaultProps.textAlignment, + }) + ) { return block.props.textAlignment; } if (block.type === "table") { @@ -75,7 +80,14 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => { editor.focus(); for (const block of selectedBlocks) { - if (checkBlockTypeHasDefaultProp("textAlignment", block.type, editor)) { + if ( + blockHasType(block, editor, block.type, { + textAlignment: defaultProps.textAlignment, + }) && + editorHasBlockWithType(editor, block.type, { + textAlignment: defaultProps.textAlignment, + }) + ) { editor.updateBlock(block, { props: { textAlignment: textAlignment }, }); @@ -122,10 +134,12 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => { const show = useMemo(() => { return !!selectedBlocks.find( (block) => - "textAlignment" in block.props || + blockHasType(block, editor, block.type, { + textAlignment: defaultProps.textAlignment, + }) || (block.type === "table" && block.children), ); - }, [selectedBlocks]); + }, [editor, selectedBlocks]); if (!show || !editor.isEditable) { return null; diff --git a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx index 5e27e1eb21..c8733458d6 100644 --- a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx @@ -1,10 +1,11 @@ import { + Block, + blockHasType, BlockSchema, - checkBlockHasDefaultProp, - checkBlockTypeHasDefaultProp, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -28,9 +29,18 @@ export const BlockColorsItem = < const editor = useBlockNoteEditor(); + // We cast the block to a generic one, as the base type causes type errors + // with runtime type checking using `blockHasType`. Runtime type checking is + // more valuable than static checks, so better to do it like this. + const block = props.block as Block; + if ( - !checkBlockTypeHasDefaultProp("textColor", props.block.type, editor) && - !checkBlockTypeHasDefaultProp("backgroundColor", props.block.type, editor) + !blockHasType(block, editor, block.type, { + textColor: "string", + }) || + !blockHasType(block, editor, block.type, { + backgroundColor: "string", + }) ) { return null; } @@ -53,32 +63,33 @@ export const BlockColorsItem = < - editor.updateBlock(props.block, { - type: props.block.type, + editor.updateBlock(block, { + type: block.type, props: { textColor: color }, }), } : undefined } background={ - checkBlockTypeHasDefaultProp( - "backgroundColor", - props.block.type, - editor, - ) && - checkBlockHasDefaultProp("backgroundColor", props.block, editor) + blockHasType(block, editor, block.type, { + backgroundColor: "string", + }) && + editorHasBlockWithType(editor, block.type, { + backgroundColor: "string", + }) ? { - color: props.block.props.backgroundColor, + color: block.props.backgroundColor, setColor: (color) => - editor.updateBlock(props.block, { + editor.updateBlock(block, { props: { backgroundColor: color }, }), } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index fc50be2bb4..f1058fb223 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,18 +5,18 @@ export * from "./editor/BlockNoteView.js"; export * from "./editor/ComponentsContext.js"; export * from "./i18n/dictionary.js"; -export * from "./blocks/AudioBlockContent/AudioBlockContent.js"; -export * from "./blocks/FileBlockContent/FileBlockContent.js"; -export * from "./blocks/FileBlockContent/helpers/render/AddFileButton.js"; -export * from "./blocks/FileBlockContent/helpers/render/FileBlockWrapper.js"; -export * from "./blocks/FileBlockContent/helpers/render/FileNameWithIcon.js"; -export * from "./blocks/FileBlockContent/helpers/render/ResizableFileBlockWrapper.js"; -export * from "./blocks/FileBlockContent/helpers/toExternalHTML/FigureWithCaption.js"; -export * from "./blocks/FileBlockContent/helpers/toExternalHTML/LinkWithCaption.js"; -export * from "./blocks/FileBlockContent/useResolveUrl.js"; -export * from "./blocks/ImageBlockContent/ImageBlockContent.js"; -export * from "./blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.js"; -export * from "./blocks/VideoBlockContent/VideoBlockContent.js"; +export * from "./blocks/Audio/block.js"; +export * from "./blocks/File/block.js"; +export * from "./blocks/File/helpers/render/AddFileButton.js"; +export * from "./blocks/File/helpers/render/FileBlockWrapper.js"; +export * from "./blocks/File/helpers/render/FileNameWithIcon.js"; +export * from "./blocks/File/helpers/render/ResizableFileBlockWrapper.js"; +export * from "./blocks/File/helpers/toExternalHTML/FigureWithCaption.js"; +export * from "./blocks/File/helpers/toExternalHTML/LinkWithCaption.js"; +export * from "./blocks/File/useResolveUrl.js"; +export * from "./blocks/Image/block.js"; +export * from "./blocks/PageBreak/getPageBreakReactSlashMenuItems.js"; +export * from "./blocks/Video/block.js"; export * from "./blocks/ToggleWrapper/ToggleWrapper.js"; export * from "./components/FormattingToolbar/DefaultButtons/AddCommentButton.js"; diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 3f4f56c706..eec0ae8e9e 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -1,12 +1,18 @@ import { applyNonSelectableBlockFix, + BlockConfig, BlockFromConfig, + BlockImplementation, + BlockNoDefaults, BlockNoteEditor, + BlockNoteExtension, BlockSchemaWithBlock, + BlockSpec, camelToDataKebab, - createInternalBlockSpec, + createTypedBlockSpec, createStronglyTypedTiptapNode, CustomBlockConfig, + CustomBlockImplementation, getBlockFromPos, getParseRules, inheritedProps, @@ -32,26 +38,44 @@ import { renderToDOMSpec } from "./@util/ReactRenderUtil.js"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks export type ReactCustomBlockRenderProps< - T extends CustomBlockConfig, - I extends InlineContentSchema, - S extends StyleSchema, + TName extends string = string, + TProps extends PropSchema = PropSchema, + TContent extends "inline" | "none" = "inline" | "none", > = { - block: BlockFromConfig; - editor: BlockNoteEditor, I, S>; + block: BlockNoDefaults< + Record>, + any, + any + >; + editor: BlockNoteEditor< + Record>, + any, + any + >; contentRef: (node: HTMLElement | null) => void; }; // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< - T extends CustomBlockConfig, - I extends InlineContentSchema, - S extends StyleSchema, + TName extends string = string, + TProps extends PropSchema = PropSchema, + TContent extends "inline" | "none" = "inline" | "none", +> = Omit< + CustomBlockImplementation, + "render" | "toExternalHTML" +> & { + render: FC>; + toExternalHTML?: FC>; +}; + +export type ReactCustomBlockSpec< + T extends string = string, + PS extends PropSchema = PropSchema, + C extends "inline" | "none" = "inline" | "none", > = { - render: FC>; - toExternalHTML?: FC>; - parse?: ( - el: HTMLElement, - ) => PartialBlockFromConfig["props"] | undefined; + config: BlockConfig; + implementation: ReactCustomBlockImplementation; + extensions?: BlockNoteExtension[]; }; // Function that wraps the React component returned from 'blockConfig.render' in @@ -122,14 +146,18 @@ export function createReactBlockSpec< ? "inline*" : "") as T["content"] extends "inline" ? "inline*" : "", group: "blockContent", - selectable: blockConfig.isSelectable ?? true, + selectable: blockConfig.meta?.selectable ?? true, isolating: true, addAttributes() { return propsToAttributes(blockConfig.propSchema); }, parseHTML() { - return getParseRules(blockConfig, blockImplementation.parse); + return getParseRules( + blockConfig, + blockImplementation.parse, + blockImplementation.parseContent, + ); }, renderHTML({ HTMLAttributes }) { @@ -147,7 +175,7 @@ export function createReactBlockSpec< blockConfig.type, {}, blockConfig.propSchema, - blockConfig.isFileBlock, + !!blockConfig.meta?.fileBlockAccept, HTMLAttributes, ); }, @@ -181,7 +209,7 @@ export function createReactBlockSpec< blockType={block.type} blockProps={block.props} propSchema={blockConfig.propSchema} - isFileBlock={blockConfig.isFileBlock} + isFileBlock={!!blockConfig.meta?.fileBlockAccept} domAttributes={blockContentDOMAttributes} > { + render: (block, editor) => { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; @@ -285,3 +313,28 @@ export function createReactBlockSpec< }, }); } + +export function createReactBlockSpec< + TCallback extends (options?: any) => CustomBlockConfig, + TOptions extends Parameters[0], + TName extends ReturnType["type"], + TProps extends ReturnType["propSchema"], + TContent extends ReturnType["content"], +>( + callback: TCallback, +): { + implementation: ( + cb: ( + options?: TOptions, + ) => ReactCustomBlockImplementation, + addExtensions?: (options?: TOptions) => BlockNoteExtension[], + ) => (options?: TOptions) => ReactCustomBlockSpec; +} { + return { + implementation: (cb, addExtensions) => (options) => ({ + config: callback(options) as any, + implementation: cb(options), + extensions: addExtensions?.(options), + }), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47c24460a4..bfdafb8827 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -393,6 +393,9 @@ importers: '@blocknote/ariakit': specifier: latest version: link:../../../packages/ariakit + '@blocknote/code-block': + specifier: latest + version: link:../../../packages/code-block '@blocknote/core': specifier: latest version: link:../../../packages/core diff --git a/shared/formatConversionTestUtil.ts b/shared/formatConversionTestUtil.ts index 5a9c95f090..565f0f1cfb 100644 --- a/shared/formatConversionTestUtil.ts +++ b/shared/formatConversionTestUtil.ts @@ -115,7 +115,7 @@ export function partialBlocksToBlocksForTesting< S extends StyleSchema, >( schema: BlockNoteSchema, - partialBlocks: Array, NoInfer, NoInfer>>, + partialBlocks: Array>, ): Array> { return partialBlocks.map((partialBlock) => partialBlockToBlockForTesting(schema.blockSchema, partialBlock), diff --git a/tests/src/unit/core/testSchema.ts b/tests/src/unit/core/testSchema.ts index b47a3276f0..897829f271 100644 --- a/tests/src/unit/core/testSchema.ts +++ b/tests/src/unit/core/testSchema.ts @@ -1,6 +1,6 @@ import { BlockNoteSchema, - createBlockSpec, + addNodeAndExtensionsToSpec, createInlineContentSpec, createStyleSpec, defaultBlockSpecs, @@ -17,7 +17,7 @@ import { // This is a modified version of the default image block that does not implement // a `toExternalHTML` function. It's used to test if the custom serializer by // default serializes custom blocks using their `render` function. -const SimpleImage = createBlockSpec( +const SimpleImage = addNodeAndExtensionsToSpec( { type: "simpleImage", propSchema: imagePropSchema, @@ -28,7 +28,7 @@ const SimpleImage = createBlockSpec( }, ); -const CustomParagraph = createBlockSpec( +const CustomParagraph = addNodeAndExtensionsToSpec( { type: "customParagraph", propSchema: defaultProps, @@ -56,7 +56,7 @@ const CustomParagraph = createBlockSpec( }, ); -const SimpleCustomParagraph = createBlockSpec( +const SimpleCustomParagraph = addNodeAndExtensionsToSpec( { type: "simpleCustomParagraph", propSchema: defaultProps, diff --git a/tests/src/utils/customblocks/Alert.tsx b/tests/src/utils/customblocks/Alert.tsx index 8fb9740a11..103c9d3e25 100644 --- a/tests/src/utils/customblocks/Alert.tsx +++ b/tests/src/utils/customblocks/Alert.tsx @@ -3,7 +3,7 @@ import { BlockNoteEditor, BlockSchemaWithBlock, PartialBlock, - createBlockSpec, + addNodeAndExtensionsToSpec, defaultProps, } from "@blocknote/core"; @@ -27,7 +27,7 @@ const values = { }, } as const; -export const Alert = createBlockSpec( +export const Alert = addNodeAndExtensionsToSpec( { type: "alert" as const, propSchema: { diff --git a/tests/src/utils/customblocks/Button.tsx b/tests/src/utils/customblocks/Button.tsx index 82f465f06a..07672cbb8c 100644 --- a/tests/src/utils/customblocks/Button.tsx +++ b/tests/src/utils/customblocks/Button.tsx @@ -1,11 +1,11 @@ import { BlockNoteEditor, - createBlockSpec, + addNodeAndExtensionsToSpec, defaultProps, } from "@blocknote/core"; import { RiRadioButtonFill } from "react-icons/ri"; -export const Button = createBlockSpec( +export const Button = addNodeAndExtensionsToSpec( { type: "button" as const, propSchema: { diff --git a/tests/src/utils/customblocks/Embed.tsx b/tests/src/utils/customblocks/Embed.tsx index c6e9b1bc9c..2d55e28c54 100644 --- a/tests/src/utils/customblocks/Embed.tsx +++ b/tests/src/utils/customblocks/Embed.tsx @@ -1,8 +1,8 @@ -import { BlockNoteEditor, createBlockSpec } from "@blocknote/core"; +import { BlockNoteEditor, addNodeAndExtensionsToSpec } from "@blocknote/core"; import { RiLayout5Fill } from "react-icons/ri"; -export const Embed = createBlockSpec( +export const Embed = addNodeAndExtensionsToSpec( { type: "embed" as const, propSchema: { diff --git a/tests/src/utils/customblocks/Image.tsx b/tests/src/utils/customblocks/Image.tsx index 2a5899d4ef..e2597d1b84 100644 --- a/tests/src/utils/customblocks/Image.tsx +++ b/tests/src/utils/customblocks/Image.tsx @@ -1,10 +1,10 @@ import { BlockNoteEditor, - createBlockSpec, + addNodeAndExtensionsToSpec, defaultProps, } from "@blocknote/core"; import { RiImage2Fill } from "react-icons/ri"; -export const Image = createBlockSpec( +export const Image = addNodeAndExtensionsToSpec( { type: "image" as const, propSchema: { diff --git a/tests/src/utils/customblocks/Separator.tsx b/tests/src/utils/customblocks/Separator.tsx index a2a1083e64..07f7664d73 100644 --- a/tests/src/utils/customblocks/Separator.tsx +++ b/tests/src/utils/customblocks/Separator.tsx @@ -1,8 +1,8 @@ -import { BlockNoteEditor, createBlockSpec } from "@blocknote/core"; +import { BlockNoteEditor, addNodeAndExtensionsToSpec } from "@blocknote/core"; import { RiSeparator } from "react-icons/ri"; -export const Separator = createBlockSpec( +export const Separator = addNodeAndExtensionsToSpec( { type: "separator" as const, propSchema: {} as const,