diff --git a/examples/01-basic/testing/App.tsx b/examples/01-basic/testing/App.tsx index efe1e8b78d..da173bab19 100644 --- a/examples/01-basic/testing/App.tsx +++ b/examples/01-basic/testing/App.tsx @@ -4,18 +4,21 @@ import { uploadToTmpFilesDotOrg_DEV_ONLY, } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; import { - createReactFileBlock, - defaultReactFileExtensions, + ReactAudioBlock, + ReactImageBlock, + ReactVideoBlock, useCreateBlockNote, } from "@blocknote/react"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, - file: createReactFileBlock(defaultReactFileExtensions), + image: ReactImageBlock, + video: ReactVideoBlock, + audio: ReactAudioBlock, }, }); diff --git a/packages/core/src/api/testUtil/cases/customBlocks.ts b/packages/core/src/api/testUtil/cases/customBlocks.ts index 4161f319fb..9554a82b10 100644 --- a/packages/core/src/api/testUtil/cases/customBlocks.ts +++ b/packages/core/src/api/testUtil/cases/customBlocks.ts @@ -10,20 +10,22 @@ import { defaultProps } from "../../../blocks/defaultProps"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; import { BlockNoteSchema } from "../../../editor/BlockNoteSchema"; import { createBlockSpec } from "../../../schema"; -import { fileRender } from "../../../blocks/FileBlockContent/fileBlockImplementation"; -import { filePropSchema } from "../../../blocks/FileBlockContent/fileBlockConfig"; +import { + imagePropSchema, + imageRender, +} from "../../../blocks/ImageBlockContent/ImageBlockContent"; -// This is a modified version of the default file block that does not implement +// 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 SimpleFile = createBlockSpec( +const SimpleImage = createBlockSpec( { - type: "simpleFile", - propSchema: filePropSchema, + type: "simpleImage", + propSchema: imagePropSchema, content: "none", }, { - render: (block, editor) => fileRender(block as any, editor as any), + render: (block, editor) => imageRender(block as any, editor as any), } ); @@ -77,7 +79,7 @@ const SimpleCustomParagraph = createBlockSpec( const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, - simpleFile: SimpleFile, + simpleImage: SimpleImage, customParagraph: CustomParagraph, simpleCustomParagraph: SimpleCustomParagraph, }, @@ -97,18 +99,18 @@ export const customBlocksTestCases: EditorTestCases< }, documents: [ { - name: "simpleFile/button", + name: "simpleImage/button", blocks: [ { - type: "simpleFile", + type: "simpleImage", }, ], }, { - name: "simpleFile/basic", + name: "simpleImage/basic", blocks: [ { - type: "simpleFile", + type: "simpleImage", props: { url: "exampleURL", caption: "Caption", @@ -118,10 +120,10 @@ export const customBlocksTestCases: EditorTestCases< ], }, { - name: "simpleFile/nested", + name: "simpleImage/nested", blocks: [ { - type: "simpleFile", + type: "simpleImage", props: { url: "exampleURL", caption: "Caption", @@ -129,7 +131,7 @@ export const customBlocksTestCases: EditorTestCases< }, children: [ { - type: "simpleFile", + type: "simpleImage", props: { url: "exampleURL", caption: "Caption", diff --git a/packages/core/src/api/testUtil/cases/defaultSchema.ts b/packages/core/src/api/testUtil/cases/defaultSchema.ts index a5d13dbcee..b7b59747ac 100644 --- a/packages/core/src/api/testUtil/cases/defaultSchema.ts +++ b/packages/core/src/api/testUtil/cases/defaultSchema.ts @@ -143,10 +143,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/button", blocks: [ { - type: "file", - props: { - fileType: "image", - }, + type: "image", }, ], }, @@ -154,9 +151,8 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/basic", blocks: [ { - type: "file", + type: "image", props: { - fileType: "image", url: "exampleURL", caption: "Caption", previewWidth: 256, @@ -168,18 +164,16 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/nested", blocks: [ { - type: "file", + type: "image", props: { - fileType: "image", url: "exampleURL", caption: "Caption", previewWidth: 256, }, children: [ { - type: "file", + type: "image", props: { - fileType: "image", url: "exampleURL", caption: "Caption", previewWidth: 256, diff --git a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts new file mode 100644 index 0000000000..e3d2555f40 --- /dev/null +++ b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts @@ -0,0 +1,148 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + BlockFromConfig, + createBlockSpec, + FileBlockConfig, + Props, + PropSchema, +} from "../../schema"; +import { defaultProps } from "../defaultProps"; + +import { + createAddFileButton, + createDefaultFilePreview, + createFigureWithCaption, + createFileAndCaptionWrapper, + parseFigureElement, +} from "../FileBlockContent/fileBlockHelpers"; +import { parseAudioElement } from "./audioBlockHelpers"; + +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, +} satisfies FileBlockConfig; + +export const audioRender = ( + block: BlockFromConfig, + editor: BlockNoteEditor +) => { + const wrapper = document.createElement("div"); + wrapper.className = "bn-file-block-content-wrapper"; + + if (block.props.url === "") { + const fileBlockAudioIcon = document.createElement("div"); + fileBlockAudioIcon.innerHTML = + ''; + const addAudioButton = createAddFileButton( + block, + editor, + "Add audio", + fileBlockAudioIcon.firstElementChild as HTMLElement + ); + wrapper.appendChild(addAudioButton.dom); + + return { + dom: wrapper, + destroy: () => { + addAudioButton?.destroy?.(); + }, + }; + } else if (!block.props.showPreview) { + const file = createDefaultFilePreview(block).dom; + const element = createFileAndCaptionWrapper(block, file); + + return { + dom: element.dom, + }; + } else { + const audio = document.createElement("audio"); + audio.className = "bn-audio"; + audio.src = block.props.url; + audio.controls = true; + audio.contentEditable = "false"; + audio.draggable = false; + + const element = createFileAndCaptionWrapper(block, audio); + wrapper.appendChild(element.dom); + + return { + dom: wrapper, + }; + } +}; + +export const audioParse = ( + element: HTMLElement +): Partial> | undefined => { + if (element.tagName === "AUDIO") { + 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.innerHTML = "Add audio"; + + return { + dom: div, + }; + } + + const audio = document.createElement("audio"); + audio.src = block.props.url; + + if (block.props.caption) { + return createFigureWithCaption(audio, block.props.caption); + } + + return { + dom: audio, + }; +}; + +export const AudioBlock = createBlockSpec(audioBlockConfig, { + render: audioRender, + parse: audioParse, + toExternalHTML: audioToExternalHTML, +}); diff --git a/packages/core/src/blocks/AudioBlockContent/audioBlockHelpers.ts b/packages/core/src/blocks/AudioBlockContent/audioBlockHelpers.ts new file mode 100644 index 0000000000..2a5bec4403 --- /dev/null +++ b/packages/core/src/blocks/AudioBlockContent/audioBlockHelpers.ts @@ -0,0 +1,5 @@ +export const parseAudioElement = (audioElement: HTMLAudioElement) => { + const url = audioElement.src || undefined; + + return { url }; +}; diff --git a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts index fa389eb075..e15d649d9c 100644 --- a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts +++ b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts @@ -1,20 +1,128 @@ -import { createBlockSpec } from "../../schema"; -import { fileBlockConfig } from "./fileBlockConfig"; -import { createFileBlockImplementation } from "./fileBlockImplementation"; -import { FileBlockExtension } from "./fileBlockExtension"; - -export const createFileBlock = ( - extensions?: Record -) => - createBlockSpec(fileBlockConfig, createFileBlockImplementation(extensions)); - -// - React support? -// - Support parse HTML and toExternalHTML -// - Copy/paste support -// - Drag/drop from external into BlockNote (automatic conversion to block & upload) -// - Custom props e.g. PDF height, video playback options -// - Toolbar/menu options should change based on file type -// - Button text/icon should be file type specific -// - Should be able to define the file type before uploading/embedding -// - Media like pdfs should be previewable (might be issues with cross domain access) -// - Renderers should be loosely coupled to file extensions via plugins for different file types +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + BlockFromConfig, + FileBlockConfig, + PropSchema, + createBlockSpec, +} from "../../schema"; +import { defaultProps } from "../defaultProps"; +import { + createFileAndCaptionWrapper, + createDefaultFilePreview, + createAddFileButton, + parseEmbedElement, + parseFigureElement, +} from "./fileBlockHelpers"; + +export const filePropSchema = { + textAlignment: defaultProps.textAlignment, + backgroundColor: defaultProps.backgroundColor, + // File name. + name: { + default: "" as const, + }, + // File url. + url: { + default: "" as const, + }, + // File caption. + caption: { + default: "" as const, + }, +} satisfies PropSchema; + +export const fileBlockConfig = { + type: "file" as const, + propSchema: filePropSchema, + content: "none", + isFileBlock: true, +} satisfies FileBlockConfig; + +export const fileRender = ( + block: BlockFromConfig, + editor: BlockNoteEditor +) => { + // Wrapper element to set the file alignment, contains both file/file + // upload dashboard and caption. + const wrapper = document.createElement("div"); + wrapper.className = "bn-file-block-content-wrapper"; + + if (block.props.url === "") { + const addFileButton = createAddFileButton(block, editor); + wrapper.appendChild(addFileButton.dom); + + return { + dom: wrapper, + destroy: addFileButton.destroy, + }; + } else { + const file = createDefaultFilePreview(block).dom; + const element = createFileAndCaptionWrapper(block, file); + wrapper.appendChild(element.dom); + + return { + dom: wrapper, + }; + } +}; + +export const fileParse = (element: HTMLElement) => { + if (element.tagName === "EMBED") { + 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.innerHTML = "Add file"; + + return { + dom: div, + }; + } + + const fileSrcLink = document.createElement("a"); + fileSrcLink.href = block.props.url; + fileSrcLink.innerText = block.props.name; + + if (block.props.caption) { + const wrapper = document.createElement("div"); + const fileCaption = document.createElement("p"); + fileCaption.innerText = block.props.caption; + wrapper.appendChild(fileSrcLink); + wrapper.appendChild(fileCaption); + + return { + dom: wrapper, + }; + } + + return { + dom: fileSrcLink, + }; +}; + +export const FileBlock = createBlockSpec(fileBlockConfig, { + render: fileRender, + parse: fileParse, + toExternalHTML: fileToExternalHTML, +}); diff --git a/packages/core/src/blocks/FileBlockContent/extensions/audioFileExtension.ts b/packages/core/src/blocks/FileBlockContent/extensions/audioFileExtension.ts deleted file mode 100644 index d83c6cb380..0000000000 --- a/packages/core/src/blocks/FileBlockContent/extensions/audioFileExtension.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { BlockFromConfig } from "../../../schema"; -import { fileBlockConfig } from "../fileBlockConfig"; -import { FileBlockExtension } from "../fileBlockExtension"; - -export const audioRender = ( - block: BlockFromConfig -) => { - // Audio element. - const audio = document.createElement("audio"); - audio.className = "bn-audio"; - audio.src = block.props.url; - audio.contentEditable = "false"; - audio.controls = true; - audio.draggable = false; - - return { - dom: audio, - }; -}; - -export const audioParse = (element: HTMLElement) => { - if (element.tagName === "FIGURE") { - const audio = element.querySelector("audio"); - const caption = element.querySelector("figcaption"); - return { - fileType: "audio", - url: audio?.src || undefined, - caption: caption?.textContent ?? undefined, - }; - } - - if (element.tagName === "AUDIO") { - return { - fileType: "audio", - url: (element as HTMLAudioElement).src || undefined, - }; - } - - return undefined; -}; - -export const audioToExternalHTML = ( - block: BlockFromConfig -) => { - const audio = document.createElement("audio"); - audio.src = block.props.url; - - if (block.props.caption) { - const figure = document.createElement("figure"); - const caption = document.createElement("figcaption"); - caption.textContent = block.props.caption; - - figure.appendChild(audio); - figure.appendChild(caption); - - return { - dom: figure, - }; - } - - return { - dom: audio, - }; -}; - -export const audioFileExtension: FileBlockExtension = { - fileEndings: ["flac", "mp3", "wav"], - render: audioRender, - parse: audioParse, - toExternalHTML: audioToExternalHTML, - buttonText: "audio", - buttonIcon: () => { - const fileBlockAudioIcon = document.createElement("div"); - fileBlockAudioIcon.innerHTML = - ''; - return fileBlockAudioIcon.firstElementChild as HTMLElement; - }, -}; diff --git a/packages/core/src/blocks/FileBlockContent/extensions/defaultFileExtensions.ts b/packages/core/src/blocks/FileBlockContent/extensions/defaultFileExtensions.ts deleted file mode 100644 index c6f5a5ff7f..0000000000 --- a/packages/core/src/blocks/FileBlockContent/extensions/defaultFileExtensions.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FileBlockExtension } from "../fileBlockExtension"; -import { imageFileExtension } from "./imageFileExtension"; -import { videoFileExtension } from "./videoFileExtension"; -import { audioFileExtension } from "./audioFileExtension"; - -export const defaultFileExtensions: Record = { - image: imageFileExtension, - video: videoFileExtension, - audio: audioFileExtension, -}; diff --git a/packages/core/src/blocks/FileBlockContent/extensions/imageFileExtension.ts b/packages/core/src/blocks/FileBlockContent/extensions/imageFileExtension.ts deleted file mode 100644 index 7ae9b15b96..0000000000 --- a/packages/core/src/blocks/FileBlockContent/extensions/imageFileExtension.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; -import { BlockFromConfig, BlockSchemaWithBlock } from "../../../schema"; -import { fileBlockConfig } from "../fileBlockConfig"; -import { FileBlockExtension } from "../fileBlockExtension"; -import { renderWithResizeHandles } from "./utils/renderWithResizeHandles"; - -export const imageRender = ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock<"file", typeof fileBlockConfig>, - any, - any - > -) => { - // Image element. - const image = document.createElement("img"); - image.className = "bn-visual-media"; - image.src = block.props.url; - image.alt = block.props.caption || "BlockNote image"; - image.contentEditable = "false"; - image.draggable = false; - image.width = Math.min( - block.props.previewWidth, - editor.domElement.firstElementChild!.clientWidth - ); - - return renderWithResizeHandles( - block, - editor, - image, - () => image.width, - (width) => (image.width = width) - ); -}; - -export const imageParse = (element: HTMLElement) => { - if (element.tagName === "FIGURE") { - const img = element.querySelector("img"); - const caption = element.querySelector("figcaption"); - return { - fileType: "image", - url: img?.src || undefined, - caption: caption?.textContent ?? img?.alt, - previewWidth: img?.width || undefined, - }; - } - - if (element.tagName === "IMG") { - return { - fileType: "image", - url: (element as HTMLImageElement).src || undefined, - previewWidth: (element as HTMLImageElement).width || undefined, - }; - } - - return undefined; -}; - -export const imageToExternalHTML = ( - block: BlockFromConfig -) => { - const image = document.createElement("img"); - image.src = block.props.url; - image.width = block.props.previewWidth; - image.alt = block.props.caption || "BlockNote image"; - - if (block.props.caption) { - const figure = document.createElement("figure"); - const caption = document.createElement("figcaption"); - caption.textContent = block.props.caption; - - figure.appendChild(image); - figure.appendChild(caption); - - return { - dom: figure, - }; - } - - return { - dom: image, - }; -}; - -export const imageFileExtension: FileBlockExtension = { - fileEndings: [ - "apng", - "avif", - "gif", - "jpg", - "jpeg", - "jfif", - "pjpeg", - "pjp", - "svg", - "webp", - ], - render: imageRender, - parse: imageParse, - toExternalHTML: imageToExternalHTML, - buttonText: "image", - buttonIcon: () => { - const fileBlockImageIcon = document.createElement("div"); - fileBlockImageIcon.innerHTML = - ''; - - return fileBlockImageIcon.firstElementChild as HTMLElement; - }, -}; diff --git a/packages/core/src/blocks/FileBlockContent/extensions/videoFileExtension.ts b/packages/core/src/blocks/FileBlockContent/extensions/videoFileExtension.ts deleted file mode 100644 index f4c9eceee0..0000000000 --- a/packages/core/src/blocks/FileBlockContent/extensions/videoFileExtension.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; -import { BlockFromConfig, BlockSchemaWithBlock } from "../../../schema"; -import { fileBlockConfig } from "../fileBlockConfig"; -import { FileBlockExtension } from "../fileBlockExtension"; -import { renderWithResizeHandles } from "./utils/renderWithResizeHandles"; - -export const videoRender = ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock<"file", typeof fileBlockConfig>, - any, - any - > -) => { - // Video element. - const video = document.createElement("video"); - video.className = "bn-visual-media"; - video.src = block.props.url; - video.controls = true; - video.contentEditable = "false"; - video.draggable = false; - video.width = Math.min( - block.props.previewWidth, - editor.domElement.firstElementChild!.clientWidth - ); - - return renderWithResizeHandles( - block, - editor, - video, - () => video.width, - (width) => (video.width = width) - ); -}; - -export const videoParse = (element: HTMLElement) => { - if (element.tagName === "FIGURE") { - const img = element.querySelector("video"); - const caption = element.querySelector("figcaption"); - return { - fileType: "video", - url: img?.src || undefined, - caption: caption?.textContent ?? undefined, - previewWidth: img?.width || undefined, - }; - } - - if (element.tagName === "VIDEO") { - return { - fileType: "video", - url: (element as HTMLVideoElement).src || undefined, - previewWidth: (element as HTMLVideoElement).width || undefined, - }; - } - - return undefined; -}; - -export const videoToExternalHTML = ( - block: BlockFromConfig -) => { - const video = document.createElement("video"); - video.src = block.props.url; - video.width = block.props.previewWidth; - - if (block.props.caption) { - const figure = document.createElement("figure"); - const caption = document.createElement("figcaption"); - caption.textContent = block.props.caption; - - figure.appendChild(video); - figure.appendChild(caption); - - return { - dom: figure, - }; - } - - return { - dom: video, - }; -}; - -export const videoFileExtension: FileBlockExtension = { - fileEndings: ["mp4", "ogg", "webm"], - render: videoRender, - parse: videoParse, - toExternalHTML: videoToExternalHTML, - buttonText: "video", - buttonIcon: () => { - const fileBlockVideoIcon = document.createElement("div"); - fileBlockVideoIcon.innerHTML = - ''; - return fileBlockVideoIcon.firstElementChild as HTMLElement; - }, -}; diff --git a/packages/core/src/blocks/FileBlockContent/fileBlockConfig.ts b/packages/core/src/blocks/FileBlockContent/fileBlockConfig.ts deleted file mode 100644 index 923b2a20c8..0000000000 --- a/packages/core/src/blocks/FileBlockContent/fileBlockConfig.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { defaultProps } from "../defaultProps"; -import { CustomBlockConfig, PropSchema } from "../../schema"; - -export const filePropSchema = { - textAlignment: defaultProps.textAlignment, - backgroundColor: defaultProps.backgroundColor, - // File type. - fileType: { - default: "" as const, - }, - // File name. - name: { - default: "" as const, - }, - // File url. - url: { - default: "" as const, - }, - // File caption. - caption: { - default: "" as const, - }, - // Whether to show the file preview or the name only. - showPreview: { - default: true as const, - }, - // File preview width in px. - previewWidth: { - default: 512 as const, - }, -} satisfies PropSchema; - -export const fileBlockConfig = { - type: "file" as const, - propSchema: filePropSchema, - content: "none", -} satisfies CustomBlockConfig; diff --git a/packages/core/src/blocks/FileBlockContent/fileBlockExtension.ts b/packages/core/src/blocks/FileBlockContent/fileBlockExtension.ts deleted file mode 100644 index f9c4c2b0ec..0000000000 --- a/packages/core/src/blocks/FileBlockContent/fileBlockExtension.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - BlockFromConfig, - BlockSchemaWithBlock, - PartialBlockFromConfig, -} from "../../schema"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { fileBlockConfig } from "./fileBlockConfig"; - -export type FileBlockExtension = { - fileEndings: string[]; - render: ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock<"file", typeof fileBlockConfig>, - any, - any - > - ) => { - dom: HTMLElement; - destroy?: () => void; - }; - parse?: ( - element: HTMLElement - ) => - | PartialBlockFromConfig["props"] - | undefined; - toExternalHTML?: ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock<"file", typeof fileBlockConfig>, - any, - any - > - ) => { - dom: HTMLElement; - }; - buttonText?: string; - buttonIcon?: () => HTMLElement; -}; diff --git a/packages/core/src/blocks/FileBlockContent/extensions/utils/renderWithResizeHandles.ts b/packages/core/src/blocks/FileBlockContent/fileBlockHelpers.ts similarity index 53% rename from packages/core/src/blocks/FileBlockContent/extensions/utils/renderWithResizeHandles.ts rename to packages/core/src/blocks/FileBlockContent/fileBlockHelpers.ts index 32a51d2963..7ddc865166 100644 --- a/packages/core/src/blocks/FileBlockContent/extensions/utils/renderWithResizeHandles.ts +++ b/packages/core/src/blocks/FileBlockContent/fileBlockHelpers.ts @@ -1,18 +1,166 @@ -import { BlockFromConfig, BlockSchemaWithBlock } from "../../../../schema"; -import { fileBlockConfig } from "../../fileBlockConfig"; -import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; - -export const renderWithResizeHandles = ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock<"file", typeof fileBlockConfig>, - any, - any - >, +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { BlockFromConfig, FileBlockConfig } from "../../schema"; + +// Default file preview, displaying a file icon and file name. +export const createDefaultFilePreview = ( + block: BlockFromConfig +): { dom: HTMLElement; destroy?: () => void } => { + const file = document.createElement("div"); + file.className = "bn-file-default-preview"; + + const icon = document.createElement("div"); + icon.className = "bn-file-default-preview-icon"; + icon.innerHTML = + ''; + + const fileName = document.createElement("p"); + fileName.className = "bn-file-default-preview-name"; + fileName.innerText = block.props.name || ""; + + file.appendChild(icon); + file.appendChild(fileName); + + return { + dom: file, + }; +}; + +// Wrapper element containing file preview and caption. +export const createFileAndCaptionWrapper = ( + block: BlockFromConfig, + file: HTMLElement +) => { + const fileAndCaptionWrapper = document.createElement("div"); + fileAndCaptionWrapper.className = "bn-file-and-caption-wrapper"; + + const caption = document.createElement("p"); + caption.className = "bn-file-caption"; + caption.innerText = block.props.caption; + + fileAndCaptionWrapper.appendChild(file); + fileAndCaptionWrapper.appendChild(caption); + + return { + dom: fileAndCaptionWrapper, + }; +}; + +// Button element that acts as a placeholder for files with no src. +export const createAddFileButton = ( + block: BlockFromConfig, + editor: BlockNoteEditor, + buttonText?: string, + buttonIcon?: HTMLElement +) => { + const addFileButton = document.createElement("div"); + addFileButton.className = "bn-add-file-button"; + + const addFileButtonIcon = document.createElement("div"); + addFileButtonIcon.className = "bn-add-file-button-icon"; + if (buttonIcon) { + addFileButtonIcon.appendChild(buttonIcon); + } else { + addFileButtonIcon.innerHTML = + ''; + } + + const addFileButtonText = document.createElement("p"); + addFileButtonText.className = "bn-add-file-button-text"; + addFileButtonText.innerHTML = + buttonText || editor.dictionary.file_blocks.file.add_button_text; + + // Prevents focus from moving to the button. + const addFileButtonMouseDownHandler = (event: MouseEvent) => { + event.preventDefault(); + }; + // Opens the file toolbar. + const addFileButtonClickHandler = () => { + editor._tiptapEditor.view.dispatch( + editor._tiptapEditor.state.tr.setMeta(editor.filePanel!.plugin, { + block: block, + }) + ); + }; + + addFileButton.appendChild(addFileButtonIcon); + addFileButton.appendChild(addFileButtonText); + + addFileButton.addEventListener( + "mousedown", + addFileButtonMouseDownHandler, + true + ); + addFileButton.addEventListener("click", addFileButtonClickHandler, true); + + return { + dom: addFileButton, + destroy: () => { + addFileButton.removeEventListener( + "mousedown", + addFileButtonMouseDownHandler, + true + ); + addFileButton.removeEventListener( + "click", + addFileButtonClickHandler, + true + ); + }, + }; +}; + +export const parseEmbedElement = (embedElement: HTMLEmbedElement) => { + const url = embedElement.src || undefined; + + return { url }; +}; + +export const parseFigureElement = ( + figureElement: HTMLElement, + targetTag: string +) => { + const targetElement = figureElement.querySelector( + targetTag + ) as HTMLElement | null; + if (!targetElement) { + return undefined; + } + + const captionElement = figureElement.querySelector("figcaption"); + const caption = captionElement?.textContent ?? undefined; + + return { targetElement, caption }; +}; + +// Wrapper figure element to display file preview with caption. Used for +// external HTML. +export const createFigureWithCaption = ( + element: HTMLElement, + caption: string +) => { + const figure = document.createElement("figure"); + const captionElement = document.createElement("figcaption"); + captionElement.textContent = caption; + + figure.appendChild(element); + figure.appendChild(captionElement); + + return { dom: figure }; +}; + +// Wrapper element which adds resize handles & logic for visual media file +// previews. +export const createResizeHandlesWrapper = ( + block: BlockFromConfig, + editor: BlockNoteEditor, element: HTMLElement, getWidth: () => number, setWidth: (width: number) => void ): { dom: HTMLElement; destroy: () => void } => { + if (!block.props.previewWidth) { + throw new Error("Block must have a `previewWidth` prop."); + } + // Wrapper element for rendered element and resize handles. const wrapper = document.createElement("div"); wrapper.className = "bn-visual-media-wrapper"; @@ -112,7 +260,6 @@ export const renderWithResizeHandles = ( resizeParams = undefined; editor.updateBlock(block, { - type: "file", props: { previewWidth: getWidth(), }, @@ -160,7 +307,7 @@ export const renderWithResizeHandles = ( resizeParams = { handleUsed: "left", - initialWidth: block.props.previewWidth, + initialWidth: block.props.previewWidth!, initialClientX: event.clientX, }; }; @@ -172,7 +319,7 @@ export const renderWithResizeHandles = ( resizeParams = { handleUsed: "right", - initialWidth: block.props.previewWidth, + initialWidth: block.props.previewWidth!, initialClientX: event.clientX, }; }; diff --git a/packages/core/src/blocks/FileBlockContent/fileBlockImplementation.ts b/packages/core/src/blocks/FileBlockContent/fileBlockImplementation.ts deleted file mode 100644 index 23159f1a54..0000000000 --- a/packages/core/src/blocks/FileBlockContent/fileBlockImplementation.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { - BlockFromConfig, - BlockSchemaWithBlock, - CustomBlockImplementation, -} from "../../schema"; -import { fileBlockConfig } from "./fileBlockConfig"; -import { FileBlockExtension } from "./fileBlockExtension"; - -const defaultFileRender = ( - block: BlockFromConfig -): { dom: HTMLElement; destroy?: () => void } => { - const file = document.createElement("div"); - file.className = "bn-file-default-preview"; - - const icon = document.createElement("div"); - icon.className = "bn-file-default-preview-icon"; - icon.innerHTML = - ''; - - const fileName = document.createElement("p"); - fileName.className = "bn-file-default-preview-name"; - fileName.innerHTML = block.props.name || ""; - - file.appendChild(icon); - file.appendChild(fileName); - - return { - dom: file, - }; -}; - -export const fileRender = ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock<"file", typeof fileBlockConfig>, - any, - any - >, - extensions?: Record< - string, - Pick - > -) => { - // Wrapper element to set the file alignment, contains both file/file - // upload dashboard and caption. - const wrapper = document.createElement("div"); - wrapper.className = "bn-file-block-content-wrapper"; - - // Button element that acts as a placeholder for files with no src. - const addFileButton = document.createElement("div"); - addFileButton.className = "bn-add-file-button"; - - // Icon for the add file button. - const addFileButtonIcon = document.createElement("div"); - addFileButtonIcon.className = "bn-add-file-button-icon"; - - // Text for the add file button. - const addFileButtonText = document.createElement("p"); - addFileButtonText.className = "bn-add-file-button-text"; - addFileButtonText.innerHTML = `${editor.dictionary.file.button_add_text} ${ - block.props.fileType && - extensions && - block.props.fileType in extensions && - extensions[block.props.fileType].buttonText !== undefined - ? extensions[block.props.fileType].buttonText! - : editor.dictionary.file.button_default_file_type_text - }`; - - // Wrapper element for the file, resize handles and caption. - const fileAndCaptionWrapper = document.createElement("div"); - fileAndCaptionWrapper.className = "bn-file-and-caption-wrapper"; - - const fileType = block.props.fileType; - const renderedFileExtension = - block.props.showPreview && fileType && extensions && fileType in extensions - ? extensions[fileType].render(block, editor) - : defaultFileRender(block); - - // File element. - const file = renderedFileExtension.dom; - - // Caption element. - const caption = document.createElement("p"); - caption.className = "bn-file-caption"; - caption.innerHTML = block.props.caption; - - // Prevents focus from moving to the button. - const addFileButtonMouseDownHandler = (event: MouseEvent) => { - event.preventDefault(); - }; - // Opens the file toolbar. - const addFileButtonClickHandler = () => { - editor._tiptapEditor.view.dispatch( - editor._tiptapEditor.state.tr.setMeta(editor.filePanel!.plugin, { - block: block, - }) - ); - }; - - if ( - block.props.fileType && - extensions && - block.props.fileType in extensions && - extensions[block.props.fileType].buttonIcon !== undefined - ) { - addFileButtonIcon.appendChild( - extensions[block.props.fileType].buttonIcon!() - ); - } else { - addFileButtonIcon.innerHTML = - ''; - } - addFileButton.appendChild(addFileButtonIcon); - addFileButton.appendChild(addFileButtonText); - - fileAndCaptionWrapper.appendChild(file); - fileAndCaptionWrapper.appendChild(caption); - - if (block.props.url === "") { - wrapper.appendChild(addFileButton); - } else { - wrapper.appendChild(fileAndCaptionWrapper); - } - - addFileButton.addEventListener( - "mousedown", - addFileButtonMouseDownHandler, - true - ); - addFileButton.addEventListener("click", addFileButtonClickHandler, true); - - return { - dom: wrapper, - destroy: () => { - addFileButton.removeEventListener( - "mousedown", - addFileButtonMouseDownHandler, - true - ); - addFileButton.removeEventListener( - "click", - addFileButtonClickHandler, - true - ); - renderedFileExtension?.destroy?.(); - }, - }; -}; - -export const fileParse = ( - element: HTMLElement, - parseExtensions?: Record> -) => { - // Checks if any extensions can parse the element. - const propsFromExtension = Object.values(parseExtensions || {}) - .map((extension) => - extension.parse ? extension.parse(element) : undefined - ) - .find((item) => item !== undefined); - - if (propsFromExtension) { - return propsFromExtension; - } - - // Falls back to default parsing logic. - if (element.tagName === "EMBED") { - const fileType = element.getAttribute("type"); - const url = element.getAttribute("src"); - const previewWidth = element.getAttribute("width"); - - return { - fileType: - fileType && parseExtensions && fileType in parseExtensions - ? fileType.split("/")[0] - : undefined, - url: url || undefined, - previewWidth: previewWidth ? parseInt(previewWidth) : undefined, - }; - } - - if (element.tagName === "FIGURE") { - const fileElement = element.querySelector("embed"); - const captionElement = element.querySelector("figcaption"); - - const fileType = fileElement?.type; - const url = fileElement?.src; - const previewWidth = fileElement?.width; - const caption = captionElement?.textContent; - - return { - fileType: - fileType && parseExtensions && fileType in parseExtensions - ? fileType.split("/")[0] - : undefined, - url: url || undefined, - previewWidth: previewWidth ? parseInt(previewWidth) : undefined, - caption: caption || undefined, - }; - } - - return undefined; -}; - -export const fileToExternalHTML = ( - block: BlockFromConfig, - editor: BlockNoteEditor< - BlockSchemaWithBlock<"file", typeof fileBlockConfig>, - any, - any - >, - extensions?: Record< - string, - Pick - > -) => { - if (!block.props.url) { - const div = document.createElement("p"); - div.innerHTML = `${editor.dictionary.file.button_add_text} ${ - block.props.fileType && - extensions && - block.props.fileType in extensions && - extensions[block.props.fileType].buttonText !== undefined - ? extensions[block.props.fileType].buttonText - : editor.dictionary.file.button_default_file_type_text - }`; - - return { - dom: div, - }; - } - - if ( - extensions && - block.props.fileType && - block.props.fileType in extensions && - extensions[block.props.fileType].toExternalHTML - ) { - return extensions[block.props.fileType].toExternalHTML!(block, editor); - } - - const embed = document.createElement("embed"); - if (block.props.fileType) { - embed.type = block.props.fileType; - } - embed.src = block.props.url; - - if (block.props.caption) { - const figure = document.createElement("figure"); - const caption = document.createElement("figcaption"); - - caption.textContent = block.props.caption; - - figure.appendChild(embed); - figure.appendChild(caption); - - return { - dom: figure, - }; - } - - return { - dom: embed, - }; -}; - -export const createFileBlockImplementation = ( - extensions?: Record -) => - ({ - render: (block, editor) => fileRender(block, editor, extensions), - parse: (element) => fileParse(element, extensions), - toExternalHTML: (block, editor) => - fileToExternalHTML(block, editor, extensions), - extensions: extensions || {}, - } satisfies CustomBlockImplementation & { - extensions: Record; - }); diff --git a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts new file mode 100644 index 0000000000..06fa6b3553 --- /dev/null +++ b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts @@ -0,0 +1,168 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + BlockFromConfig, + createBlockSpec, + FileBlockConfig, + Props, + PropSchema, +} from "../../schema"; +import { defaultProps } from "../defaultProps"; + +import { + createAddFileButton, + createDefaultFilePreview, + createFigureWithCaption, + createFileAndCaptionWrapper, + createResizeHandlesWrapper, + parseFigureElement, +} from "../FileBlockContent/fileBlockHelpers"; +import { parseImageElement } from "./imageBlockHelpers"; + +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: 512, + }, +} satisfies PropSchema; + +export const imageBlockConfig = { + type: "image" as const, + propSchema: imagePropSchema, + content: "none", + isFileBlock: true, +} satisfies FileBlockConfig; + +export const imageRender = ( + block: BlockFromConfig, + editor: BlockNoteEditor +) => { + const wrapper = document.createElement("div"); + wrapper.className = "bn-file-block-content-wrapper"; + + if (block.props.url === "") { + const fileBlockImageIcon = document.createElement("div"); + fileBlockImageIcon.innerHTML = + ''; + const addImageButton = createAddFileButton( + block, + editor, + "Add image", + fileBlockImageIcon.firstElementChild as HTMLElement + ); + wrapper.appendChild(addImageButton.dom); + + return { + dom: wrapper, + destroy: () => { + addImageButton?.destroy?.(); + }, + }; + } else if (!block.props.showPreview) { + const file = createDefaultFilePreview(block).dom; + const element = createFileAndCaptionWrapper(block, file); + + return { + dom: element.dom, + }; + } else { + const image = document.createElement("img"); + image.className = "bn-visual-media"; + image.src = block.props.url; + image.alt = block.props.caption || "BlockNote image"; + image.contentEditable = "false"; + image.draggable = false; + image.width = Math.min( + block.props.previewWidth, + editor.domElement.firstElementChild!.clientWidth + ); + + const file = createResizeHandlesWrapper( + block, + editor, + image, + () => image.width, + (width) => (image.width = width) + ); + + const element = createFileAndCaptionWrapper(block, file.dom); + wrapper.appendChild(element.dom); + + return { + dom: wrapper, + destroy: file.destroy, + }; + } +}; + +export const imageParse = ( + element: HTMLElement +): Partial> | undefined => { + if (element.tagName === "IMG") { + 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.innerHTML = "Add image"; + + return { + dom: div, + }; + } + + const image = document.createElement("img"); + image.src = block.props.url; + image.alt = block.props.caption || "BlockNote image"; + + if (block.props.caption) { + return createFigureWithCaption(image, block.props.caption); + } + + return { + dom: image, + }; +}; + +export const ImageBlock = createBlockSpec(imageBlockConfig, { + render: imageRender, + parse: imageParse, + toExternalHTML: imageToExternalHTML, +}); diff --git a/packages/core/src/blocks/ImageBlockContent/imageBlockHelpers.ts b/packages/core/src/blocks/ImageBlockContent/imageBlockHelpers.ts new file mode 100644 index 0000000000..d225b9daa3 --- /dev/null +++ b/packages/core/src/blocks/ImageBlockContent/imageBlockHelpers.ts @@ -0,0 +1,6 @@ +export const parseImageElement = (imageElement: HTMLImageElement) => { + const url = imageElement.src || undefined; + const previewWidth = imageElement.width || undefined; + + return { url, previewWidth }; +}; diff --git a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts new file mode 100644 index 0000000000..32a99c8bde --- /dev/null +++ b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts @@ -0,0 +1,167 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + BlockFromConfig, + createBlockSpec, + FileBlockConfig, + Props, + PropSchema, +} from "../../schema"; +import { defaultProps } from "../defaultProps"; + +import { + createAddFileButton, + createDefaultFilePreview, + createFigureWithCaption, + createFileAndCaptionWrapper, + createResizeHandlesWrapper, + parseFigureElement, +} from "../FileBlockContent/fileBlockHelpers"; +import { parseVideoElement } from "./videoBlockHelpers"; + +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: 512, + }, +} satisfies PropSchema; + +export const videoBlockConfig = { + type: "video" as const, + propSchema: videoPropSchema, + content: "none", + isFileBlock: true, +} satisfies FileBlockConfig; + +export const videoRender = ( + block: BlockFromConfig, + editor: BlockNoteEditor +) => { + const wrapper = document.createElement("div"); + wrapper.className = "bn-file-block-content-wrapper"; + + if (block.props.url === "") { + const fileBlockVideoIcon = document.createElement("div"); + fileBlockVideoIcon.innerHTML = + ''; + const addVideoButton = createAddFileButton( + block, + editor, + "Add video", + fileBlockVideoIcon.firstElementChild as HTMLElement + ); + wrapper.appendChild(addVideoButton.dom); + + return { + dom: wrapper, + destroy: () => { + addVideoButton?.destroy?.(); + }, + }; + } else if (!block.props.showPreview) { + const file = createDefaultFilePreview(block).dom; + const element = createFileAndCaptionWrapper(block, file); + + return { + dom: element.dom, + }; + } else { + const video = document.createElement("video"); + video.className = "bn-visual-media"; + video.src = block.props.url; + video.controls = true; + video.contentEditable = "false"; + video.draggable = false; + video.width = Math.min( + block.props.previewWidth, + editor.domElement.firstElementChild!.clientWidth + ); + + const file = createResizeHandlesWrapper( + block, + editor, + video, + () => video.width, + (width) => (video.width = width) + ); + + const element = createFileAndCaptionWrapper(block, file.dom); + wrapper.appendChild(element.dom); + + return { + dom: wrapper, + destroy: file.destroy, + }; + } +}; + +export const videoParse = ( + element: HTMLElement +): Partial> | undefined => { + if (element.tagName === "VIDEO") { + 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.innerHTML = "Add video"; + + return { + dom: div, + }; + } + + const video = document.createElement("video"); + video.src = block.props.url; + + if (block.props.caption) { + return createFigureWithCaption(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/VideoBlockContent/videoBlockHelpers.ts b/packages/core/src/blocks/VideoBlockContent/videoBlockHelpers.ts new file mode 100644 index 0000000000..4b11481d48 --- /dev/null +++ b/packages/core/src/blocks/VideoBlockContent/videoBlockHelpers.ts @@ -0,0 +1,6 @@ +export const parseVideoElement = (videoElement: HTMLVideoElement) => { + const url = videoElement.src || undefined; + const previewWidth = videoElement.width || undefined; + + return { url, previewWidth }; +}; diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 175aabca2b..dad1e461f1 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -1,5 +1,11 @@ import type { BlockNoteEditor } from "../editor/BlockNoteEditor"; -import { BlockFromConfig, InlineContentSchema, StyleSchema } from "../schema"; +import { + BlockFromConfig, + BlockSchema, + FileBlockConfig, + InlineContentSchema, + StyleSchema, +} from "../schema"; import { Block, DefaultBlockSchema, defaultBlockSchema } from "./defaultBlocks"; import { defaultProps } from "./defaultProps"; @@ -33,6 +39,43 @@ export function checkBlockIsDefaultType< ); } +export function checkBlockIsFileBlock< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + block: Block, + editor: BlockNoteEditor +): block is BlockFromConfig { + return ( + (block.type in editor.schema.blockSchema && + editor.schema.blockSchema[block.type].isFileBlock) || + false + ); +} + +export function checkBlockIsFileBlockWithPreview< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + block: Block, + editor: BlockNoteEditor +): block is BlockFromConfig< + FileBlockConfig & { + propSchema: Required; + }, + I, + S +> { + return ( + (block.type in editor.schema.blockSchema && + editor.schema.blockSchema[block.type].isFileBlock && + "showPreview" in editor.schema.blockSchema[block.type].propSchema) || + false + ); +} + export function checkBlockTypeHasDefaultProp< Prop extends keyof typeof defaultProps, I extends InlineContentSchema, diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index a2629b2488..8bde3a6077 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -19,20 +19,25 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema"; +import { FileBlock } from "./FileBlockContent/FileBlockContent"; +import { ImageBlock } from "./ImageBlockContent/ImageBlockContent"; import { Heading } from "./HeadingBlockContent/HeadingBlockContent"; import { BulletListItem } from "./ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItem } from "./ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { Paragraph } from "./ParagraphBlockContent/ParagraphBlockContent"; -import { createFileBlock } from "./FileBlockContent/FileBlockContent"; -import { defaultFileExtensions } from "./FileBlockContent/extensions/defaultFileExtensions"; import { Table } from "./TableBlockContent/TableBlockContent"; +import { VideoBlock } from "./VideoBlockContent/VideoBlockContent"; +import { AudioBlock } from "./AudioBlockContent/AudioBlockContent"; export const defaultBlockSpecs = { paragraph: Paragraph, heading: Heading, bulletListItem: BulletListItem, numberedListItem: NumberedListItem, - file: createFileBlock(defaultFileExtensions), + file: FileBlock, + image: ImageBlock, + video: VideoBlock, + audio: AudioBlock, table: Table, } satisfies BlockSpecs; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 98cbe0f71c..8af8254973 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -237,11 +237,8 @@ NESTED BLOCKS /* FILES */ -[data-content-type="file"] { +.bn-file-block-content-wrapper { cursor: pointer; -} - -[data-content-type="file"] .bn-file-block-content-wrapper { display: flex; flex-direction: column; justify-content: stretch; @@ -249,11 +246,11 @@ NESTED BLOCKS width: 100%; } -[data-content-type="file"]:not([data-url]) { +[data-file-block]:not([data-url]) { width: 100%; } -[data-content-type="file"] .bn-add-file-button { +.bn-add-file-button { display: flex; flex-direction: row; align-items: center; @@ -265,42 +262,42 @@ NESTED BLOCKS width: 100%; } -[data-content-type="file"] .bn-add-file-button:hover { +.bn-add-file-button:hover { background-color: gainsboro; } -[data-content-type="file"] .bn-add-file-button-icon { +.bn-add-file-button-icon { width: 24px; height: 24px; } -[data-content-type="file"] .bn-add-file-button-text { +.bn-add-file-button-text { color: black; } -[data-content-type="file"] .bn-file-and-caption-wrapper { +.bn-file-and-caption-wrapper { display: flex; flex-direction: column; border-radius: 4px; } -[data-content-type="file"] .bn-file-default-preview { +.bn-file-default-preview { align-items: center; display: flex; flex-direction: row; gap: 4px; } -[data-content-type="file"] .bn-file-default-preview-icon { +.bn-file-default-preview-icon { width: 24px; height: 24px; } -[data-content-type="file"] .bn-file-default-preview-name { +.bn-file-default-preview-name { /*font-size: 0.8em;*/ } -[data-content-type="file"] .bn-visual-media-wrapper { +.bn-visual-media-wrapper { display: flex; flex-direction: row; align-items: center; @@ -308,12 +305,12 @@ NESTED BLOCKS width: fit-content; } -[data-content-type="file"] .bn-visual-media { +.bn-visual-media { border-radius: 4px; max-width: 100%; } -[data-content-type="file"] .bn-visual-media-resize-handle { +.bn-visual-media-resize-handle { position: absolute; width: 8px; height: 30px; @@ -323,16 +320,16 @@ NESTED BLOCKS cursor: ew-resize; } -[data-content-type="file"][data-file-type="audio"], [data-content-type="file"] .bn-audio { +[data-content-type="audio"], .bn-audio { width: 100%; } -[data-content-type="file"] .bn-file-caption { +.bn-file-caption { font-size: 0.8em; padding-block: 4px; } -[data-content-type="file"] .bn-file-caption:empty { +.bn-file-caption:empty { padding-block: 0; } diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index fb4a254554..eb41dddcdd 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -181,14 +181,11 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("file", editor)) { + if (checkDefaultBlockTypeInSchema("image", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { - type: "file", - props: { - fileType: "image", - }, + type: "image", }); // Immediately open the file toolbar @@ -201,13 +198,13 @@ export function getDefaultSlashMenuItems< key: "image", ...editor.dictionary.slash_menu.image, }); + } + + if (checkDefaultBlockTypeInSchema("video", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { - type: "file", - props: { - fileType: "video", - }, + type: "video", }); // Immediately open the file toolbar @@ -220,13 +217,13 @@ export function getDefaultSlashMenuItems< key: "video", ...editor.dictionary.slash_menu.video, }); + } + + if (checkDefaultBlockTypeInSchema("audio", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { - type: "file", - props: { - fileType: "audio", - }, + type: "audio", }); // Immediately open the file toolbar @@ -239,6 +236,9 @@ export function getDefaultSlashMenuItems< key: "audio", ...editor.dictionary.slash_menu.audio, }); + } + + if (checkDefaultBlockTypeInSchema("file", editor)) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -252,7 +252,7 @@ export function getDefaultSlashMenuItems< }) ); }, - key: "file", + key: "image", ...editor.dictionary.slash_menu.file, }); } diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 511f973c1b..1a48f18a40 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -103,9 +103,19 @@ export const en = { bulletListItem: "List", numberedListItem: "List", }, - file: { - button_add_text: "Add", - button_default_file_type_text: "file", + file_blocks: { + image: { + add_button_text: "Add image", + }, + video: { + add_button_text: "Add video", + }, + audio: { + add_button_text: "Add audio", + }, + file: { + add_button_text: "Add file", + }, }, // from react package: side_menu: { diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 1b18bb823f..0ac4b31c88 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -107,9 +107,19 @@ export const fr: Dictionary = { numberedListItem: "Liste", }, // TODO - file: { - button_add_text: "Add", - button_default_file_type_text: "file", + file_blocks: { + image: { + add_button_text: "Add image", + }, + video: { + add_button_text: "Add video", + }, + audio: { + add_button_text: "Add audio", + }, + file: { + add_button_text: "Add file", + }, }, // from react package: side_menu: { diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index 3f0e40957f..2da253e7ba 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -107,9 +107,19 @@ export const nl: Dictionary = { numberedListItem: "Lijst", }, // TODO - file: { - button_add_text: "Add", - button_default_file_type_text: "file", + file_blocks: { + image: { + add_button_text: "Add image", + }, + video: { + add_button_text: "Add video", + }, + audio: { + add_button_text: "Add audio", + }, + file: { + add_button_text: "Add file", + }, }, // from react package: side_menu: { diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index d5d2c8b607..80ce26fb81 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -122,9 +122,19 @@ export const zh: Dictionary = { numberedListItem: "列表", }, // TODO - file: { - button_add_text: "Add", - button_default_file_type_text: "file", + file_blocks: { + image: { + add_button_text: "Add image", + }, + video: { + add_button_text: "Add video", + }, + audio: { + add_button_text: "Add audio", + }, + file: { + add_button_text: "Add file", + }, }, // from react package: side_menu: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bc7a6323cc..88b0f6186a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,15 +2,13 @@ import * as locales from "./i18n/locales"; export * from "./api/exporters/html/externalHTMLExporter"; export * from "./api/exporters/html/internalHTMLSerializer"; export * from "./api/testUtil"; -export * from "./blocks/FileBlockContent/fileBlockConfig"; -export * from "./blocks/FileBlockContent/fileBlockImplementation"; -export * from "./blocks/FileBlockContent/fileBlockExtension"; export * from "./blocks/FileBlockContent/FileBlockContent"; +export * from "./blocks/ImageBlockContent/ImageBlockContent"; +export * from "./blocks/VideoBlockContent/VideoBlockContent"; +export * from "./blocks/AudioBlockContent/AudioBlockContent"; + +export * from "./blocks/FileBlockContent/fileBlockHelpers"; export * from "./blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY"; -export * from "./blocks/FileBlockContent/extensions/imageFileExtension"; -export * from "./blocks/FileBlockContent/extensions/videoFileExtension"; -export * from "./blocks/FileBlockContent/extensions/audioFileExtension"; -export * from "./blocks/FileBlockContent/extensions/defaultFileExtensions"; export * from "./blocks/defaultBlockTypeGuards"; export * from "./blocks/defaultBlocks"; export * from "./blocks/defaultProps"; @@ -19,8 +17,8 @@ export * from "./editor/BlockNoteExtensions"; export * from "./editor/BlockNoteSchema"; export * from "./editor/selectionTypes"; export * from "./extensions-shared/UiElementPosition"; -export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/FilePanel/FilePanelPlugin"; +export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/LinkToolbar/LinkToolbarPlugin"; export * from "./extensions/SideMenu/SideMenuPlugin"; export * from "./extensions/SuggestionMenu/DefaultSuggestionItem"; @@ -37,3 +35,4 @@ export * from "./extensions/UniqueID/UniqueID"; export * from "./i18n/dictionary"; export { UnreachableCaseError, assertEmpty } from "./util/typescript"; export { locales }; +export { parseImageElement } from "./blocks/ImageBlockContent/imageBlockHelpers"; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index eee2ac6788..248445a4c2 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -193,6 +193,7 @@ export function createBlockSpec< block.type, block.props, blockConfig.propSchema, + blockConfig.isFileBlock, blockContentDOMAttributes ); }, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index 7ab8b1d4c5..9e5722e6a8 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -139,6 +139,7 @@ export function wrapInBlockStructure< blockType: BType, blockProps: Props, propSchema: PSchema, + isFileBlock = false, domAttributes?: Record ): { dom: HTMLElement; @@ -171,6 +172,10 @@ export function wrapInBlockStructure< blockContent.setAttribute(camelToDataKebab(prop), value); } } + // Adds file block attribute + if (isFileBlock) { + blockContent.setAttribute("data-file-block", ""); + } blockContent.appendChild(element.dom); diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 66d073655d..b2f2881216 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -21,14 +21,44 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; -// BlockConfig contains the "schema" info about a Block type -// i.e. what props it supports, what content it supports, etc. -export type BlockConfig = { +export type FileBlockConfig = { type: string; - readonly propSchema: PropSchema; - content: "inline" | "none" | "table"; + // TODO: The `PropSchema & ` breaks typing for `parse` function + readonly propSchema: PropSchema & { + url: { + default: ""; + }; + caption: { + default: ""; + }; + name: { + default: ""; + }; + // Whether to show the file preview or the name only. + showPreview?: { + default: boolean; + }; + // File preview width in px. + previewWidth?: { + default: number; + }; + }; + content: "none"; + isFileBlock: true; + // TODO: add "accept" mime types here }; +// BlockConfig contains the "schema" info about a Block type +// i.e. what props it supports, what content it supports, etc. +export type BlockConfig = + | { + type: string; + readonly propSchema: PropSchema; + content: "inline" | "none" | "table"; + isFileBlock?: false; + } + | FileBlockConfig; + // Block implementation contains the "implementation" info about a Block // such as the functions / Nodes required to render and / or serialize it export type TiptapBlockImplementation< diff --git a/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx b/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx new file mode 100644 index 0000000000..1decbe204e --- /dev/null +++ b/packages/react/src/blocks/AudioBlockContent/AudioBlockContent.tsx @@ -0,0 +1,79 @@ +import { FileBlockConfig, audioBlockConfig, audioParse } from "@blocknote/core"; +import { RiVolumeUpFill } from "react-icons/ri"; + +import { + createReactBlockSpec, + ReactCustomBlockRenderProps, +} from "../../schema/ReactBlockSpec"; +import { + FileAndCaptionWrapper, + AddFileButton, + DefaultFilePreview, + FigureWithCaption, +} from "../FileBlockContent/fileBlockHelpers"; + +export const AudioPreview = ( + props: Omit< + ReactCustomBlockRenderProps, + "contentRef" + > +) => ( +