From 3eeb9016cbd034f18c8eecc6f1b834d2eae646bb Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 3 Jul 2026 14:53:37 +0200 Subject: [PATCH] feat: reusable React API for source blocks with previews Extracts the math block's React popup UI into reusable components and hooks in @blocknote/react: the PreviewWithSourcePopup shell, the SourceBlockWithPreview/SourceInlineContentWithPreview wrappers, and the useSourceBlockPreviewPopup/useSourceInlineContentPreviewPopup hooks. Moves the generic SourceInlineContentWithPreviewExtension to core after splitting its math-specific input rules into a new MathInlineInputRulesExtension. Also fixes exported inline math HTML rendering as selected with an open popup, and reads getPos() fresh in popup actions instead of capturing it at render time. Co-Authored-By: Claude Fable 5 --- ...SourceInlineContentWithPreviewExtension.ts | 32 +---- packages/core/src/blocks/index.ts | 1 + .../render/MathBlockPreviewWithPopup.tsx | 98 ++------------- packages/math-block/src/index.ts | 3 +- .../MathInlineInputRulesExtension.ts | 42 +++++++ .../createReactMathInlineContentSpec.tsx | 6 +- .../render/MathInlinePreviewWithPopup.tsx | 118 ++---------------- .../SourceWithPreview}/AddSourceButton.tsx | 2 +- .../PreviewWithSourcePopup.tsx | 90 +++++++++++++ .../SourceWithPreview/SourcePreviewPopup.ts | 25 ++++ .../block/SourceBlockWithPreview.tsx | 83 ++++++++++++ .../block/useSourceBlockPreviewPopup.ts | 53 ++++++++ .../SourceInlineContentWithPreview.tsx | 91 ++++++++++++++ .../useSourceInlineContentPreviewPopup.ts | 94 ++++++++++++++ packages/react/src/index.ts | 7 ++ .../core/schema/__snapshots__/blocks.json | 1 + .../schema/__snapshots__/inlinecontent.json | 2 + .../blocknoteHTML/inlineMath/basic.html | 5 +- 18 files changed, 523 insertions(+), 230 deletions(-) rename packages/{math-block/src/inlineContent => core/src/blocks/Code/helpers/extensions}/SourceInlineContentWithPreviewExtension.ts (82%) create mode 100644 packages/math-block/src/inlineContent/MathInlineInputRulesExtension.ts rename packages/{math-block/src/shared/react/render => react/src/blocks/SourceWithPreview}/AddSourceButton.tsx (88%) create mode 100644 packages/react/src/blocks/SourceWithPreview/PreviewWithSourcePopup.tsx create mode 100644 packages/react/src/blocks/SourceWithPreview/SourcePreviewPopup.ts create mode 100644 packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx create mode 100644 packages/react/src/blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.ts create mode 100644 packages/react/src/blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.tsx create mode 100644 packages/react/src/blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.ts diff --git a/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/SourceInlineContentWithPreviewExtension.ts similarity index 82% rename from packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts rename to packages/core/src/blocks/Code/helpers/extensions/SourceInlineContentWithPreviewExtension.ts index d9ef2ce736..04951e9cd7 100644 --- a/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts +++ b/packages/core/src/blocks/Code/helpers/extensions/SourceInlineContentWithPreviewExtension.ts @@ -1,10 +1,11 @@ -import { BlockNoteEditor, createExtension, createStore } from "@blocknote/core"; -import { - InputRule, - inputRules as inputRulesPlugin, -} from "@handlewithcare/prosemirror-inputrules"; import { Selection } from "prosemirror-state"; +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { + createExtension, + createStore, +} from "../../../../editor/BlockNoteExtension.js"; + /** * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. Drives * the source popup for inline content with a preview. @@ -71,27 +72,6 @@ export const SourceInlineContentWithPreviewExtension = createExtension( ArrowUp: moveSelectionOut("before"), ArrowDown: moveSelectionOut("after"), }, - // Cannot use `inputRules` field as it only allows for converting matched content to blocks. - prosemirrorPlugins: [ - inputRulesPlugin({ - rules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map( - (find) => - new InputRule(find, (state, match, start, end) => { - const source = match[1]?.trim(); - const nodeType = state.schema.nodes[inlineContentType]; - if (!source || !nodeType) { - return null; - } - - return state.tr.replaceRangeWith( - start, - end, - nodeType.create(null, state.schema.text(source)), - ); - }), - ), - }), - ], mount: ({ dom, signal }) => { // The popup is open exactly when the selection is inside the inline // content, so we just track which inline content (if any) holds it. diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts index ada4918c16..7c54322345 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -18,6 +18,7 @@ export * from "./Video/block.js"; export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH } from "./Table/TableExtension.js"; export * from "./Code/helpers/extensions/CodeKeyboardShortcutsExtension.js"; export * from "./Code/helpers/extensions/SourceBlockWithPreviewExtension.js"; +export * from "./Code/helpers/extensions/SourceInlineContentWithPreviewExtension.js"; export * from "./Code/helpers/parse/parsePreCode.js"; export * from "./Code/helpers/render/createSourceBlock.js"; export * from "./Code/helpers/render/createSourceBlockWithPreview.js"; diff --git a/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx index 0043e89a83..d593898e1d 100644 --- a/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx +++ b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx @@ -1,104 +1,26 @@ -import { SourceBlockWithPreviewExtension } from "@blocknote/core"; import { ReactCustomBlockRenderProps, - useExtension, - useExtensionState, + SourceBlockWithPreview, } from "@blocknote/react"; -import { MouseEvent } from "react"; import { MathBlockConfig } from "../../createMathBlockConfig.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { AddSourceButton } from "../../../shared/react/render/AddSourceButton.js"; import { useLatexToMathMLString } from "../../../shared/react/render/useLatexToMathML.js"; export const MathBlockPreviewWithPopup = ( props: ReactCustomBlockRenderProps, ) => { - const { block, editor, contentRef } = props; - - const source = getMathPlainTextContent(block.content); - - const { store } = useExtension(SourceBlockWithPreviewExtension, { editor }); - const popupOpen = useExtensionState(SourceBlockWithPreviewExtension, { - editor, - selector: (state) => state.popupOpen === block.id, - }); - const selected = useExtensionState(SourceBlockWithPreviewExtension, { - editor, - selector: (state) => state.selected === block.id, - }); - + const source = getMathPlainTextContent(props.block.content); const { mathMLString, error } = useLatexToMathMLString(source); - // Opens the popup when clicking the preview. - const handlePreviewMouseDown = (event: MouseEvent) => { - if (!editor.isEditable) { - return; - } - - store.setState((state) => ({ ...state, popupOpen: block.id })); - - event.preventDefault(); - event.stopPropagation(); - - editor.setTextCursorPosition(block.id, "end"); - editor.focus(); - }; - - // Closes the popup when clicking the "OK" button. - const handleOkButtonMouseDown = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - store.setState((state) => ({ ...state, popupOpen: undefined })); - }; - return ( -
-
- {source.length > 0 ? ( - - ) : ( - - )} -
-
-
-
-            
-          
-
- -
-
-
- {error} -
-
-
+ } + error={error} + /> ); }; diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts index 72af447ef3..a6a443baf3 100644 --- a/packages/math-block/src/index.ts +++ b/packages/math-block/src/index.ts @@ -8,12 +8,11 @@ export * from "./block/vanilla/createMathBlockSpec.js"; export * from "./block/vanilla/render/createMathBlockPreviewWithPopup.js"; export * from "./block/vanilla/toExternalHTML/createBlockMathMLElement.js"; export * from "./inlineContent/mathInlineContentConfig.js"; -export * from "./inlineContent/SourceInlineContentWithPreviewExtension.js"; +export * from "./inlineContent/MathInlineInputRulesExtension.js"; export * from "./inlineContent/react/createReactMathInlineContentSpec.js"; export * from "./inlineContent/react/render/MathInlinePreviewWithPopup.js"; export * from "./inlineContent/react/toExternalHTML/InlineMathMLElement.js"; export * from "./shared/getMathPlainTextContent.js"; export * from "./shared/latexToHTMLString.js"; -export * from "./shared/react/render/AddSourceButton.js"; export * from "./shared/react/render/useLatexToMathML.js"; export * from "./shared/vanilla/toExternalHTML/latexToMathMLElement.js"; diff --git a/packages/math-block/src/inlineContent/MathInlineInputRulesExtension.ts b/packages/math-block/src/inlineContent/MathInlineInputRulesExtension.ts new file mode 100644 index 0000000000..b78d042e82 --- /dev/null +++ b/packages/math-block/src/inlineContent/MathInlineInputRulesExtension.ts @@ -0,0 +1,42 @@ +import { createExtension } from "@blocknote/core"; +import { + InputRule, + inputRules as inputRulesPlugin, +} from "@handlewithcare/prosemirror-inputrules"; + +import { mathInlineContentConfig } from "./mathInlineContentConfig.js"; + +/** + * Converts text wrapped in LaTeX inline-math delimiters into inline math + * content as it's typed: + * - `$...$` (TeX inline math) + * - `\(...\)` (LaTeX inline math) + * + * The delimiters are removed and the enclosed source becomes the inline math's + * content. + */ +export const MathInlineInputRulesExtension = createExtension({ + key: "math-inline-input-rules", + // Cannot use the `inputRules` field as it only allows for converting matched + // content to blocks. + prosemirrorPlugins: [ + inputRulesPlugin({ + rules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map( + (find) => + new InputRule(find, (state, match, start, end) => { + const source = match[1]?.trim(); + const nodeType = state.schema.nodes[mathInlineContentConfig.type]; + if (!source || !nodeType) { + return null; + } + + return state.tr.replaceRangeWith( + start, + end, + nodeType.create(null, state.schema.text(source)), + ); + }), + ), + }), + ], +}); diff --git a/packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx b/packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx index 61b009ad8c..e39d817fe7 100644 --- a/packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx +++ b/packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx @@ -1,6 +1,7 @@ +import { SourceInlineContentWithPreviewExtension } from "@blocknote/core"; import { createReactInlineContentSpec } from "@blocknote/react"; -import { SourceInlineContentWithPreviewExtension } from "../SourceInlineContentWithPreviewExtension.js"; +import { MathInlineInputRulesExtension } from "../MathInlineInputRulesExtension.js"; import { mathInlineContentConfig } from "../mathInlineContentConfig.js"; import { MathInlinePreviewWithPopup } from "./render/MathInlinePreviewWithPopup.js"; import { InlineMathMLElement } from "./toExternalHTML/InlineMathMLElement.js"; @@ -23,7 +24,8 @@ export const createReactInlineMathSpec = () => [ SourceInlineContentWithPreviewExtension({ key: INLINE_MATH_PREVIEW_KEY, - inlineContentType: "inlineMath", + inlineContentType: mathInlineContentConfig.type, }), + MathInlineInputRulesExtension, ], ); diff --git a/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx b/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx index 33f7a9f9d2..91a06507bd 100644 --- a/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx +++ b/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx @@ -1,16 +1,11 @@ import { StyleSchema } from "@blocknote/core"; import { ReactCustomInlineContentRenderProps, - useExtension, - useExtensionState, + SourceInlineContentWithPreview, } from "@blocknote/react"; -import { TextSelection } from "prosemirror-state"; -import { MouseEvent } from "react"; import { MathInlineContentConfig } from "../../mathInlineContentConfig.js"; -import { SourceInlineContentWithPreviewExtension } from "../../SourceInlineContentWithPreviewExtension.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { AddSourceButton } from "../../../shared/react/render/AddSourceButton.js"; import { useLatexToMathMLString } from "../../../shared/react/render/useLatexToMathML.js"; export const MathInlinePreviewWithPopup = ( @@ -19,109 +14,18 @@ export const MathInlinePreviewWithPopup = ( StyleSchema >, ) => { - const { inlineContent, editor, contentRef, node, getPos } = props; - const pos = getPos(); - - const source = getMathPlainTextContent(inlineContent.content); - - const { store } = useExtension(SourceInlineContentWithPreviewExtension, { - editor, - }); - // The popup is open exactly when the selection is inside this inline content, - // which is the same condition that marks it as selected. - const selected = useExtensionState(SourceInlineContentWithPreviewExtension, { - editor, - selector: (state) => state.selected === pos, - }); - + const source = getMathPlainTextContent(props.inlineContent.content); const { mathMLString, error } = useLatexToMathMLString(source, true); - // Opens the popup when clicking the preview. - const handlePreviewMouseDown = (event: MouseEvent) => { - if (!editor.isEditable || !pos) { - return; - } - - store.setState({ selected: pos }); - - event.preventDefault(); - event.stopPropagation(); - - const view = editor.prosemirrorView!; - view.dispatch( - view.state.tr.setSelection( - TextSelection.create(view.state.tr.doc, pos + node.nodeSize - 1), - ), - ); - editor.focus(); - }; - - // Closes the popup by moving the selection to just after the inline content. - const handleOkButtonMouseDown = (event: MouseEvent) => { - if (!editor.isEditable || !pos) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const view = editor.prosemirrorView!; - view.dispatch( - view.state.tr.setSelection( - TextSelection.create(view.state.tr.doc, pos + node.nodeSize), - ), - ); - editor.focus(); - }; - return ( - - - {source.length > 0 ? ( - - ) : ( - - )} - -
-
-
-            
-          
-
- -
-
-
- {error} -
-
-
+ } + error={error} + /> ); }; diff --git a/packages/math-block/src/shared/react/render/AddSourceButton.tsx b/packages/react/src/blocks/SourceWithPreview/AddSourceButton.tsx similarity index 88% rename from packages/math-block/src/shared/react/render/AddSourceButton.tsx rename to packages/react/src/blocks/SourceWithPreview/AddSourceButton.tsx index 90b38f11aa..8a128657bc 100644 --- a/packages/math-block/src/shared/react/render/AddSourceButton.tsx +++ b/packages/react/src/blocks/SourceWithPreview/AddSourceButton.tsx @@ -1,4 +1,4 @@ -// Shown in place of the preview when the math content has no source yet. +// Shown in place of the preview when the source content is empty. export const AddSourceButton = (props: { text: string }) => (
diff --git a/packages/react/src/blocks/SourceWithPreview/PreviewWithSourcePopup.tsx b/packages/react/src/blocks/SourceWithPreview/PreviewWithSourcePopup.tsx new file mode 100644 index 0000000000..1474e8652a --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/PreviewWithSourcePopup.tsx @@ -0,0 +1,90 @@ +import { MouseEventHandler, ReactNode } from "react"; + +export type PreviewWithSourcePopupProps = { + /** + * Renders with `span` wrappers so the shell can be used for inline content. + * Defaults to `false`, which renders with `div` wrappers for blocks. + */ + inline?: boolean; + /** + * Whether the source popup is shown. + */ + popupOpen: boolean; + /** + * Whether the preview is highlighted as selected. + */ + selected: boolean; + /** + * The rendered preview (e.g. a formula or diagram). Rendered inside a + * non-editable container that opens the source popup on click. + */ + preview: ReactNode; + /** + * Error from rendering the preview, shown below the source in the popup. + * Accepts arbitrary elements, so actions (e.g. a button that fixes the + * source) can be rendered alongside the error message. + */ + error?: ReactNode; + /** + * Ref for the element holding the editable source content. + */ + contentRef: (node: HTMLElement | null) => void; + onPreviewMouseDown?: MouseEventHandler; + onOkMouseDown?: MouseEventHandler; +}; + +/** + * Presentational shell for blocks & inline content that render a preview of + * their source content (e.g. math or diagrams): shows the preview, with the + * editable source in a popup. Purely visual - opening/closing the popup and + * keyboard handling are driven by the caller, typically via + * `useSourceBlockPreviewPopup`/`useSourceInlineContentPreviewPopup` (see + * `SourceBlockWithPreview` and `SourceInlineContentWithPreview`). + */ +export const PreviewWithSourcePopup = (props: PreviewWithSourcePopupProps) => { + const Wrapper = props.inline ? "span" : "div"; + const PreviewContainer = props.inline ? "span" : "div"; + + return ( + + + {props.preview} + +
+
+
+            
+          
+
+ +
+
+
+ {props.error} +
+
+
+ ); +}; diff --git a/packages/react/src/blocks/SourceWithPreview/SourcePreviewPopup.ts b/packages/react/src/blocks/SourceWithPreview/SourcePreviewPopup.ts new file mode 100644 index 0000000000..7f78539376 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/SourcePreviewPopup.ts @@ -0,0 +1,25 @@ +/** + * State of & actions on the source popup of a block or inline content with a + * preview. Returned by `useSourceBlockPreviewPopup` and + * `useSourceInlineContentPreviewPopup`. + */ +export type SourcePreviewPopup = { + /** + * Whether the source popup is open. + */ + isOpen: boolean; + /** + * Whether the block/inline content is selected, i.e. whether its preview is + * highlighted. + */ + isSelected: boolean; + /** + * Opens the popup, moves the cursor into the source, and focuses the + * editor. Does nothing when the editor isn't editable. + */ + open: () => void; + /** + * Closes the popup. + */ + close: () => void; +}; diff --git a/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx new file mode 100644 index 0000000000..5b92badd8d --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx @@ -0,0 +1,83 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { MouseEvent, ReactNode } from "react"; + +import { AddSourceButton } from "../AddSourceButton.js"; +import { PreviewWithSourcePopup } from "../PreviewWithSourcePopup.js"; +import { useSourceBlockPreviewPopup } from "./useSourceBlockPreviewPopup.js"; + +export type SourceBlockWithPreviewProps = { + // Only the ID is needed, so any block type is accepted. + block: { id: string }; + editor: BlockNoteEditor; + contentRef: (node: HTMLElement | null) => void; + /** + * The block's source content as plain text. When empty, an "add source" + * button is shown in place of the preview. + */ + source: string; + /** + * The rendered preview (e.g. a formula or diagram). + */ + preview: ReactNode; + /** + * Error from rendering the preview, shown below the source in the popup. + * Accepts arbitrary elements, so actions (e.g. a button that fixes the + * source) can be rendered alongside the error message. + */ + error?: ReactNode; +}; + +/** + * Renders a block as a preview of its source content, with the editable + * source in a popup - the React counterpart of `createSourceBlockWithPreview` + * from `@blocknote/core`. The popup is controlled via + * {@link useSourceBlockPreviewPopup}, so the block's + * `SourceBlockWithPreviewExtension` must be registered with the block spec. + * The caller only provides the preview itself, making this the base for + * custom blocks rendered from source code (math, diagrams, etc). + */ +export const SourceBlockWithPreview = (props: SourceBlockWithPreviewProps) => { + const { block, editor, contentRef, source, preview, error } = props; + + const popup = useSourceBlockPreviewPopup({ editor, block }); + + // Opens the popup when clicking the preview. + const handlePreviewMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + popup.open(); + }; + + // Closes the popup when clicking the "OK" button. + const handleOkButtonMouseDown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + popup.close(); + }; + + return ( + 0 ? ( + preview + ) : ( + + ) + } + error={error} + contentRef={contentRef} + onPreviewMouseDown={handlePreviewMouseDown} + onOkMouseDown={handleOkButtonMouseDown} + /> + ); +}; diff --git a/packages/react/src/blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.ts b/packages/react/src/blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.ts new file mode 100644 index 0000000000..06ee02828c --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.ts @@ -0,0 +1,53 @@ +import { + BlockNoteEditor, + SourceBlockWithPreviewExtension, +} from "@blocknote/core"; + +import { + useExtension, + useExtensionState, +} from "../../../hooks/useExtension.js"; +import type { SourcePreviewPopup } from "../SourcePreviewPopup.js"; + +/** + * Controls the source popup of a block with a preview, e.g. to open it from a + * custom preview element. A block's popup is toggled explicitly - `open` and + * `close` set a flag, separate from the selection. The popup state itself is + * managed by the `SourceBlockWithPreviewExtension` registered with the block + * spec (so it survives node view re-creation and stays in sync with the + * keyboard handling) - this hook is the React API to it. + */ +export const useSourceBlockPreviewPopup = (props: { + editor: BlockNoteEditor; + block: { id: string }; +}): SourcePreviewPopup => { + const { editor, block } = props; + + const { store } = useExtension(SourceBlockWithPreviewExtension, { editor }); + + const isOpen = useExtensionState(SourceBlockWithPreviewExtension, { + editor, + selector: (state) => state.popupOpen === block.id, + }); + const isSelected = useExtensionState(SourceBlockWithPreviewExtension, { + editor, + selector: (state) => state.selected === block.id, + }); + + // Opens the popup with the cursor at the end of the source. + const open = () => { + if (!editor.isEditable) { + return; + } + + store.setState((state) => ({ ...state, popupOpen: block.id })); + editor.setTextCursorPosition(block.id, "end"); + editor.focus(); + }; + + const close = () => { + store.setState((state) => ({ ...state, popupOpen: undefined })); + }; + + return { isOpen, isSelected, open, close }; +}; diff --git a/packages/react/src/blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.tsx new file mode 100644 index 0000000000..e963f1d210 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.tsx @@ -0,0 +1,91 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { MouseEvent, ReactNode } from "react"; + +import { AddSourceButton } from "../AddSourceButton.js"; +import { PreviewWithSourcePopup } from "../PreviewWithSourcePopup.js"; +import { useSourceInlineContentPreviewPopup } from "./useSourceInlineContentPreviewPopup.js"; + +export type SourceInlineContentWithPreviewProps = { + editor: BlockNoteEditor; + contentRef: (node: HTMLElement | null) => void; + // Only the size is needed, so any inline content node is accepted. + node: { nodeSize: number }; + getPos: () => number | undefined; + /** + * The inline content's source as plain text. When empty, an "add source" + * button is shown in place of the preview. + */ + source: string; + /** + * The rendered preview (e.g. a formula or diagram). + */ + preview: ReactNode; + /** + * Error from rendering the preview, shown below the source in the popup. + * Accepts arbitrary elements, so actions (e.g. a button that fixes the + * source) can be rendered alongside the error message. + */ + error?: ReactNode; +}; + +/** + * Renders inline content as a preview of its source, with the editable source + * in a popup - the inline counterpart of `SourceBlockWithPreview`. The popup + * is controlled via {@link useSourceInlineContentPreviewPopup}, so the inline + * content's `SourceInlineContentWithPreviewExtension` must be registered with + * the inline content spec. Unlike blocks, the popup is open exactly while the + * selection is inside the inline content's source, which is the same + * condition that marks it as selected. + */ +export const SourceInlineContentWithPreview = ( + props: SourceInlineContentWithPreviewProps, +) => { + const { editor, contentRef, node, getPos, source, preview, error } = props; + + const popup = useSourceInlineContentPreviewPopup({ editor, node, getPos }); + + // Opens the popup when clicking the preview. + const handlePreviewMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + popup.open(); + }; + + // Closes the popup when clicking the "OK" button. + const handleOkButtonMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + popup.close(); + }; + + return ( + 0 ? ( + preview + ) : ( + + ) + } + error={error} + contentRef={contentRef} + onPreviewMouseDown={handlePreviewMouseDown} + onOkMouseDown={handleOkButtonMouseDown} + /> + ); +}; diff --git a/packages/react/src/blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.ts b/packages/react/src/blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.ts new file mode 100644 index 0000000000..30cc81b7a1 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.ts @@ -0,0 +1,94 @@ +import { + BlockNoteEditor, + SourceInlineContentWithPreviewExtension, +} from "@blocknote/core"; +import { TextSelection } from "@tiptap/pm/state"; + +import { + useExtension, + useExtensionState, +} from "../../../hooks/useExtension.js"; +import type { SourcePreviewPopup } from "../SourcePreviewPopup.js"; + +/** + * Controls the source popup of inline content with a preview, e.g. to open it + * from a custom preview element. Unlike a block's, the popup is open exactly + * while the selection is inside the inline content's source - so `isOpen` and + * `isSelected` always agree, and `open`/`close` work by moving the selection + * into/out of the source. The popup state itself is managed by the + * `SourceInlineContentWithPreviewExtension` registered with the inline + * content spec (so it survives node view re-creation and stays in sync with + * the keyboard handling) - this hook is the React API to it. + */ +export const useSourceInlineContentPreviewPopup = (props: { + editor: BlockNoteEditor; + node: { nodeSize: number }; + getPos: () => number | undefined; +}): SourcePreviewPopup => { + const { editor, node, getPos } = props; + + const { store } = useExtension(SourceInlineContentWithPreviewExtension, { + editor, + }); + + // `getPos` is called fresh in the selector and actions rather than captured + // once per render, as the inline content's position can shift without its + // node view re-rendering. The `undefined` guard matters when rendered + // outside the editor (i.e. serialized to HTML): there `getPos()` returns + // `undefined`, which must not match the store's initial `undefined` state. + const isSelected = useExtensionState( + SourceInlineContentWithPreviewExtension, + { + editor, + selector: (state) => + state.selected !== undefined && state.selected === getPos(), + }, + ); + + // Opens the popup by moving the selection to the end of the source. + const open = () => { + if (!editor.isEditable) { + return; + } + + const pos = getPos(); + if (!pos) { + return; + } + + store.setState({ selected: pos }); + + const view = editor.prosemirrorView!; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.tr.doc, pos + node.nodeSize - 1), + ), + ); + editor.focus(); + }; + + // Closes the popup by moving the selection to just after the inline + // content. + const close = () => { + if (!editor.isEditable) { + return; + } + + const pos = getPos(); + if (!pos) { + return; + } + + const view = editor.prosemirrorView!; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.tr.doc, pos + node.nodeSize), + ), + ); + editor.focus(); + }; + + // The popup is open exactly when the selection is inside the source, which + // is the same condition that marks it as selected. + return { isOpen: isSelected, isSelected, open, close }; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6beb5a7082..bb25cf3260 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -16,6 +16,13 @@ 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/SourceWithPreview/AddSourceButton.js"; +export * from "./blocks/SourceWithPreview/PreviewWithSourcePopup.js"; +export * from "./blocks/SourceWithPreview/SourcePreviewPopup.js"; +export * from "./blocks/SourceWithPreview/block/SourceBlockWithPreview.js"; +export * from "./blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.js"; +export * from "./blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.js"; +export * from "./blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.js"; export * from "./blocks/Video/block.js"; export * from "./blocks/ToggleWrapper/ToggleWrapper.js"; diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 4b0e8678a1..1468bc565e 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -128,6 +128,7 @@ }, "extensions": [ [Function], + [Function], ], "implementation": { "meta": { diff --git a/tests/src/unit/core/schema/__snapshots__/inlinecontent.json b/tests/src/unit/core/schema/__snapshots__/inlinecontent.json index c682709782..6013f4791c 100644 --- a/tests/src/unit/core/schema/__snapshots__/inlinecontent.json +++ b/tests/src/unit/core/schema/__snapshots__/inlinecontent.json @@ -13,6 +13,7 @@ }, "type": "mention", }, + "extensions": undefined, "implementation": { "node": null, "parse": [Function], @@ -26,6 +27,7 @@ "propSchema": {}, "type": "tag", }, + "extensions": undefined, "implementation": { "node": null, "parse": [Function], diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/inlineMath/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/inlineMath/basic.html index 0a687bbc9e..4fb2753592 100644 --- a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/inlineMath/basic.html +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/inlineMath/basic.html @@ -11,10 +11,7 @@ data-node-view-wrapper="" style="white-space: normal;" > - +