diff --git a/examples/01-basic/01-minimal/package.json b/examples/01-basic/01-minimal/package.json index 7deed32f95..79bc3beed8 100644 --- a/examples/01-basic/01-minimal/package.json +++ b/examples/01-basic/01-minimal/package.json @@ -15,6 +15,7 @@ "@blocknote/ariakit": "latest", "@blocknote/mantine": "latest", "@blocknote/shadcn": "latest", + "@blocknote/code-block": "latest", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -24,4 +25,4 @@ "@vitejs/plugin-react": "^4.3.1", "vite": "^5.3.4" } -} \ No newline at end of file +} diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index 48e32a2b01..dc718437b1 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -1,6 +1,6 @@ import { BlockSchema, - checkBlockIsFileBlock, + blockHasType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +41,7 @@ export const FileReplaceButton = () => { if ( block === undefined || - !checkBlockIsFileBlock(block, editor) || + !blockHasType(block, editor, { url: "string" }) || !editor.isEditable ) { return null; diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 6a08ae4254..1e73471d23 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -1,9 +1,10 @@ -import { EditorState } from "prosemirror-state"; +import { EditorState, Transaction } from "prosemirror-state"; import { getBlockInfo, getNearestBlockPos, } from "../../../getBlockInfoFromPos.js"; +import { getPmSchema } from "../../../pmUtil.js"; export const splitBlockCommand = ( posInBlock: number, @@ -17,33 +18,41 @@ export const splitBlockCommand = ( state: EditorState; dispatch: ((args?: any) => any) | undefined; }) => { - const nearestBlockContainerPos = getNearestBlockPos(state.doc, posInBlock); - - const info = getBlockInfo(nearestBlockContainerPos); - - if (!info.isBlockContainer) { - throw new Error( - `BlockContainer expected when calling splitBlock, position ${posInBlock}`, - ); - } - - const types = [ - { - type: info.bnBlock.node.type, // always keep blockcontainer type - attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {}, - }, - { - type: keepType - ? info.blockContent.node.type - : state.schema.nodes["paragraph"], - attrs: keepProps ? { ...info.blockContent.node.attrs } : {}, - }, - ]; - if (dispatch) { - state.tr.split(posInBlock, 2, types); + return splitBlockTr(state.tr, posInBlock, keepType, keepProps); } return true; }; }; + +export const splitBlockTr = ( + tr: Transaction, + posInBlock: number, + keepType?: boolean, + keepProps?: boolean, +): boolean => { + const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock); + + const info = getBlockInfo(nearestBlockContainerPos); + + if (!info.isBlockContainer) { + return false; + } + const schema = getPmSchema(tr); + + const types = [ + { + type: info.bnBlock.node.type, // always keep blockcontainer type + attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {}, + }, + { + type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"], + attrs: keepProps ? { ...info.blockContent.node.attrs } : {}, + }, + ]; + + tr.split(posInBlock, 2, types); + + return true; +}; diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index d164dcb762..ce49383b86 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -2,7 +2,6 @@ import { Block, PartialBlock } from "../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; import { BlockSchema, - FileBlockConfig, InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; @@ -106,15 +105,11 @@ export async function handleFileInsertion< event.preventDefault(); - const fileBlockConfigs = Object.values(editor.schema.blockSchema).filter( - (blockConfig) => blockConfig.isFileBlock, - ) as FileBlockConfig[]; - for (let i = 0; i < items.length; i++) { // Gets file block corresponding to MIME type. let fileBlockType = "file"; - for (const fileBlockConfig of fileBlockConfigs) { - for (const mimeType of fileBlockConfig.fileBlockAccept || []) { + for (const fileBlockConfig of Object.values(editor.schema.blockSchema)) { + for (const mimeType of fileBlockConfig.meta?.fileBlockAccept || []) { const isFileExtension = mimeType.startsWith("."); const file = items[i].getAsFile(); diff --git a/packages/core/src/blks/Audio/definition.ts b/packages/core/src/blks/Audio/definition.ts new file mode 100644 index 0000000000..c25b30b090 --- /dev/null +++ b/packages/core/src/blks/Audio/definition.ts @@ -0,0 +1,135 @@ +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/BulletListItem/definition.ts b/packages/core/src/blks/BulletListItem/definition.ts new file mode 100644 index 0000000000..ba6e378f93 --- /dev/null +++ b/packages/core/src/blks/BulletListItem/definition.ts @@ -0,0 +1,103 @@ +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 { + createBlockConfig, + createBlockDefinition, + createBlockNoteExtension, +} from "../../schema/index.js"; +import { handleEnter } from "../utils/listItemEnterHandler.js"; + +const config = createBlockConfig( + () => + ({ + type: "bulletListItem" as const, + propSchema: { + ...defaultProps, + }, + content: "inline", + }) as const, +); + +export const definition = createBlockDefinition(config).implementation( + () => ({ + parse(element) { + if (element.tagName !== "LI") { + return false; + } + + const parent = element.parentElement; + + if (parent === null) { + return false; + } + + if ( + parent.tagName === "UL" || + (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL") + ) { + return {}; + } + + return false; + }, + // As `li` elements can contain multiple paragraphs, we need to merge their contents + // into a single one so that ProseMirror can parse everything correctly. + parseContent: ({ el, schema }) => + getListItemContent(el, schema, "bulletListItem"), + render() { + const div = document.createElement("div"); + // We use a

tag, because for

  • tags we'd need a