Files
upstream-tiptap/packages/extension-mathematics/src/extensions/InlineMath.ts
upstream-mirror 083a46e93b
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:11:02 +05:00

289 lines
6.7 KiB
TypeScript

import { InputRule, mergeAttributes, Node } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import katex, { type KatexOptions } from 'katex'
/**
* Configuration options for the InlineMath extension.
*/
export type InlineMathOptions = {
/**
* KaTeX specific options
* @see https://katex.org/docs/options.html
* @example
* ```ts
* katexOptions: {
* displayMode: false,
* throwOnError: false,
* macros: {
* '\\RR': '\\mathbb{R}',
* '\\ZZ': '\\mathbb{Z}'
* }
* }
* ```
*/
katexOptions?: KatexOptions
/**
* Optional click handler for inline math nodes.
* Called when a user clicks on an inline math expression in the editor.
*
* @param node - The ProseMirror node representing the inline math element
* @param pos - The position of the node within the document
* @example
* ```ts
* onClick: (node, pos) => {
* console.log('Inline math clicked:', node.attrs.latex, 'at position:', pos)
* }
* ```
*/
onClick?: (node: PMNode, pos: number) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
inlineMath: {
/**
* Insert a inline math node with LaTeX string.
* @param options - Options for inserting inline math.
* @returns ReturnType
*/
insertInlineMath: (options: { latex: string; pos?: number }) => ReturnType
/**
* Delete an inline math node.
* @returns ReturnType
*/
deleteInlineMath: (options?: { pos?: number }) => ReturnType
/**
* Update inline math node with optional LaTeX string.
* @param options - Options for updating inline math.
* @returns ReturnType
*/
updateInlineMath: (options?: { latex?: string; pos?: number }) => ReturnType
}
}
}
/**
* InlineMath is a Tiptap extension for rendering inline mathematical expressions using KaTeX.
* It allows users to insert LaTeX formatted math expressions inline within text.
* It supports rendering, input rules for LaTeX syntax, and click handling for interaction.
*
* @example
* ```javascript
* import { InlineMath } from '@tiptap/extension-mathematics'
* import { Editor } from '@tiptap/core'
*
* const editor = new Editor({
* extensions: [
* InlineMath.configure({
* onClick: (node, pos) => {
* console.log('Inline math clicked:', node.attrs.latex, 'at position:', pos)
* },
* }),
* ],
* })
*/
export const InlineMath = Node.create<InlineMathOptions>({
name: 'inlineMath',
group: 'inline',
inline: true,
atom: true,
addOptions() {
return {
onClick: undefined,
katexOptions: undefined,
}
},
addAttributes() {
return {
latex: {
default: '',
parseHTML: element => element.getAttribute('data-latex'),
renderHTML: attributes => {
return {
'data-latex': attributes.latex,
}
},
},
}
},
addCommands() {
return {
insertInlineMath:
options =>
({ editor, tr }) => {
const latex = options.latex
const from = options?.pos ?? editor.state.selection.from
if (!latex) {
return false
}
tr.replaceWith(from, from, this.type.create({ latex }))
return true
},
deleteInlineMath:
options =>
({ editor, tr }) => {
const pos = options?.pos ?? editor.state.selection.$from.pos
const node = editor.state.doc.nodeAt(pos)
if (!node || node.type.name !== this.name) {
return false
}
tr.delete(pos, pos + node.nodeSize)
return true
},
updateInlineMath:
options =>
({ editor, tr }) => {
const latex = options?.latex
let pos = options?.pos
if (pos === undefined) {
pos = editor.state.selection.$from.pos
}
const node = editor.state.doc.nodeAt(pos)
if (!node || node.type.name !== this.name) {
return false
}
tr.setNodeMarkup(pos, this.type, { ...node.attrs, latex })
return true
},
}
},
parseHTML() {
return [
{
tag: 'span[data-type="inline-math"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'inline-math' })]
},
parseMarkdown: (token: any) => {
return {
type: 'inlineMath',
attrs: {
latex: token.latex,
},
}
},
renderMarkdown: node => {
const latex = node.attrs?.latex || ''
return `$${latex}$`
},
markdownTokenizer: {
name: 'inlineMath',
level: 'inline',
start: (src: string) => src.indexOf('$'),
tokenize: (src: string) => {
// Match $latex$ syntax for inline math (but not $$)
const match = src.match(/^\$([^$]+)\$(?!\$)/)
if (!match) {
return undefined
}
const [fullMatch, latex] = match
return {
type: 'inlineMath',
raw: fullMatch,
latex: latex.trim(),
}
},
},
addInputRules() {
return [
new InputRule({
find: /(?<!\$)(\$\$([^$\n]+?)\$\$)(?!\$)/,
handler: ({ state, range, match }) => {
const latex = match[2]
const { tr } = state
const start = range.from
const end = range.to
tr.replaceWith(start, end, this.type.create({ latex }))
},
}),
]
},
addNodeView() {
const { katexOptions } = this.options
return ({ node, getPos }) => {
const wrapper = document.createElement('span')
wrapper.className = 'tiptap-mathematics-render'
if (this.editor.isEditable) {
wrapper.classList.add('tiptap-mathematics-render--editable')
}
wrapper.dataset.type = 'inline-math'
wrapper.setAttribute('data-latex', node.attrs.latex)
function renderMath() {
try {
katex.render(node.attrs.latex, wrapper, katexOptions)
wrapper.classList.remove('inline-math-error')
} catch {
wrapper.textContent = node.attrs.latex
wrapper.classList.add('inline-math-error')
}
}
const handleClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const pos = getPos()
if (pos == null) {
return
}
if (this.options.onClick) {
this.options.onClick(node, pos)
}
}
if (this.options.onClick) {
wrapper.addEventListener('click', handleClick)
}
renderMath()
return {
dom: wrapper,
destroy() {
wrapper.removeEventListener('click', handleClick)
},
}
}
},
})