Skip to content

Commit 3eeb901

Browse files
YousefEDclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 2ac3ef8 commit 3eeb901

18 files changed

Lines changed: 523 additions & 230 deletions

File tree

packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts renamed to packages/core/src/blocks/Code/helpers/extensions/SourceInlineContentWithPreviewExtension.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { BlockNoteEditor, createExtension, createStore } from "@blocknote/core";
2-
import {
3-
InputRule,
4-
inputRules as inputRulesPlugin,
5-
} from "@handlewithcare/prosemirror-inputrules";
61
import { Selection } from "prosemirror-state";
72

3+
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
4+
import {
5+
createExtension,
6+
createStore,
7+
} from "../../../../editor/BlockNoteExtension.js";
8+
89
/**
910
* Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. Drives
1011
* the source popup for inline content with a preview.
@@ -71,27 +72,6 @@ export const SourceInlineContentWithPreviewExtension = createExtension(
7172
ArrowUp: moveSelectionOut("before"),
7273
ArrowDown: moveSelectionOut("after"),
7374
},
74-
// Cannot use `inputRules` field as it only allows for converting matched content to blocks.
75-
prosemirrorPlugins: [
76-
inputRulesPlugin({
77-
rules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map(
78-
(find) =>
79-
new InputRule(find, (state, match, start, end) => {
80-
const source = match[1]?.trim();
81-
const nodeType = state.schema.nodes[inlineContentType];
82-
if (!source || !nodeType) {
83-
return null;
84-
}
85-
86-
return state.tr.replaceRangeWith(
87-
start,
88-
end,
89-
nodeType.create(null, state.schema.text(source)),
90-
);
91-
}),
92-
),
93-
}),
94-
],
9575
mount: ({ dom, signal }) => {
9676
// The popup is open exactly when the selection is inside the inline
9777
// content, so we just track which inline content (if any) holds it.

packages/core/src/blocks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export * from "./Video/block.js";
1818
export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH } from "./Table/TableExtension.js";
1919
export * from "./Code/helpers/extensions/CodeKeyboardShortcutsExtension.js";
2020
export * from "./Code/helpers/extensions/SourceBlockWithPreviewExtension.js";
21+
export * from "./Code/helpers/extensions/SourceInlineContentWithPreviewExtension.js";
2122
export * from "./Code/helpers/parse/parsePreCode.js";
2223
export * from "./Code/helpers/render/createSourceBlock.js";
2324
export * from "./Code/helpers/render/createSourceBlockWithPreview.js";
Lines changed: 10 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,26 @@
1-
import { SourceBlockWithPreviewExtension } from "@blocknote/core";
21
import {
32
ReactCustomBlockRenderProps,
4-
useExtension,
5-
useExtensionState,
3+
SourceBlockWithPreview,
64
} from "@blocknote/react";
7-
import { MouseEvent } from "react";
85

96
import { MathBlockConfig } from "../../createMathBlockConfig.js";
107
import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js";
11-
import { AddSourceButton } from "../../../shared/react/render/AddSourceButton.js";
128
import { useLatexToMathMLString } from "../../../shared/react/render/useLatexToMathML.js";
139

1410
export const MathBlockPreviewWithPopup = (
1511
props: ReactCustomBlockRenderProps<MathBlockConfig>,
1612
) => {
17-
const { block, editor, contentRef } = props;
18-
19-
const source = getMathPlainTextContent(block.content);
20-
21-
const { store } = useExtension(SourceBlockWithPreviewExtension, { editor });
22-
const popupOpen = useExtensionState(SourceBlockWithPreviewExtension, {
23-
editor,
24-
selector: (state) => state.popupOpen === block.id,
25-
});
26-
const selected = useExtensionState(SourceBlockWithPreviewExtension, {
27-
editor,
28-
selector: (state) => state.selected === block.id,
29-
});
30-
13+
const source = getMathPlainTextContent(props.block.content);
3114
const { mathMLString, error } = useLatexToMathMLString(source);
3215

33-
// Opens the popup when clicking the preview.
34-
const handlePreviewMouseDown = (event: MouseEvent) => {
35-
if (!editor.isEditable) {
36-
return;
37-
}
38-
39-
store.setState((state) => ({ ...state, popupOpen: block.id }));
40-
41-
event.preventDefault();
42-
event.stopPropagation();
43-
44-
editor.setTextCursorPosition(block.id, "end");
45-
editor.focus();
46-
};
47-
48-
// Closes the popup when clicking the "OK" button.
49-
const handleOkButtonMouseDown = (event: MouseEvent) => {
50-
event.preventDefault();
51-
event.stopPropagation();
52-
53-
store.setState((state) => ({ ...state, popupOpen: undefined }));
54-
};
55-
5616
return (
57-
<div
58-
className={
59-
"bn-preview-with-source-popup" +
60-
(selected ? " ProseMirror-selectednode" : "")
61-
}
62-
data-open={popupOpen ? "true" : "false"}
63-
>
64-
<div
65-
className="bn-preview-container"
66-
contentEditable={false}
67-
onMouseDown={handlePreviewMouseDown}
68-
>
69-
{source.length > 0 ? (
70-
<span dangerouslySetInnerHTML={{ __html: mathMLString }} />
71-
) : (
72-
<AddSourceButton
73-
text={editor.dictionary.code_block.add_source_button_text}
74-
/>
75-
)}
76-
</div>
77-
<div className="bn-source-block-popup">
78-
<div className="bn-code-block-source-popup-body">
79-
<pre>
80-
<code ref={contentRef} />
81-
</pre>
82-
<div
83-
className="bn-code-block-source-popup-ok-button-wrapper"
84-
contentEditable={false}
85-
>
86-
<button
87-
className="bn-code-block-source-popup-ok-button"
88-
onMouseDown={handleOkButtonMouseDown}
89-
>
90-
OK
91-
</button>
92-
</div>
93-
</div>
94-
<div
95-
className="bn-code-block-source-error"
96-
contentEditable={false}
97-
style={{ display: error ? "block" : "none" }}
98-
>
99-
{error}
100-
</div>
101-
</div>
102-
</div>
17+
<SourceBlockWithPreview
18+
block={props.block}
19+
editor={props.editor}
20+
contentRef={props.contentRef}
21+
source={source}
22+
preview={<span dangerouslySetInnerHTML={{ __html: mathMLString }} />}
23+
error={error}
24+
/>
10325
);
10426
};

packages/math-block/src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ export * from "./block/vanilla/createMathBlockSpec.js";
88
export * from "./block/vanilla/render/createMathBlockPreviewWithPopup.js";
99
export * from "./block/vanilla/toExternalHTML/createBlockMathMLElement.js";
1010
export * from "./inlineContent/mathInlineContentConfig.js";
11-
export * from "./inlineContent/SourceInlineContentWithPreviewExtension.js";
11+
export * from "./inlineContent/MathInlineInputRulesExtension.js";
1212
export * from "./inlineContent/react/createReactMathInlineContentSpec.js";
1313
export * from "./inlineContent/react/render/MathInlinePreviewWithPopup.js";
1414
export * from "./inlineContent/react/toExternalHTML/InlineMathMLElement.js";
1515
export * from "./shared/getMathPlainTextContent.js";
1616
export * from "./shared/latexToHTMLString.js";
17-
export * from "./shared/react/render/AddSourceButton.js";
1817
export * from "./shared/react/render/useLatexToMathML.js";
1918
export * from "./shared/vanilla/toExternalHTML/latexToMathMLElement.js";
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { createExtension } from "@blocknote/core";
2+
import {
3+
InputRule,
4+
inputRules as inputRulesPlugin,
5+
} from "@handlewithcare/prosemirror-inputrules";
6+
7+
import { mathInlineContentConfig } from "./mathInlineContentConfig.js";
8+
9+
/**
10+
* Converts text wrapped in LaTeX inline-math delimiters into inline math
11+
* content as it's typed:
12+
* - `$...$` (TeX inline math)
13+
* - `\(...\)` (LaTeX inline math)
14+
*
15+
* The delimiters are removed and the enclosed source becomes the inline math's
16+
* content.
17+
*/
18+
export const MathInlineInputRulesExtension = createExtension({
19+
key: "math-inline-input-rules",
20+
// Cannot use the `inputRules` field as it only allows for converting matched
21+
// content to blocks.
22+
prosemirrorPlugins: [
23+
inputRulesPlugin({
24+
rules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map(
25+
(find) =>
26+
new InputRule(find, (state, match, start, end) => {
27+
const source = match[1]?.trim();
28+
const nodeType = state.schema.nodes[mathInlineContentConfig.type];
29+
if (!source || !nodeType) {
30+
return null;
31+
}
32+
33+
return state.tr.replaceRangeWith(
34+
start,
35+
end,
36+
nodeType.create(null, state.schema.text(source)),
37+
);
38+
}),
39+
),
40+
}),
41+
],
42+
});

packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { SourceInlineContentWithPreviewExtension } from "@blocknote/core";
12
import { createReactInlineContentSpec } from "@blocknote/react";
23

3-
import { SourceInlineContentWithPreviewExtension } from "../SourceInlineContentWithPreviewExtension.js";
4+
import { MathInlineInputRulesExtension } from "../MathInlineInputRulesExtension.js";
45
import { mathInlineContentConfig } from "../mathInlineContentConfig.js";
56
import { MathInlinePreviewWithPopup } from "./render/MathInlinePreviewWithPopup.js";
67
import { InlineMathMLElement } from "./toExternalHTML/InlineMathMLElement.js";
@@ -23,7 +24,8 @@ export const createReactInlineMathSpec = () =>
2324
[
2425
SourceInlineContentWithPreviewExtension({
2526
key: INLINE_MATH_PREVIEW_KEY,
26-
inlineContentType: "inlineMath",
27+
inlineContentType: mathInlineContentConfig.type,
2728
}),
29+
MathInlineInputRulesExtension,
2830
],
2931
);
Lines changed: 11 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import { StyleSchema } from "@blocknote/core";
22
import {
33
ReactCustomInlineContentRenderProps,
4-
useExtension,
5-
useExtensionState,
4+
SourceInlineContentWithPreview,
65
} from "@blocknote/react";
7-
import { TextSelection } from "prosemirror-state";
8-
import { MouseEvent } from "react";
96

107
import { MathInlineContentConfig } from "../../mathInlineContentConfig.js";
11-
import { SourceInlineContentWithPreviewExtension } from "../../SourceInlineContentWithPreviewExtension.js";
128
import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js";
13-
import { AddSourceButton } from "../../../shared/react/render/AddSourceButton.js";
149
import { useLatexToMathMLString } from "../../../shared/react/render/useLatexToMathML.js";
1510

1611
export const MathInlinePreviewWithPopup = (
@@ -19,109 +14,18 @@ export const MathInlinePreviewWithPopup = (
1914
StyleSchema
2015
>,
2116
) => {
22-
const { inlineContent, editor, contentRef, node, getPos } = props;
23-
const pos = getPos();
24-
25-
const source = getMathPlainTextContent(inlineContent.content);
26-
27-
const { store } = useExtension(SourceInlineContentWithPreviewExtension, {
28-
editor,
29-
});
30-
// The popup is open exactly when the selection is inside this inline content,
31-
// which is the same condition that marks it as selected.
32-
const selected = useExtensionState(SourceInlineContentWithPreviewExtension, {
33-
editor,
34-
selector: (state) => state.selected === pos,
35-
});
36-
17+
const source = getMathPlainTextContent(props.inlineContent.content);
3718
const { mathMLString, error } = useLatexToMathMLString(source, true);
3819

39-
// Opens the popup when clicking the preview.
40-
const handlePreviewMouseDown = (event: MouseEvent) => {
41-
if (!editor.isEditable || !pos) {
42-
return;
43-
}
44-
45-
store.setState({ selected: pos });
46-
47-
event.preventDefault();
48-
event.stopPropagation();
49-
50-
const view = editor.prosemirrorView!;
51-
view.dispatch(
52-
view.state.tr.setSelection(
53-
TextSelection.create(view.state.tr.doc, pos + node.nodeSize - 1),
54-
),
55-
);
56-
editor.focus();
57-
};
58-
59-
// Closes the popup by moving the selection to just after the inline content.
60-
const handleOkButtonMouseDown = (event: MouseEvent) => {
61-
if (!editor.isEditable || !pos) {
62-
return;
63-
}
64-
65-
event.preventDefault();
66-
event.stopPropagation();
67-
68-
const view = editor.prosemirrorView!;
69-
view.dispatch(
70-
view.state.tr.setSelection(
71-
TextSelection.create(view.state.tr.doc, pos + node.nodeSize),
72-
),
73-
);
74-
editor.focus();
75-
};
76-
7720
return (
78-
<span
79-
// The source is hidden, so highlight the whole inline content while the
80-
// cursor is in it.
81-
className={
82-
"bn-preview-with-source-popup" +
83-
(selected ? " ProseMirror-selectednode" : "")
84-
}
85-
data-open={selected ? "true" : "false"}
86-
>
87-
<span
88-
className="bn-preview-container"
89-
contentEditable={false}
90-
onMouseDown={handlePreviewMouseDown}
91-
>
92-
{source.length > 0 ? (
93-
<span dangerouslySetInnerHTML={{ __html: mathMLString }} />
94-
) : (
95-
<AddSourceButton
96-
text={editor.dictionary.code_block.add_source_button_text}
97-
/>
98-
)}
99-
</span>
100-
<div className="bn-source-block-popup">
101-
<div className="bn-code-block-source-popup-body">
102-
<pre>
103-
<code ref={contentRef} />
104-
</pre>
105-
<div
106-
className="bn-code-block-source-popup-ok-button-wrapper"
107-
contentEditable={false}
108-
>
109-
<button
110-
className="bn-code-block-source-popup-ok-button"
111-
onMouseDown={handleOkButtonMouseDown}
112-
>
113-
OK
114-
</button>
115-
</div>
116-
</div>
117-
<div
118-
className="bn-code-block-source-error"
119-
contentEditable={false}
120-
style={{ display: error ? "block" : "none" }}
121-
>
122-
{error}
123-
</div>
124-
</div>
125-
</span>
21+
<SourceInlineContentWithPreview
22+
editor={props.editor}
23+
contentRef={props.contentRef}
24+
node={props.node}
25+
getPos={props.getPos}
26+
source={source}
27+
preview={<span dangerouslySetInnerHTML={{ __html: mathMLString }} />}
28+
error={error}
29+
/>
12630
);
12731
};

packages/math-block/src/shared/react/render/AddSourceButton.tsx renamed to packages/react/src/blocks/SourceWithPreview/AddSourceButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Shown in place of the preview when the math content has no source yet.
1+
// Shown in place of the preview when the source content is empty.
22
export const AddSourceButton = (props: { text: string }) => (
33
<div className="bn-add-source-code-button" contentEditable={false}>
44
<div className="bn-add-source-code-button-icon">

0 commit comments

Comments
 (0)