Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions examples/06-custom-schema/09-math-block/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
insertOrUpdateBlockForSlashMenu,
} from "@blocknote/core/extensions";
import { createHighlighter } from "@blocknote/code-block";
import { createReactMathBlockSpec } from "@blocknote/math-block";
import {
createReactInlineMathSpec,
createReactMathBlockSpec,
} from "@blocknote/math-block";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import {
Expand All @@ -22,6 +25,10 @@ const schema = BlockNoteSchema.create().extend({
// Creates an instance of the Math block and adds it to the schema.
math: createReactMathBlockSpec(),
},
inlineContentSpecs: {
// Creates an instance of the inline Math content and adds it to the schema.
inlineMath: createReactInlineMathSpec(),
},
});

// Slash menu item to insert a Math block.
Expand All @@ -37,6 +44,23 @@ const insertMath = (editor: typeof schema.BlockNoteEditor) => ({
icon: <TbMathFunction />,
});

// Slash menu item to insert an inline Math equation.
const insertInlineMath = (editor: typeof schema.BlockNoteEditor) => ({
title: "Inline Math",
subtext: "Insert an inline LaTeX math formula",
onItemClick: () => {
editor.insertInlineContent([
// Inserts an empty inline equation, ready to be edited.
{ type: "inlineMath", content: "" },
// Adds a trailing space so the cursor can leave the equation.
" ",
]);
},
aliases: ["inline math", "inline latex", "inline formula", "inline equation"],
group: "Inline",
icon: <TbMathFunction />,
});

export default function App() {
const editor = useCreateBlockNote({
// Configures the syntax highlighting extension to always use LaTeX syntax highlighting in the
Expand All @@ -60,6 +84,14 @@ export default function App() {
type: "math",
content: "\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}",
},
{
type: "paragraph",
content: [
"Equations can also be inline, like ",
{ type: "inlineMath", content: "e^{i\\pi} + 1 = 0" },
". Click one to edit its LaTeX source.",
],
},
{
type: "paragraph",
content: "Press the '/' key to open the Slash Menu and add another",
Expand All @@ -80,8 +112,14 @@ export default function App() {
const lastBasicBlockIndex = defaultItems.findLastIndex(
(item) => item.group === "Basic blocks",
);
// Inserts the Math item as the last item in the "Basic blocks" group.
defaultItems.splice(lastBasicBlockIndex + 1, 0, insertMath(editor));
// Inserts the Math item as the last item in the "Basic blocks" group,
// followed by the inline Math item.
defaultItems.splice(
lastBasicBlockIndex + 1,
0,
insertMath(editor),
insertInlineMath(editor),
);

// Returns filtered items based on the query.
return filterSuggestionItems(defaultItems, query);
Expand Down
55 changes: 54 additions & 1 deletion packages/core/src/editor/Block.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ BASIC STYLES

.bn-block-content.ProseMirror-selectednode > *,
/* Case for node view renderers */
.ProseMirror-selectednode > .bn-block-content > * {
.ProseMirror-selectednode > .bn-block-content > *,
/* Case for inline content (e.g. while the text cursor is within it) */
.bn-inline-content .ProseMirror-selectednode {
border-radius: 4px;
outline: 4px solid rgb(100, 160, 255);
}
Expand Down Expand Up @@ -562,6 +564,57 @@ block's "add file" button styling. */
margin: 0;
}

/* INLINE SOURCE CONTENT PREVIEW */
/* Inline-content counterpart of the code block preview above. Reuses
`.bn-source-block-popup` and `.bn-code-block-source-error` for the popup. */
.bn-inline-source-content {
/* Positioning context for the absolutely-positioned source popup, while
staying inline with the surrounding text. */
position: relative;
display: inline-block;
}

.bn-inline-source-preview {
cursor: pointer;
}

/* The shared popup stretches edge-to-edge for the (full-width) code block; for
inline content it sits below the preview and sizes to its source instead. */
.bn-inline-source-content .bn-source-block-popup {
right: auto;
width: max-content;
min-width: 120px;
max-width: min(400px, 90vw);
}

/* Keeps the popup from collapsing to a sliver while the source is empty. */
.bn-inline-source-content .bn-source-block-popup > pre {
min-height: 1.5em;
box-sizing: content-box;
}

.bn-inline-source-content[data-open="true"] .bn-source-block-popup {
opacity: 1;
pointer-events: auto;
}

/* The "add source" button sits inline among text, so it needs to be compact
rather than the full-size block-preview button. */
.bn-inline-source-content .bn-add-source-code-button {
display: inline-flex;
gap: 4px;
padding: 1px 6px;
}

.bn-inline-source-content .bn-add-source-code-button-icon {
width: 1em;
height: 1em;
}

.bn-inline-source-content .bn-add-source-code-button-text {
font-size: 0.85em;
}

/* PAGE BREAK */
.bn-block-content[data-content-type="pageBreak"] > div {
width: 100%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
FilePanelExtension,
FormattingToolbarExtension,
HistoryExtension,
InlineContentBoundaryEditExtension,
LinkToolbarExtension,
NodeSelectionKeyboardExtension,
PlaceholderExtension,
Expand Down Expand Up @@ -176,6 +177,7 @@ export function getDefaultExtensions(
SideMenuExtension(options),
SuggestionMenu(options),
HistoryExtension(),
InlineContentBoundaryEditExtension(),
PositionMappingExtension(),
...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []),
] as ExtensionFactoryInstance[];
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/editor/managers/ExtensionManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ export class ExtensionManager {
this.addExtension(extension);
}
}

// Add the extensions from inline content specs (the built-in text/link
// specs carry none).
for (const inlineContent of Object.values(
this.editor.schema.inlineContentSpecs,
)) {
for (const extension of (
inlineContent as {
extensions?: (Extension | ExtensionFactoryInstance)[];
}
).extensions ?? []) {
this.addExtension(extension);
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
import { createExtension } from "../../editor/BlockNoteExtension.js";

const PLUGIN_KEY = new PluginKey("inline-content-boundary-edit");

// Whether a Backspace/Delete at `selection` would remove the entire content
// range `[content.from, content.to)` of an inline content node.
function emptiesInlineContent(
selection: Selection,
key: string,
content: { from: number; to: number },
) {
if (!selection.empty) {
return selection.from <= content.from && selection.to >= content.to;
}

const isSingleChar = content.to - content.from === 1;

return key === "Backspace"
? isSingleChar && selection.from === content.to
: isSingleChar && selection.from === content.from;
}

// Fixes editing at the boundary of an empty custom inline content node (i.e. an
// inline node with editable content, like a mention or inline math).
//
// An empty inline node can't hold a text cursor, so ProseMirror can't reconcile
// edits across the empty boundary from the DOM: typing into an empty node
// inserts text next to it rather than inside, and deleting the last character
// leaves an un-reconcilable empty node that corrupts/freezes the editor. Both
// boundary edits are handled here via transactions so the caret stays inside
// the node, which is kept alive and editable in its empty state.
//
// The cursor is inside such a node exactly when its directly-enclosing node is
// inline (`inline: true` in the spec) - regular text blocks aren't inline, and
// atomic inline content can't hold a cursor - so the handling applies to any
// inline content type without needing to know it by name.
export const InlineContentBoundaryEditExtension = createExtension(
() =>
({
key: "inlineContentBoundaryEdit",
prosemirrorPlugins: [
new Plugin({
key: PLUGIN_KEY,
props: {
handleKeyDown: (view, event) => {
if (!view.editable) {
return false;
}

const isTypedChar =
event.key.length === 1 && !event.ctrlKey && !event.metaKey;

if (
!isTypedChar &&
event.key !== "Backspace" &&
event.key !== "Delete"
) {
return false;
}

const { selection } = view.state;
const node = selection.$from.node();
if (!node.type.spec.inline) {
return false;
}

const pos = selection.$from.before();
const contentFrom = pos + 1;
const contentTo = pos + 1 + node.content.size;

// Empty content: redirect the typed character into the node.
if (isTypedChar && node.content.size === 0) {
const tr = view.state.tr.insert(
contentFrom,
view.state.schema.text(event.key),
);
tr.setSelection(
TextSelection.create(tr.doc, contentFrom + event.key.length),
);
view.dispatch(tr);

return true;
}

// Backspace/Delete that would empty the content: delete it all in
// one transaction, keeping the now-empty node (and the caret
// inside it) so it stays editable.
if (
node.content.size > 0 &&
emptiesInlineContent(selection, event.key, {
from: contentFrom,
to: contentTo,
})
) {
const tr = view.state.tr.delete(contentFrom, contentTo);
tr.setSelection(TextSelection.create(tr.doc, contentFrom));
view.dispatch(tr);

return true;
}

return false;
},
},
}),
],
}) as const,
);
1 change: 1 addition & 0 deletions packages/core/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./DropCursor/DropCursor.js";
export * from "./FilePanel/FilePanel.js";
export * from "./FormattingToolbar/FormattingToolbar.js";
export * from "./History/History.js";
export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js";
export * from "./LinkToolbar/LinkToolbar.js";
export * from "./LinkToolbar/protocols.js";
export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js";
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/schema/inlineContent/createSpec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Node } from "@tiptap/core";

import { TagParseRule } from "@tiptap/pm/model";
import { Node as ProsemirrorNode, TagParseRule } from "@tiptap/pm/model";
import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js";
import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
Expand Down Expand Up @@ -54,6 +54,16 @@ export type CustomInlineContentImplementation<
editor: BlockNoteEditor<any, any, S>,
// (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations
// or allow manually passing <BSchema>, but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics
/**
* The ProseMirror node backing this inline content.
*/
node: ProsemirrorNode,
/**
* Returns this inline content's position in the document. When rendered
* outside the editor (i.e. serialized to HTML), this is a no-op that returns
* `undefined`.
*/
getPos: () => number | undefined,
) => {
dom: HTMLElement;
contentDOM?: HTMLElement;
Expand Down Expand Up @@ -170,6 +180,8 @@ export function createInlineContentSpec<
// No-op
},
editor,
node,
() => undefined,
);

return addInlineContentAttributes(
Expand Down Expand Up @@ -206,6 +218,8 @@ export function createInlineContentSpec<
);
},
editor,
node,
getPos,
);

return addInlineContentAttributes(
Expand All @@ -225,10 +239,19 @@ export function createInlineContentSpec<
...inlineContentImplementation,
toExternalHTML: inlineContentImplementation.toExternalHTML,
render(inlineContent, updateInlineContent, editor) {
// Rendered outside the editor (serialization), so there's no live node
// view - derive the node from the content and stub out `getPos`.
const node = inlineContentToNodes(
[inlineContent] as any,
editor.pmSchema,
)[0];

const output = inlineContentImplementation.render(
inlineContent,
updateInlineContent,
editor,
node,
() => undefined,
);

return addInlineContentAttributes(
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/schema/inlineContent/internal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { KeyboardShortcutCommand, Node } from "@tiptap/core";

import {
Extension,
ExtensionFactoryInstance,
} from "../../editor/BlockNoteExtension.js";
import { camelToDataKebab } from "../../util/string.js";
import { PropSchema, Props } from "../propTypes.js";
import {
Expand Down Expand Up @@ -77,10 +81,12 @@ export function createInternalInlineContentSpec<
>(
config: T,
implementation: InlineContentImplementation<NoInfer<T>>,
extensions?: (Extension | ExtensionFactoryInstance)[],
): InlineContentSpec<T> {
return {
config,
implementation,
extensions,
} as const;
}

Expand Down
Loading
Loading