Equation adds block and inline void nodes for LaTeX expressions. Both nodes store source in latex and render through KaTeX. This page covers kit setup, block versus inline ownership, insertion, input rules, Markdown serialization, and registry UI behavior.
MathKit installs both equation plugins, their registry components, and the default math input rules.
'use client';
import '@platejs/math/katex.css';
import { MathRules } from '@platejs/math';
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
import katex, { type KatexOptions } from 'katex';
import { CornerDownLeftIcon, RadicalIcon } from 'lucide-react';
import { isHotkey } from 'platejs';
import {
type PlateElementProps,
PlateElement,
useEditor,
useEditorReadOnly,
useEditorSelector,
useElement,
useElementSelected,
} from 'platejs/react';
import * as React from 'react';
import TextareaAutosize, {
type TextareaAutosizeProps,
} from 'react-textarea-autosize';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
FloatingPopover as Popover,
FloatingPopoverContent as PopoverContent,
FloatingPopoverTrigger as PopoverTrigger,
} from '@/components/editor/floating-popover';
import { inlineSuggestionVariants } from '@/lib/inline-suggestion';
function useEquation({
element,
katexRef,
options,
}: {
element: { latex: string };
katexRef: React.RefObject<HTMLDivElement | null>;
options?: KatexOptions;
}) {
React.useEffect(() => {
if (!katexRef.current) return;
katex.render(element.latex, katexRef.current, {
...options,
throwOnError: false,
});
}, [element.latex, katexRef, options]);
}
export function EquationElement(
props: PlateElementProps<typeof EquationPlugin> & {
lineBreakBadge?: React.ReactNode;
}
) {
const selected = useElementSelected();
const [open, setOpen] = React.useState(selected);
const katexRef = React.useRef<HTMLDivElement | null>(null);
const { lineBreakBadge } = props;
useEquation({
element: props.element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement className="my-1" {...props}>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger>
<button
aria-label={
props.element.latex.length > 0 ? 'Edit equation' : 'Add equation'
}
className={cn(
'group flex cursor-pointer select-none items-center justify-center rounded-sm hover:bg-primary/10 data-[selected=true]:bg-primary/10',
props.element.latex.length === 0
? 'bg-muted p-3 pr-9'
: 'px-2 py-1'
)}
data-selected={selected}
contentEditable={false}
type="button"
>
{props.element.latex.length > 0 ? (
<span ref={katexRef} />
) : (
<span className="flex h-7 w-full items-center gap-2 text-sm whitespace-nowrap text-muted-foreground">
<RadicalIcon className="size-6 text-muted-foreground/80" />
<span>Add a Tex equation</span>
</span>
)}
{lineBreakBadge}
</button>
</PopoverTrigger>
<EquationPopoverContent
open={open}
placeholder={
'f(x) = \\begin{cases}\n x^2, &\\quad x > 0 \\\\\n 0, &\\quad x = 0 \\\\\n -x^2, &\\quad x < 0\n\\end{cases}'
}
isInline={false}
setOpen={setOpen}
/>
</Popover>
{props.children}
</PlateElement>
);
}
export function InlineEquationElement(
props: PlateElementProps<typeof InlineEquationPlugin>
) {
const { element } = props;
const katexRef = React.useRef<HTMLDivElement | null>(null);
const selected = useElementSelected();
const isCollapsed = useEditorSelector((editor) =>
editor.read.selection.isCollapsed()
);
const [popoverState, setPopoverState] = React.useState({
dismissed: false,
selected,
});
if (popoverState.selected !== selected) {
setPopoverState({ dismissed: false, selected });
}
const dismissed =
popoverState.selected === selected && popoverState.dismissed;
const open = selected && isCollapsed && !dismissed;
const setOpen = React.useCallback(
(nextOpen: boolean) => {
setPopoverState({ dismissed: !nextOpen, selected });
},
[selected]
);
useEquation({
element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement
{...props}
className={cn(
'mx-1 inline-block select-none rounded-sm [&_.katex-display]:my-0!'
)}
>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger>
<button
aria-label={
element.latex.length > 0 ? 'Edit equation' : 'Add equation'
}
className={cn(
'after:-top-0.5 after:-left-1 after:absolute after:inset-0 after:z-1 after:h-[calc(100%)+4px] after:w-[calc(100%+8px)] after:rounded-sm after:content-[""]',
'h-6',
inlineSuggestionVariants(),
((element.latex.length > 0 && open) || selected) &&
'after:bg-brand/15',
element.latex.length === 0 &&
'text-muted-foreground after:bg-neutral-500/10'
)}
contentEditable={false}
type="button"
>
<span
ref={katexRef}
className={cn(
element.latex.length === 0 && 'hidden',
'font-mono leading-none'
)}
/>
{element.latex.length === 0 && (
<span>
<RadicalIcon className="mr-1 inline-block h-[19px] w-4 py-[1.5px] align-text-bottom" />
New equation
</span>
)}
</button>
</PopoverTrigger>
<EquationPopoverContent
className="my-auto"
open={open}
placeholder="E = mc^2"
setOpen={setOpen}
isInline
/>
</Popover>
{props.children}
</PlateElement>
);
}
function EquationInput({
isInline,
onClose,
open,
...props
}: TextareaAutosizeProps & {
isInline?: boolean;
open?: boolean;
onClose?: () => void;
}) {
const editor = useEditor();
const element = useElement(isInline ? InlineEquationPlugin : EquationPlugin);
const ref = React.useRef<HTMLTextAreaElement>(null);
const initialExpressionRef = React.useRef(element.latex);
const effectContextRef = React.useRef({ editor, element, isInline });
React.useEffect(() => {
effectContextRef.current = { editor, element, isInline };
}, [editor, element, isInline]);
React.useEffect(() => {
if (!open) return undefined;
const timeoutId = window.setTimeout(() => {
ref.current?.focus();
ref.current?.select();
const context = effectContextRef.current;
if (context.isInline) {
initialExpressionRef.current = context.element.latex;
}
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
}, [open]);
const setExpression = (latex: string) => {
const at = editor.read.nodes.path(element);
if (!at) return;
if (isInline) {
editor
.plugin(InlineEquationPlugin)
.update({ history: 'merge' })
.set({ latex }, { at });
} else {
editor.plugin(EquationPlugin).update.set({ latex }, { at });
}
};
const dismiss = () => {
if (isInline) setExpression(initialExpressionRef.current);
onClose?.();
};
const selectOutside = (direction: 'after' | 'before') => {
const point = editor.read.points[direction](element);
if (!point) return;
editor.update.selection.set(point);
editor.api.dom.focus();
};
return (
<TextareaAutosize
ref={ref}
value={element.latex}
onChange={(event) => {
setExpression(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (isHotkey('enter')(event)) {
event.preventDefault();
onClose?.();
} else if (isHotkey('escape')(event)) {
event.preventDefault();
dismiss();
}
if (!isInline) return;
const { selectionEnd, selectionStart } = event.currentTarget;
if (
selectionStart === 0 &&
selectionEnd === 0 &&
isHotkey('ArrowLeft')(event)
) {
event.preventDefault();
selectOutside('before');
}
if (
selectionEnd === element.latex.length &&
selectionStart === element.latex.length &&
isHotkey('ArrowRight')(event)
) {
event.preventDefault();
selectOutside('after');
}
}}
{...props}
/>
);
}
const EquationPopoverContent = ({
className,
isInline,
open,
setOpen,
...props
}: {
isInline: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} & TextareaAutosizeProps) => {
const editor = useEditor();
const readOnly = useEditorReadOnly();
const element = useElement(isInline ? InlineEquationPlugin : EquationPlugin);
if (readOnly) return null;
const onClose = () => {
setOpen(false);
if (isInline) {
const nextPoint = editor.read.points.after(element);
if (nextPoint) {
editor.update.selection.set(nextPoint);
}
} else {
const path = editor.read.nodes.path(element);
if (path) {
editor.update.selection.setNodes([path]);
}
}
editor.api.dom.focus();
};
return (
<PopoverContent
className="flex gap-2"
onFinalFocus={(event) => {
if (isInline) event.preventDefault();
}}
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
contentEditable={false}
>
<EquationInput
className={cn('max-h-[50vh] grow resize-none p-2 text-sm', className)}
isInline={isInline}
onClose={onClose}
open={open}
autoFocus
{...props}
/>
<Button variant="secondary" className="px-3" onClick={onClose}>
Done <CornerDownLeftIcon className="size-3.5" />
</Button>
</PopoverContent>
);
};
export const MathKit = [
InlineEquationPlugin.configure({
component: InlineEquationElement,
inputRules: [MathRules.markdown({ variant: '$' })],
}),
EquationPlugin.configure({
component: EquationElement,
inputRules: [MathRules.markdown({ on: 'break', variant: '$$' })],
}),
];'use client';
import '@platejs/math/katex.css';
import { MathRules } from '@platejs/math';
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
import katex, { type KatexOptions } from 'katex';
import { CornerDownLeftIcon, RadicalIcon } from 'lucide-react';
import { isHotkey } from 'platejs';
import {
type PlateElementProps,
PlateElement,
useEditor,
useEditorReadOnly,
useEditorSelector,
useElement,
useElementSelected,
} from 'platejs/react';
import * as
import { createPlateEditor } from 'platejs/react';
import { MathKit } from '@/components/editor/math';
export const editor = createPlateEditor({
plugins: MathKit,
});import { createPlateEditor } from 'platejs/react';
import { MathKit } from '@/components/editor/math';
export const editor = createPlateEditor({
plugins: MathKit,
});math owns the block equation, inline equation, editable popover, textarea input, KaTeX render target, static elements, and DOCX fallback elements.
'use client';
import '@platejs/math/katex.css';
import { MathRules } from '@platejs/math';
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
import katex, { type KatexOptions } from 'katex';
import { CornerDownLeftIcon, RadicalIcon } from 'lucide-react';
import { isHotkey } from 'platejs';
import {
type PlateElementProps,
PlateElement,
useEditor,
useEditorReadOnly,
useEditorSelector,
useElement,
useElementSelected,
} from 'platejs/react';
import
equation-toolbar-button inserts an inline equation through
editor.plugin(BaseInlineEquationPlugin).update.insert().
'use client';
import { BaseInlineEquationPlugin } from '@platejs/math';
import { RadicalIcon } from 'lucide-react';
import { useEditor } from 'platejs/react';
import * as React from 'react';
import { ToolbarButton } from '@/components/editor/toolbar';
export function InlineEquationToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const editor = useEditor();
return (
<ToolbarButton
| Layer | Owner | What It Does |
|---|---|---|
@platejs/math | Package | Exports the base equation family, MathRules, and getEquationHtml. |
@platejs/math/react | Package | Exports the React equation descriptors. |
math | Registry | Adds block and inline React plugins with input rules and interactive UI components. |
math-static | Registry | Adds static equation components for read-only rendering. |
math | Registry UI | Renders editable, static, and DOCX equation elements. |
equation-toolbar-button | Registry UI | Inserts inline equations from the toolbar. |
@platejs/markdown | Package | Serializes and deserializes math and inlineMath nodes when remark-math is configured. |
Block and inline equations share the EquationElement shape, but they are different node types with different plugin names.
Import the KaTeX stylesheet once in your app or feature kit:
import '@platejs/math/katex.css';import '@platejs/math/katex.css';Use both React plugins when your editor supports block and inline equations.
import '@platejs/math/katex.css';
import { MathRules } from '@platejs/math';
import {
EquationPlugin,
InlineEquationPlugin,
} from '@platejs/math/react';
import { createPlateEditor } from 'platejs/react';
import {
EquationElement,
InlineEquationElement,
} from '@/components/editor/math';
export const editor = createPlateEditor({
plugins: [
InlineEquationPlugin.configure({
component: InlineEquationElement,
inputRules: [MathRules.markdown({ variant: '$' })],
Use the base kit when rendering read-only output with platejs/static.
import {
BaseEquationPlugin,
BaseInlineEquationPlugin,
getEquationHtml,
} from '@platejs/math';
import '@platejs/math/katex.css';
import { RadicalIcon } from 'lucide-react';
import { type PliteElementProps, PliteElement } from 'platejs/static';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { inlineSuggestionVariants } from '@/lib/inline-suggestion';
export function EquationElementStatic(
props: PliteElementProps<typeof
Use each plugin's scoped update surface.
import {
BaseEquationPlugin,
BaseInlineEquationPlugin,
} from '@platejs/math';
editor.plugin(BaseEquationPlugin).update.insert({}, { select: true });
editor
.plugin(BaseInlineEquationPlugin)
.update.insert({ latex: 'E = mc^2' }, { select: true });import {
BaseEquationPlugin,
BaseInlineEquationPlugin,
} from '@platejs/math';
Both equation nodes store source in latex. The child text is only the Plite-required child for a void element.
const value = [
{
children: [
{ text: 'Mass-energy equivalence: ' },
{
children: [{ text: '' }],
latex: 'E = mc^2',
type: 'inlineEquation',
},
{ text: '.' },
],
type: 'paragraph',
},
{
children: [{ text: '' }],
latex: '\\\\int_{a}^{b} f(x) \\\\, dx = F(b) - F(a)',
type: 'equation',
},
];const value = [
{
children: [
{ text: 'Mass-energy equivalence: ' },
{
children: [{ text: '' }],
latex: 'E = mc^2',
type: 'inlineEquation',
},
{ text: '.' },
],
type: 'paragraph',
},
{
children: [{ text: '' }],
latex: '\\\\int_{a}^{b} f(x) \\\\, dx = F(b) - F(a)',
type: 'equation',
},
];| Node | Type | Behavior |
|---|---|---|
BaseEquationPlugin | equation | Block void equation. |
BaseInlineEquationPlugin | inlineEquation | Inline void equation. |
EquationElement.latex | string | LaTeX source rendered by KaTeX. |
MathRules.markdown creates editor input rules. It is separate from Markdown serialization.
| Rule | Trigger | Behavior |
|---|---|---|
MathRules.markdown({ variant: '$' }) | $...$ | Deletes the delimited text and inserts an inline equation with the matched expression. |
MathRules.markdown({ on: 'break', variant: '$$' }) | $$ then line break | Replaces the paragraph fence with a block equation. |
MathRules.markdown({ on: 'match', variant: '$$' }) | $$...$$ match | Creates a block equation on match. |
Math input rules are disabled inside code blocks, block equations, and inline equations.
The registry components render KaTeX and keep source editing in a popover.
| Surface | Behavior |
|---|---|
| Editable block equation | math calls katex.render with display-mode options. |
| Editable inline equation | Opens a popover when the inline void node is selected and the selection is collapsed. |
| Popover input | math writes latex as the textarea changes. |
Enter | Submits and closes the input. |
Escape | Dismisses; inline equations restore the initial expression. |
| Inline left/right edge arrows | Move selection out of the inline equation. |
| Static rendering | getEquationHtml calls katex.renderToString. |
KaTeX is configured with throwOnError: false, strict: 'warn', and trust: false in the registry UI.
Markdown math support comes from @platejs/markdown plus remark-math, as configured by the registry MarkdownKit.
Inline $x+1$ mathInline $x+1$ math$$
x+1
$$$$
x+1
$$Inline math deserializes to inlineEquation. Block math deserializes to equation. Serialization writes the same Markdown math shapes from latex.
| API | Package | Use |
|---|---|---|
BaseEquationPlugin | @platejs/math | Headless block equation plugin. |
BaseInlineEquationPlugin | @platejs/math | Headless inline equation plugin. |
EquationPlugin | @platejs/math/react | React block equation plugin. |
InlineEquationPlugin | @platejs/math/react | React inline equation plugin. |
editor.plugin(BaseEquationPlugin).update.insert(props?, options?) | @platejs/math | Inserts a block equation. |
editor.plugin(BaseInlineEquationPlugin).update.insert(input?, options?) | @platejs/math | Inserts an inline equation. latex defaults to the selected string. |
MathRules.markdown(options) | @platejs/math | Creates inline or block math input rules. |
EquationElement / InlineEquationElement | math registry UI | Render KaTeX and own the editable popover interaction. |
getEquationHtml(options) | @platejs/math | Returns static KaTeX HTML. |
EquationElement | @platejs/math | Union of the block and inline equation element shapes. |
'use client';
import '@platejs/math/katex.css';
import { MathRules } from '@platejs/math';
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
import katex, { type KatexOptions } from 'katex';
import { CornerDownLeftIcon, RadicalIcon } from 'lucide-react';
import { isHotkey } from 'platejs';
import {
type PlateElementProps,
PlateElement,
useEditor,
useEditorReadOnly,
useEditorSelector,
useElement,
useElementSelected,
} from 'platejs/react';
import * as React from 'react';
import TextareaAutosize, {
type TextareaAutosizeProps,
} from 'react-textarea-autosize';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
FloatingPopover as Popover,
FloatingPopoverContent as PopoverContent,
FloatingPopoverTrigger as PopoverTrigger,
} from '@/components/editor/floating-popover';
import { inlineSuggestionVariants } from '@/lib/inline-suggestion';
function useEquation({
element,
katexRef,
options,
}: {
element: { latex: string };
katexRef: React.RefObject<HTMLDivElement | null>;
options?: KatexOptions;
}) {
React.useEffect(() => {
if (!katexRef.current) return;
katex.render(element.latex, katexRef.current, {
...options,
throwOnError: false,
});
}, [element.latex, katexRef, options]);
}
export function EquationElement(
props: PlateElementProps<typeof EquationPlugin> & {
lineBreakBadge?: React.ReactNode;
}
) {
const selected = useElementSelected();
const [open, setOpen] = React.useState(selected);
const katexRef = React.useRef<HTMLDivElement | null>(null);
const { lineBreakBadge } = props;
useEquation({
element: props.element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement className="my-1" {...props}>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger>
<button
aria-label={
props.element.latex.length > 0 ? 'Edit equation' : 'Add equation'
}
className={cn(
'group flex cursor-pointer select-none items-center justify-center rounded-sm hover:bg-primary/10 data-[selected=true]:bg-primary/10',
props.element.latex.length === 0
? 'bg-muted p-3 pr-9'
: 'px-2 py-1'
)}
data-selected={selected}
contentEditable={false}
type="button"
>
{props.element.latex.length > 0 ? (
<span ref={katexRef} />
) : (
<span className="flex h-7 w-full items-center gap-2 text-sm whitespace-nowrap text-muted-foreground">
<RadicalIcon className="size-6 text-muted-foreground/80" />
<span>Add a Tex equation</span>
</span>
)}
{lineBreakBadge}
</button>
</PopoverTrigger>
<EquationPopoverContent
open={open}
placeholder={
'f(x) = \\begin{cases}\n x^2, &\\quad x > 0 \\\\\n 0, &\\quad x = 0 \\\\\n -x^2, &\\quad x < 0\n\\end{cases}'
}
isInline={false}
setOpen={setOpen}
/>
</Popover>
{props.children}
</PlateElement>
);
}
export function InlineEquationElement(
props: PlateElementProps<typeof InlineEquationPlugin>
) {
const { element } = props;
const katexRef = React.useRef<HTMLDivElement | null>(null);
const selected = useElementSelected();
const isCollapsed = useEditorSelector((editor) =>
editor.read.selection.isCollapsed()
);
const [popoverState, setPopoverState] = React.useState({
dismissed: false,
selected,
});
if (popoverState.selected !== selected) {
setPopoverState({ dismissed: false, selected });
}
const dismissed =
popoverState.selected === selected && popoverState.dismissed;
const open = selected && isCollapsed && !dismissed;
const setOpen = React.useCallback(
(nextOpen: boolean) => {
setPopoverState({ dismissed: !nextOpen, selected });
},
[selected]
);
useEquation({
element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement
{...props}
className={cn(
'mx-1 inline-block select-none rounded-sm [&_.katex-display]:my-0!'
)}
>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger>
<button
aria-label={
element.latex.length > 0 ? 'Edit equation' : 'Add equation'
}
className={cn(
'after:-top-0.5 after:-left-1 after:absolute after:inset-0 after:z-1 after:h-[calc(100%)+4px] after:w-[calc(100%+8px)] after:rounded-sm after:content-[""]',
'h-6',
inlineSuggestionVariants(),
((element.latex.length > 0 && open) || selected) &&
'after:bg-brand/15',
element.latex.length === 0 &&
'text-muted-foreground after:bg-neutral-500/10'
)}
contentEditable={false}
type="button"
>
<span
ref={katexRef}
className={cn(
element.latex.length === 0 && 'hidden',
'font-mono leading-none'
)}
/>
{element.latex.length === 0 && (
<span>
<RadicalIcon className="mr-1 inline-block h-[19px] w-4 py-[1.5px] align-text-bottom" />
New equation
</span>
)}
</button>
</PopoverTrigger>
<EquationPopoverContent
className="my-auto"
open={open}
placeholder="E = mc^2"
setOpen={setOpen}
isInline
/>
</Popover>
{props.children}
</PlateElement>
);
}
function EquationInput({
isInline,
onClose,
open,
...props
}: TextareaAutosizeProps & {
isInline?: boolean;
open?: boolean;
onClose?: () => void;
}) {
const editor = useEditor();
const element = useElement(isInline ? InlineEquationPlugin : EquationPlugin);
const ref = React.useRef<HTMLTextAreaElement>(null);
const initialExpressionRef = React.useRef(element.latex);
const effectContextRef = React.useRef({ editor, element, isInline });
React.useEffect(() => {
effectContextRef.current = { editor, element, isInline };
}, [editor, element, isInline]);
React.useEffect(() => {
if (!open) return undefined;
const timeoutId = window.setTimeout(() => {
ref.current?.focus();
ref.current?.select();
const context = effectContextRef.current;
if (context.isInline) {
initialExpressionRef.current = context.element.latex;
}
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
}, [open]);
const setExpression = (latex: string) => {
const at = editor.read.nodes.path(element);
if (!at) return;
if (isInline) {
editor
.plugin(InlineEquationPlugin)
.update({ history: 'merge' })
.set({ latex }, { at });
} else {
editor.plugin(EquationPlugin).update.set({ latex }, { at });
}
};
const dismiss = () => {
if (isInline) setExpression(initialExpressionRef.current);
onClose?.();
};
const selectOutside = (direction: 'after' | 'before') => {
const point = editor.read.points[direction](element);
if (!point) return;
editor.update.selection.set(point);
editor.api.dom.focus();
};
return (
<TextareaAutosize
ref={ref}
value={element.latex}
onChange={(event) => {
setExpression(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (isHotkey('enter')(event)) {
event.preventDefault();
onClose?.();
} else if (isHotkey('escape')(event)) {
event.preventDefault();
dismiss();
}
if (!isInline) return;
const { selectionEnd, selectionStart } = event.currentTarget;
if (
selectionStart === 0 &&
selectionEnd === 0 &&
isHotkey('ArrowLeft')(event)
) {
event.preventDefault();
selectOutside('before');
}
if (
selectionEnd === element.latex.length &&
selectionStart === element.latex.length &&
isHotkey('ArrowRight')(event)
) {
event.preventDefault();
selectOutside('after');
}
}}
{...props}
/>
);
}
const EquationPopoverContent = ({
className,
isInline,
open,
setOpen,
...props
}: {
isInline: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} & TextareaAutosizeProps) => {
const editor = useEditor();
const readOnly = useEditorReadOnly();
const element = useElement(isInline ? InlineEquationPlugin : EquationPlugin);
if (readOnly) return null;
const onClose = () => {
setOpen(false);
if (isInline) {
const nextPoint = editor.read.points.after(element);
if (nextPoint) {
editor.update.selection.set(nextPoint);
}
} else {
const path = editor.read.nodes.path(element);
if (path) {
editor.update.selection.setNodes([path]);
}
}
editor.api.dom.focus();
};
return (
<PopoverContent
className="flex gap-2"
onFinalFocus={(event) => {
if (isInline) event.preventDefault();
}}
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
contentEditable={false}
>
<EquationInput
className={cn('max-h-[50vh] grow resize-none p-2 text-sm', className)}
isInline={isInline}
onClose={onClose}
open={open}
autoFocus
{...props}
/>
<Button variant="secondary" className="px-3" onClick={onClose}>
Done <CornerDownLeftIcon className="size-3.5" />
</Button>
</PopoverContent>
);
};
export const MathKit = [
InlineEquationPlugin.configure({
component: InlineEquationElement,
inputRules: [MathRules.markdown({ variant: '$' })],
}),
EquationPlugin.configure({
component: EquationElement,
inputRules: [MathRules.markdown({ on: 'break', variant: '$$' })],
}),
];'use client';
import { BaseInlineEquationPlugin } from '@platejs/math';
import { RadicalIcon } from 'lucide-react';
import { useEditor } from 'platejs/react';
import * as React from 'react';
import { ToolbarButton } from '@/components/editor/toolbar';
export function InlineEquationToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const editor = useEditor();
return (
<ToolbarButton
{...props}
onClick={() => {
editor.plugin(BaseInlineEquationPlugin).update.insert();
}}
tooltip="Mark as equation"
>
<RadicalIcon />
</ToolbarButton>
);
}import '@platejs/math/katex.css';
import { MathRules } from '@platejs/math';
import {
EquationPlugin,
InlineEquationPlugin,
} from '@platejs/math/react';
import { createPlateEditor } from 'platejs/react';
import {
EquationElement,
InlineEquationElement,
} from '@/components/editor/math';
export const editor = createPlateEditor({
plugins: [
InlineEquationPlugin.configure({
component: InlineEquationElement,
inputRules: [MathRules.markdown({ variant: '$' })],
}),
EquationPlugin.configure({
component: EquationElement,
inputRules: [MathRules.markdown({ on: 'break', variant: '$$' })],
}),
],
});import {
BaseEquationPlugin,
BaseInlineEquationPlugin,
getEquationHtml,
} from '@platejs/math';
import '@platejs/math/katex.css';
import { RadicalIcon } from 'lucide-react';
import { type PliteElementProps, PliteElement } from 'platejs/static';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { inlineSuggestionVariants } from '@/lib/inline-suggestion';
export function EquationElementStatic(
props: PliteElementProps<typeof BaseEquationPlugin>
) {
const { element } = props;
const html = getEquationHtml({
element,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PliteElement className="my-1" {...props}>
<div
className={cn(
'group flex select-none items-center justify-center rounded-sm hover:bg-primary/10 data-[selected=true]:bg-primary/10',
element.latex.length === 0 ? 'bg-muted p-3 pr-9' : 'px-2 py-1'
)}
>
{element.latex.length > 0 ? (
<span
// oxlint-disable-next-line react/no-danger -- [P0 behavior-boundary] KaTeX generates this HTML with trust disabled from the adjacent equation source.
dangerouslySetInnerHTML={{
__html: html,
}}
/>
) : (
<div className="flex h-7 w-full items-center gap-2 text-sm whitespace-nowrap text-muted-foreground">
<RadicalIcon className="size-6 text-muted-foreground/80" />
<div>Add a Tex equation</div>
</div>
)}
</div>
{props.children}
</PliteElement>
);
}
export function InlineEquationElementStatic(
props: PliteElementProps<typeof BaseInlineEquationPlugin>
) {
const html = getEquationHtml({
element: props.element,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PliteElement
{...props}
className="inline-block rounded-sm select-none [&_.katex-display]:my-0"
>
<div
className={cn(
'after:-top-0.5 after:-left-1 after:absolute after:inset-0 after:z-1 after:h-[calc(100%)+4px] after:w-[calc(100%+8px)] after:rounded-sm after:content-[""]',
'h-6',
inlineSuggestionVariants(),
props.element.latex.length === 0 &&
'text-muted-foreground after:bg-neutral-500/10'
)}
>
<span
className={cn(
props.element.latex.length === 0 && 'hidden',
'font-mono leading-none'
)}
// oxlint-disable-next-line react/no-danger -- [P0 behavior-boundary] KaTeX generates this HTML with trust disabled from the adjacent equation source.
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
{props.children}
</PliteElement>
);
}
/**
* DOCX-compatible block equation component.
* Displays LaTeX source code with styling.
*/
export function EquationElementDocx(
props: PliteElementProps<typeof BaseEquationPlugin>
) {
const { element } = props;
if (!element.latex || element.latex.length === 0) {
return (
<PliteElement {...props}>
<p style={{ color: '#888', fontStyle: 'italic' }}>[Empty equation]</p>
{props.children}
</PliteElement>
);
}
return (
<PliteElement {...props}>
<p
style={{
fontFamily: 'Cambria Math, Consolas, monospace',
fontSize: '12pt',
margin: '8pt 0',
textAlign: 'center',
}}
>
{element.latex}
</p>
{props.children}
</PliteElement>
);
}
/**
* DOCX-compatible inline equation component.
* Displays LaTeX source code inline.
*/
export function InlineEquationElementDocx(
props: PliteElementProps<typeof BaseInlineEquationPlugin>
) {
const { element } = props;
if (!element.latex || element.latex.length === 0) {
return (
<PliteElement {...props} as="span">
<span style={{ color: '#888', fontStyle: 'italic' }}>[equation]</span>
{props.children}
</PliteElement>
);
}
return (
<PliteElement {...props} as="span">
<span
style={{
fontFamily: 'Cambria Math, Consolas, monospace',
}}
>
{element.latex}
</span>
{props.children}
</PliteElement>
);
}
export const BaseMathKit = [
BaseInlineEquationPlugin.configure({
component: InlineEquationElementStatic,
}),
BaseEquationPlugin.configure({
component: EquationElementStatic,
}),
] as const;