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/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index e5f4fec1bd..3a6aeaffd5 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -94,7 +94,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/blks/Audio/definition.ts b/packages/core/src/blks/Audio/definition.ts deleted file mode 100644 index c25b30b090..0000000000 --- a/packages/core/src/blks/Audio/definition.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { parseAudioElement } from "../../blocks/AudioBlockContent/parseAudioElement.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; -import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js"; -import { createFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createFileBlockWrapper.js"; -import { createFigureWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; -import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { - createBlockConfig, - createBlockDefinition, -} from "../../schema/index.js"; - -export const FILE_AUDIO_ICON_SVG = - ''; - -export interface AudioOptions { - icon?: string; -} -const config = 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 definition = createBlockDefinition(config).implementation( - (config = {}) => ({ - parse: (element) => { - 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; - }, - render: (block, editor) => { - 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, - ); - }, - toExternalHTML(block) { - 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, - }; - }, - runsBefore: ["file"], - }), -); diff --git a/packages/core/src/blks/File/definition.ts b/packages/core/src/blks/File/definition.ts deleted file mode 100644 index 35f6d1f46a..0000000000 --- a/packages/core/src/blks/File/definition.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { defaultProps } from "../../blocks/defaultProps.js"; -import { parseEmbedElement } from "../../blocks/FileBlockContent/helpers/parse/parseEmbedElement.js"; -import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js"; -import { createFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createFileBlockWrapper.js"; -import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { - createBlockConfig, - createBlockDefinition, -} from "../../schema/index.js"; - -const config = 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 definition = createBlockDefinition(config).implementation(() => ({ - parse: (element) => { - 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; - }, - render: (block, editor) => { - return createFileBlockWrapper(block, editor); - }, - toExternalHTML(block) { - 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, - }; - }, -})); diff --git a/packages/core/src/blks/Image/definition.ts b/packages/core/src/blks/Image/definition.ts deleted file mode 100644 index aa918dce3f..0000000000 --- a/packages/core/src/blks/Image/definition.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { defaultProps } from "../../blocks/defaultProps.js"; -import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js"; -import { createResizableFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; -import { createFigureWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; -import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { parseImageElement } from "../../blocks/ImageBlockContent/parseImageElement.js"; -import { - createBlockConfig, - createBlockDefinition, -} from "../../schema/index.js"; - -export const FILE_IMAGE_ICON_SVG = - ''; - -export interface ImageOptions { - icon?: string; -} -const config = 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 definition = createBlockDefinition(config).implementation( - (config = {}) => ({ - parse: (element) => { - 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; - }, - render: (block, editor) => { - 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, - ); - }, - toExternalHTML(block) { - 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, - }; - }, - runsBefore: ["file"], - }), -); diff --git a/packages/core/src/blks/Video/definition.ts b/packages/core/src/blks/Video/definition.ts deleted file mode 100644 index 7f4a101a60..0000000000 --- a/packages/core/src/blks/Video/definition.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { defaultProps } from "../../blocks/defaultProps.js"; -import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js"; -import { createResizableFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.js"; -import { createFigureWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js"; -import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; -import { parseVideoElement } from "../../blocks/VideoBlockContent/parseVideoElement.js"; -import { - createBlockConfig, - createBlockDefinition, -} from "../../schema/index.js"; - -export const FILE_VIDEO_ICON_SVG = - ''; - -export interface VideoOptions { - icon?: string; -} -const config = 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 definition = createBlockDefinition(config).implementation( - (config = {}) => ({ - parse: (element) => { - 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; - }, - render: (block, editor) => { - 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, - ); - }, - toExternalHTML(block) { - 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, - }; - }, - runsBefore: ["file"], - }), -); diff --git a/packages/core/src/blks/index.ts b/packages/core/src/blks/index.ts deleted file mode 100644 index 97473296ef..0000000000 --- a/packages/core/src/blks/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * as audio from "./Audio/definition.js"; -export * as bulletListItem from "./BulletListItem/definition.js"; -export * as checkListItem from "./CheckListItem/definition.js"; -export * as codeBlock from "./Code/definition.js"; -export * as heading from "./Heading/definition.js"; -export * as numberedListItem from "./NumberedListItem/definition.js"; -export * as pageBreak from "./PageBreak/definition.js"; -export * as paragraph from "./Paragraph/definition.js"; -export * as quote from "./Quote/definition.js"; -export * as toggleListItem from "./ToggleListItem/definition.js"; - -export * as file from "./File/definition.js"; -export * as image from "./Image/definition.js"; -export * as video from "./Video/definition.js"; 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 95fdb093a4..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/*"], -} as any; - -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/blks/Code/definition.ts b/packages/core/src/blocks/Code/block.ts similarity index 97% rename from packages/core/src/blks/Code/definition.ts rename to packages/core/src/blocks/Code/block.ts index 4e1234b2d8..3adef6a51d 100644 --- a/packages/core/src/blks/Code/definition.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -1,7 +1,7 @@ import type { HighlighterGeneric } from "@shikijs/types"; import { createBlockConfig, - createBlockDefinition, + createBlockSpec, createBlockNoteExtension, } from "../../schema/index.js"; import { lazyShikiPlugin } from "./shiki.js"; @@ -53,7 +53,7 @@ export type CodeBlockOptions = { createHighlighter?: () => Promise>; }; -const config = createBlockConfig( +export const createCodeBlockConfig = createBlockConfig( ({ defaultLanguage = "text" }: CodeBlockOptions = {}) => ({ type: "codeBlock" as const, @@ -70,7 +70,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createCodeBlockSpec = createBlockSpec( + createCodeBlockConfig, +).implementation( (options = {}) => ({ parse: (e) => { const pre = e.querySelector("pre"); diff --git a/packages/core/src/blks/Code/shiki.ts b/packages/core/src/blocks/Code/shiki.ts similarity index 97% rename from packages/core/src/blks/Code/shiki.ts rename to packages/core/src/blocks/Code/shiki.ts index 8849e49438..bb101abdcc 100644 --- a/packages/core/src/blks/Code/shiki.ts +++ b/packages/core/src/blocks/Code/shiki.ts @@ -1,7 +1,7 @@ import type { HighlighterGeneric } from "@shikijs/types"; import { Parser, createHighlightPlugin } from "prosemirror-highlight"; import { createParser } from "prosemirror-highlight/shiki"; -import { CodeBlockOptions, getLanguageId } from "./definition.js"; +import { CodeBlockOptions, getLanguageId } from "./block.js"; export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); export const shikiHighlighterPromiseSymbol = Symbol.for( 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 100% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts rename to packages/core/src/blocks/File/helpers/render/createAddFileButton.ts diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts b/packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts similarity index 100% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts rename to packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts b/packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts similarity index 100% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts rename to packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts b/packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts similarity index 100% rename from packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts rename to packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts 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 ca21c1440b..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, -} as any; - -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/blks/Heading/definition.ts b/packages/core/src/blocks/Heading/block.ts similarity index 91% rename from packages/core/src/blks/Heading/definition.ts rename to packages/core/src/blocks/Heading/block.ts index e0cc5a1602..e20cd7908f 100644 --- a/packages/core/src/blks/Heading/definition.ts +++ b/packages/core/src/blocks/Heading/block.ts @@ -1,12 +1,12 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; -import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrapper.js"; import { createBlockConfig, createBlockNoteExtension, - createBlockDefinition, + 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; @@ -17,7 +17,7 @@ export interface HeadingOptions { allowToggleHeadings?: boolean; } -const config = createBlockConfig( +export const createHeadingBlockConfig = createBlockConfig( ({ defaultLevel = 1, levels = HEADING_LEVELS, @@ -36,7 +36,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createHeadingBlockSpec = createBlockSpec( + createHeadingBlockConfig, +).implementation( ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({ parse(e) { let level: number; 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 a4113ac241..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/*"], -} as any; - -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/blks/BulletListItem/definition.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts similarity index 74% rename from packages/core/src/blks/BulletListItem/definition.ts rename to packages/core/src/blocks/ListItem/BulletListItem/block.ts index ba6e378f93..9f2658b13e 100644 --- a/packages/core/src/blks/BulletListItem/definition.ts +++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts @@ -1,15 +1,15 @@ -import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; -import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js"; +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; import { createBlockConfig, - createBlockDefinition, + createBlockSpec, createBlockNoteExtension, -} from "../../schema/index.js"; -import { handleEnter } from "../utils/listItemEnterHandler.js"; +} from "../../../schema/index.js"; +import { defaultProps } from "../../defaultProps.js"; +import { handleEnter } from "../../utils/listItemEnterHandler.js"; +import { getListItemContent } from "../getListItemContent.js"; -const config = createBlockConfig( +export const createBulletListItemBlockConfig = createBlockConfig( () => ({ type: "bulletListItem" as const, @@ -20,7 +20,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createBulletListItemBlockSpec = createBlockSpec( + createBulletListItemBlockConfig, +).implementation( () => ({ parse(element) { if (element.tagName !== "LI") { @@ -47,17 +49,14 @@ export const definition = createBlockDefinition(config).implementation( parseContent: ({ el, schema }) => getListItemContent(el, schema, "bulletListItem"), render() { - const div = document.createElement("div"); // 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 el = document.createElement("p"); - - div.appendChild(el); + const dom = document.createElement("p"); return { - dom: div, - contentDOM: el, + dom, + contentDOM: dom, }; }, }), diff --git a/packages/core/src/blks/CheckListItem/definition.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts similarity index 80% rename from packages/core/src/blks/CheckListItem/definition.ts rename to packages/core/src/blocks/ListItem/CheckListItem/block.ts index b88b167611..3528eb7a1d 100644 --- a/packages/core/src/blks/CheckListItem/definition.ts +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts @@ -1,15 +1,15 @@ -import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; -import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js"; +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; import { createBlockConfig, - createBlockDefinition, + createBlockSpec, createBlockNoteExtension, -} from "../../schema/index.js"; -import { handleEnter } from "../utils/listItemEnterHandler.js"; +} from "../../../schema/index.js"; +import { defaultProps } from "../../defaultProps.js"; +import { handleEnter } from "../../utils/listItemEnterHandler.js"; +import { getListItemContent } from "../getListItemContent.js"; -const config = createBlockConfig( +export const createCheckListItemConfig = createBlockConfig( () => ({ type: "checkListItem" as const, @@ -21,7 +21,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createCheckListItemBlockSpec = createBlockSpec( + createCheckListItemConfig, +).implementation( () => ({ parse(element) { if (element.tagName === "input") { @@ -67,7 +69,7 @@ export const definition = createBlockDefinition(config).implementation( parseContent: ({ el, schema }) => getListItemContent(el, schema, "checkListItem"), render(block) { - const div = document.createElement("div"); + const dom = document.createDocumentFragment(); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = block.props.checked; @@ -77,14 +79,14 @@ export const definition = createBlockDefinition(config).implementation( // 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 paragraphEl = document.createElement("p"); + const paragraph = document.createElement("p"); - div.appendChild(checkbox); - div.appendChild(paragraphEl); + dom.appendChild(checkbox); + dom.appendChild(paragraph); return { - dom: div, - contentDOM: paragraphEl, + dom, + contentDOM: paragraph, }; }, runsBefore: ["bulletListItem"], 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/blks/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts similarity index 98% rename from packages/core/src/blks/NumberedListItem/IndexingPlugin.ts rename to packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index 370cddbefd..64a2e8620c 100644 --- a/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -3,7 +3,7 @@ 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"; +import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js"; // Loosely based on https://github.com/ueberdosis/tiptap/blob/7ac01ef0b816a535e903b5ca92492bff110a71ae/packages/extension-mathematics/src/MathematicsPlugin.ts (MIT) diff --git a/packages/core/src/blks/NumberedListItem/definition.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts similarity index 76% rename from packages/core/src/blks/NumberedListItem/definition.ts rename to packages/core/src/blocks/ListItem/NumberedListItem/block.ts index 088f57bffd..5032e261b4 100644 --- a/packages/core/src/blks/NumberedListItem/definition.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -1,16 +1,16 @@ -import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; -import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js"; +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; import { createBlockConfig, + createBlockSpec, createBlockNoteExtension, - createBlockDefinition, -} from "../../schema/index.js"; -import { handleEnter } from "../utils/listItemEnterHandler.js"; +} 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"; -const config = createBlockConfig( +export const createNumberedListItemBlockConfig = createBlockConfig( () => ({ type: "numberedListItem" as const, @@ -22,7 +22,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createNumberedListItemBlockSpec = createBlockSpec( + createNumberedListItemBlockConfig, +).implementation( () => ({ parse(element) { if (element.tagName !== "LI") { @@ -49,17 +51,14 @@ export const definition = createBlockDefinition(config).implementation( parseContent: ({ el, schema }) => getListItemContent(el, schema, "numberedListItem"), render() { - const div = document.createElement("div"); // 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 el = document.createElement("p"); - - div.appendChild(el); + const dom = document.createElement("p"); return { - dom: div, - contentDOM: el, + dom, + contentDOM: dom, }; }, }), diff --git a/packages/core/src/blks/ToggleListItem/definition.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts similarity index 66% rename from packages/core/src/blks/ToggleListItem/definition.ts rename to packages/core/src/blocks/ListItem/ToggleListItem/block.ts index 59f304007c..b2f2d31b04 100644 --- a/packages/core/src/blks/ToggleListItem/definition.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -1,15 +1,15 @@ -import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; -import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrapper.js"; +import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js"; import { createBlockConfig, + createBlockSpec, createBlockNoteExtension, - createBlockDefinition, -} from "../../schema/index.js"; -import { handleEnter } from "../utils/listItemEnterHandler.js"; +} from "../../../schema/index.js"; +import { defaultProps } from "../../defaultProps.js"; +import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js"; +import { handleEnter } from "../../utils/listItemEnterHandler.js"; -const config = createBlockConfig( +export const createToggleListItemBlockConfig = createBlockConfig( () => ({ type: "toggleListItem" as const, @@ -20,7 +20,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createToggleListItemBlockSpec = createBlockSpec( + createToggleListItemBlockConfig, +).implementation( () => ({ render(block, editor) { const paragraphEl = document.createElement("p"); 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 1195cdead6..0000000000 --- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ /dev/null @@ -1,174 +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: { - // TODO this is going to be a problem... - // How do we represent this? As decorations! - 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/blks/PageBreak/definition.ts b/packages/core/src/blocks/PageBreak/block.ts similarity index 72% rename from packages/core/src/blks/PageBreak/definition.ts rename to packages/core/src/blocks/PageBreak/block.ts index 90a2ad9c43..3bc0365c04 100644 --- a/packages/core/src/blks/PageBreak/definition.ts +++ b/packages/core/src/blocks/PageBreak/block.ts @@ -1,9 +1,6 @@ -import { - createBlockConfig, - createBlockDefinition, -} from "../../schema/index.js"; +import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; -const config = createBlockConfig( +export const createPageBreakBlockConfig = createBlockConfig( () => ({ type: "pageBreak" as const, @@ -12,7 +9,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation(() => ({ +export const createPageBreakBlockSpec = createBlockSpec( + createPageBreakBlockConfig, +).implementation(() => ({ parse(element) { if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) { return {}; 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 4b111ae3fb..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 as any, + 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 6580e466f2..0000000000 --- a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - createBlockSpec, - CustomBlockConfig, - Props, -} from "../../schema/index.js"; - -export const pageBreakConfig = { - type: "pageBreak" as const, - propSchema: {}, - content: "none", -} 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/blks/Paragraph/definition.ts b/packages/core/src/blocks/Paragraph/block.ts similarity index 85% rename from packages/core/src/blks/Paragraph/definition.ts rename to packages/core/src/blocks/Paragraph/block.ts index 6f103840b6..41a5be97fb 100644 --- a/packages/core/src/blks/Paragraph/definition.ts +++ b/packages/core/src/blocks/Paragraph/block.ts @@ -1,13 +1,13 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; import { createBlockConfig, createBlockNoteExtension, - createBlockDefinition, + createBlockSpec, } from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; -const config = createBlockConfig( +export const createParagraphBlockConfig = createBlockConfig( () => ({ type: "paragraph" as const, @@ -16,7 +16,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createParagraphBlockSpec = createBlockSpec( + createParagraphBlockConfig, +).implementation( () => ({ parse: (e) => { const paragraph = e.querySelector("p"); 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/blks/Quote/definition.ts b/packages/core/src/blocks/Quote/block.ts similarity index 87% rename from packages/core/src/blks/Quote/definition.ts rename to packages/core/src/blocks/Quote/block.ts index 267d37d391..c813938622 100644 --- a/packages/core/src/blks/Quote/definition.ts +++ b/packages/core/src/blocks/Quote/block.ts @@ -1,13 +1,13 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; -import { defaultProps } from "../../blocks/defaultProps.js"; import { createBlockConfig, createBlockNoteExtension, - createBlockDefinition, + createBlockSpec, } from "../../schema/index.js"; +import { defaultProps } from "../defaultProps.js"; -const config = createBlockConfig( +export const createQuoteBlockConfig = createBlockConfig( () => ({ type: "quote" as const, @@ -16,7 +16,9 @@ const config = createBlockConfig( }) as const, ); -export const definition = createBlockDefinition(config).implementation( +export const createQuoteBlockSpec = createBlockSpec( + createQuoteBlockConfig, +).implementation( () => ({ parse(element) { if (element.querySelector("blockquote")) { 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 9387ab85d7..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/*"], -} as any; - -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/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 9734548368..90732880b7 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -4,25 +4,24 @@ import Italic from "@tiptap/extension-italic"; import Strike from "@tiptap/extension-strike"; import Underline from "@tiptap/extension-underline"; import { - audio, - bulletListItem, - checkListItem, - codeBlock, - file, - heading, - image, - numberedListItem, - pageBreak, - paragraph, - quote, - toggleListItem, - video, -} from "../blks/index.js"; + 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, - BlockDefinition, BlockNoDefaults, BlockSchema, InlineContentSchema, @@ -34,34 +33,23 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; -import { Table } from "./TableBlockContent/TableBlockContent.js"; +import { createTableBlockSpec } from "./Table/block.js"; export const defaultBlockSpecs = { - paragraph: paragraph.definition(), - audio: audio.definition(), - bulletListItem: bulletListItem.definition(), - checkListItem: checkListItem.definition(), - codeBlock: codeBlock.definition(), - heading: heading.definition(), - numberedListItem: numberedListItem.definition(), - pageBreak: pageBreak.definition(), - quote: quote.definition(), - toggleListItem: toggleListItem.definition(), - file: file.definition(), - image: image.definition(), - video: video.definition(), - table: Table as unknown as BlockDefinition< - "table", - { - textColor: { - default: "default"; - }; - } - > & { - config: { - content: "table"; - }; - }, + 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, 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/blks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts similarity index 100% rename from packages/core/src/blks/utils/listItemEnterHandler.ts rename to packages/core/src/blocks/utils/listItemEnterHandler.ts diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 6890f6acf8..12d738abfe 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -116,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"; @@ -276,11 +276,7 @@ export type BlockNoteEditorOptions< * * @remarks `PartialBlock[]` */ - initialContent?: PartialBlock< - NoInfer, - NoInfer, - NoInfer - >[]; + initialContent?: PartialBlock[]; /** * @deprecated, provide placeholders via dictionary instead 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 5dfac1a79b..a92a79e508 100644 --- a/packages/core/src/editor/BlockNoteSchema.ts +++ b/packages/core/src/editor/BlockNoteSchema.ts @@ -6,6 +6,8 @@ import { import { BlockNoDefaults, BlockSchema, + BlockSchemaFromSpecs, + BlockSpecs, InlineContentSchema, InlineContentSchemaFromSpecs, InlineContentSpecs, @@ -16,13 +18,13 @@ import { } from "../schema/index.js"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; -import { BlockSpecOf, CustomBlockNoteSchema } from "./CustomSchema.js"; +import { CustomBlockNoteSchema } from "./CustomSchema.js"; export class BlockNoteSchema< - BSpecs extends BlockSchema, + BSchema extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema, -> extends CustomBlockNoteSchema { +> extends CustomBlockNoteSchema { // Helper so that you can use typeof schema.BlockNoteEditor public readonly BlockNoteEditor: BlockNoteEditor = "only for types" as any; @@ -31,23 +33,20 @@ export class BlockNoteSchema< "only for types" as any; public readonly PartialBlock: PartialBlockNoDefaults< - any, - // BSchema, + BSchema, ISchema, SSchema > = "only for types" as any; public static create< - BSpecs extends BlockSchema = { - [key in keyof typeof defaultBlockSpecs]: (typeof defaultBlockSpecs)[key]["config"]; - }, + BSpecs extends BlockSpecs = typeof defaultBlockSpecs, ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, SSpecs extends StyleSpecs = typeof defaultStyleSpecs, >(options?: { /** * A list of custom block types that should be available in the editor. */ - blockSpecs?: BlockSpecOf; + blockSpecs?: BSpecs; /** * A list of custom InlineContent types that should be available in the editor. */ @@ -58,11 +57,11 @@ export class BlockNoteSchema< styleSpecs?: SSpecs; }) { return new BlockNoteSchema< - BSpecs, + BlockSchemaFromSpecs, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs >({ - blockSpecs: options?.blockSpecs ?? (defaultBlockSpecs 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 index 295e7b047b..212d96c1cb 100644 --- a/packages/core/src/editor/CustomSchema.ts +++ b/packages/core/src/editor/CustomSchema.ts @@ -1,12 +1,12 @@ import { - BlockDefinition, BlockSchema, + BlockSpec, + BlockSpecs, InlineContentSchema, InlineContentSpecs, - PropSchema, StyleSchema, StyleSpecs, - createBlockSpec, + addNodeAndExtensionsToSpec, getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; @@ -21,27 +21,21 @@ function removeUndefined | undefined>(obj: T): T { ) as T; } -export type BlockSpecOf = { - [key in keyof BSpecs]: key extends string - ? BlockDefinition - : never; -}; - export class CustomBlockNoteSchema< - BSpecs extends BlockSchema, + BSchema extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema, > { public readonly inlineContentSpecs: InlineContentSpecs; public readonly styleSpecs: StyleSpecs; - public readonly blockSpecs: BlockSpecOf; + public readonly blockSpecs: BlockSpecs; - public readonly blockSchema: BSpecs; + public readonly blockSchema: BSchema; public readonly inlineContentSchema: ISchema; public readonly styleSchema: SSchema; constructor(opts: { - blockSpecs: BlockSpecOf; + blockSpecs: BlockSpecs; inlineContentSpecs: InlineContentSpecs; styleSpecs: StyleSpecs; }) { @@ -60,7 +54,7 @@ export class CustomBlockNoteSchema< this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; } - private initBlockSpecs(specs: BlockSpecOf): BlockSpecOf { + private initBlockSpecs(specs: BlockSpecs): BlockSpecs { const dag = createDependencyGraph(); const defaultSet = new Set(); dag.set("default", defaultSet); @@ -94,26 +88,24 @@ export class CustomBlockNoteSchema< }; return Object.fromEntries( - Object.entries(specs).map( - ([key, blockDef]: [string, BlockDefinition]) => { - return [ - key, - Object.assign( - { - extensions: blockDef.extensions, - }, - // TODO annoying hack to get tables to work - blockDef.config.type === "table" - ? blockDef - : createBlockSpec( - blockDef.config as any, - blockDef.implementation as any, - getPriority(key), - ), - ), - ]; - }, - ), - ) as unknown as BlockSpecOf; + 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/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index e86cae7f66..902fb9bc21 100644 --- a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -434,7 +434,6 @@ export const KeyboardShortcutsExtension = Extension.create<{ ]); const handleEnter = (withShift = false) => { - console.log("handleEnter"); return this.editor.commands.first(({ commands, tr }) => [ // Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start // of the block. @@ -529,7 +528,6 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (dispatch) { const newBlock = state.schema.nodes["blockContainer"].createAndFill()!; - console.log(newBlock); state.tr .insert(newBlockInsertionPos, newBlock) 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 289687e39b..02b0ee72d7 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -1,93 +1,17 @@ import { Editor } from "@tiptap/core"; -import { DOMParser, Fragment, Schema, TagParseRule } from "@tiptap/pm/model"; -import { NodeView, ViewMutationRecord } from "@tiptap/pm/view"; +import { DOMParser, Fragment, TagParseRule } from "@tiptap/pm/model"; +import { NodeView } from "@tiptap/pm/view"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { InlineContentSchema } from "../inlineContent/types.js"; -import { StyleSchema } from "../styles/types.js"; import { - createInternalBlockSpec, + createTypedBlockSpec, createStronglyTypedTiptapNode, getBlockFromPos, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; -import { - BlockConfig, - BlockDefinition, - BlockFromConfig, - BlockImplementation, - BlockSchemaWithBlock, - PartialBlockFromConfig, -} from "./types.js"; +import { BlockConfig, BlockImplementation, BlockSpec } from "./types.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.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, -> = { - /** - * A function that converts the block into a DOM element - */ - 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 | 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: BlockFromConfig, - editor: BlockNoteEditor, I, S>, - ) => - | { - dom: HTMLElement; - 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, - ) => PartialBlockFromConfig["props"] | 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; -}; +import { PropSchema } from "../propTypes.js"; // Function that causes events within non-selectable blocks to be handled by the // browser instead of the editor. @@ -109,13 +33,17 @@ 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"], - customParseContentFunction: CustomBlockImplementation< - any, - any, - any +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[] = [ @@ -195,24 +123,22 @@ 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*" : blockConfig.content === "none" ? "" - : blockConfig.content) as T["content"] extends "inline" - ? "inline*" - : "", + : blockConfig.content) as TContent extends "inline" ? "inline*" : "", group: "blockContent", selectable: blockConfig.meta?.selectable ?? true, isolating: true, @@ -292,7 +218,7 @@ export function createBlockSpec< ); } - return createInternalBlockSpec(blockConfig, { + return createTypedBlockSpec(blockConfig, { node, render: (block, editor) => { const blockContentDOMAttributes = @@ -360,26 +286,34 @@ export function createBlockConfig< /** * Helper function to create a block definition. */ -export function createBlockDefinition< - TCallback extends (options?: any) => BlockConfig, +export function createBlockSpec< + TCallback extends (options?: any) => BlockConfig, TOptions extends Parameters[0], TName extends ReturnType["type"], TProps extends ReturnType["propSchema"], TContent extends ReturnType["content"], >( - callback: TCallback, + createBlockConfig: TCallback, ): { implementation: ( - cb: (options?: TOptions) => BlockImplementation, + createBlockImplementation: ( + options?: TOptions, + ) => BlockImplementation, addExtensions?: (options?: TOptions) => BlockNoteExtension[], - ) => (options?: TOptions) => BlockDefinition; + ) => (options?: TOptions) => BlockSpec; } { return { - implementation: (cb, addExtensions) => (options) => ({ - config: callback(options) as any, - implementation: cb(options), - extensions: addExtensions?.(options), - }), + implementation: (createBlockImplementation, addExtensions) => (options) => { + const blockConfig = createBlockConfig(options); + const blockImplementation = createBlockImplementation(options); + const extensions = addExtensions?.(options); + + return { + config: blockConfig, + implementation: blockImplementation, + extensions: extensions, + }; + }, }; } /** diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index b8e33ce798..3777230075 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -16,9 +16,9 @@ import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { BlockConfig, - BlockDefinition, BlockImplementation, BlockSchemaWithBlock, + BlockSpec, SpecificBlock, } from "./types.js"; @@ -148,7 +148,7 @@ export function wrapInBlockStructure< destroy?: () => void; }, blockType: BType, - blockProps: Props, + blockProps: Partial>, propSchema: PSchema, isFileBlock = false, domAttributes?: Record, @@ -230,13 +230,17 @@ 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: BlockImplementation & { + implementation: BlockImplementation< + T["type"], + T["propSchema"], + T["content"] + > & { node: Node; requiredExtensions?: Array; }, -): BlockDefinition { +): BlockSpec { return { config, implementation, @@ -247,7 +251,7 @@ 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*" diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 09d82c2048..4806f9faa3 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,5 +1,5 @@ /** Define the main block types **/ - +// 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"; @@ -50,23 +50,23 @@ export interface BlockConfigMeta { * i.e. what props it supports, what content it supports, etc. */ export interface BlockConfig< - TName extends string = string, - TSchema extends PropSchema = PropSchema, - TContent extends "inline" | "none" | "table" = "inline" | "none" | "table", + 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: TName; + type: T; /** * The properties that the block supports * @todo will be zod schema in the future */ - readonly propSchema: TSchema; + readonly propSchema: PS; /** * The content that the block supports */ - content: TContent; + content: C; // TODO: how do you represent things that have nested content? // e.g. tables, alerts (with title & content) /** @@ -75,44 +75,55 @@ export interface BlockConfig< 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 = { - config: T; - implementation: BlockImplementation, PropSchema>; +export type BlockSpec< + T extends string = string, + PS extends PropSchema = PropSchema, + C extends "inline" | "none" | "table" = "inline" | "none" | "table", +> = { + 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>; +export type BlockSpecs = { + [k in string]: BlockSpec; +}; export type BlockImplementations = Record< string, 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 = { @@ -162,7 +173,7 @@ export type BlockFromConfigNoChildren< ? TableContent : B["content"] extends "none" ? undefined - : undefined | never; + : never; }; export type BlockFromConfig< @@ -244,7 +255,9 @@ type PartialBlockFromConfigNoChildren< ? PartialInlineContent : B["content"] extends "table" ? PartialTableContent - : undefined | never; + : B["content"] extends "none" + ? undefined + : never; }; type PartialBlocksWithoutChildren< @@ -291,11 +304,11 @@ export type PartialBlockFromConfig< export type BlockIdentifier = { id: string } | string; -export interface BlockImplementation< - TName extends string, - TProps extends PropSchema, +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 */ @@ -336,7 +349,7 @@ export interface BlockImplementation< >, ) => | { - dom: HTMLElement; + dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; } | undefined; @@ -344,7 +357,7 @@ export interface BlockImplementation< /** * Parses an external HTML element into a block of this type when it returns the block props object, otherwise undefined */ - parse?: (el: HTMLElement) => NoInfer>> | undefined; + parse?: (el: HTMLElement) => Partial> | undefined; /** * The blocks that this block should run before. @@ -357,24 +370,11 @@ export interface BlockImplementation< * This is not recommended to use, and is only useful for advanced use cases. */ parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment; -} - -export type BlockDefinition< - TName extends string = string, - TProps extends PropSchema = PropSchema, - TContent extends "inline" | "none" | "table" = "inline" | "none" | "table", -> = { - config: BlockConfig; - implementation: BlockImplementation< - string, - PropSchema, - "inline" | "none" | "table" - >; - extensions?: BlockNoteExtension[]; }; -export type ExtractBlockConfig = T extends ( - options: any, -) => BlockDefinition - ? BlockConfig - : never; +// 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/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 34d0980ca4..b4307fedb7 100644 --- a/packages/react/src/components/Comments/schema.ts +++ b/packages/react/src/components/Comments/schema.ts @@ -1,5 +1,5 @@ import { BlockNoteSchema, defaultStyleSpecs } from "@blocknote/core"; -import { paragraph } from "../../../../core/src/blks/index.js"; +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 @@ -10,7 +10,7 @@ const { textColor, backgroundColor, ...styleSpecs } = defaultStyleSpecs; // the schema to use for comments export const schema = BlockNoteSchema.create({ blockSpecs: { - paragraph: paragraph.definition(), + paragraph: createParagraphBlockSpec(), }, styleSpecs, }); 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 9b15550d13..6a591c8e60 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 || {}; @@ -284,3 +312,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/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,