snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
149
packages/extension-mathematics/src/extensions/BlockMath.test.ts
Normal file
149
packages/extension-mathematics/src/extensions/BlockMath.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import { BulletList, ListItem } from '@tiptap/extension-list'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { BlockMath } from './BlockMath.js'
|
||||
import { InlineMath } from './InlineMath.js'
|
||||
|
||||
describe('BlockMath', () => {
|
||||
let editor: Editor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
})
|
||||
|
||||
describe('input rule', () => {
|
||||
it('replaces an empty host paragraph instead of leaving it behind', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BlockMath],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: '$$$x^2$$' }] }],
|
||||
},
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size)
|
||||
|
||||
editor.view.someProp('handleTextInput', f =>
|
||||
f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'),
|
||||
)
|
||||
|
||||
expect(editor.getJSON()).toEqual({
|
||||
type: 'doc',
|
||||
content: [{ type: 'blockMath', attrs: { latex: 'x^2' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('inserts a sibling block math node inside a list item without breaking the schema', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BulletList, ListItem, BlockMath],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: '$$$x^2$$' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 3)
|
||||
|
||||
editor.view.someProp('handleTextInput', f =>
|
||||
f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'),
|
||||
)
|
||||
|
||||
expect(editor.getJSON()).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph' }, { type: 'blockMath', attrs: { latex: 'x^2' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire when the match would not start at the textblock start', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BlockMath],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello $$$x^2$$' }] }],
|
||||
},
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size)
|
||||
|
||||
const handled = editor.view.someProp('handleTextInput', f =>
|
||||
f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'),
|
||||
)
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor.getJSON()).toEqual({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello $$$x^2$$' }] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateBlockMath', () => {
|
||||
function createEditor(latex: string) {
|
||||
return new Editor({
|
||||
extensions: [Document, Paragraph, Text, BlockMath],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'blockMath', attrs: { latex } }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('clears the latex attribute when given an empty string', () => {
|
||||
editor = createEditor('x^2')
|
||||
|
||||
editor.commands.updateBlockMath({ latex: '', pos: 0 })
|
||||
|
||||
expect(editor.state.doc.firstChild?.attrs.latex).toBe('')
|
||||
})
|
||||
|
||||
it('matches updateInlineMath behavior when clearing latex with an empty string', () => {
|
||||
const blockEditor = createEditor('x^2')
|
||||
blockEditor.commands.updateBlockMath({ latex: '', pos: 0 })
|
||||
const blockEmpty = blockEditor.state.doc.firstChild?.attrs.latex
|
||||
blockEditor.destroy()
|
||||
|
||||
const inlineEditor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, InlineMath],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'inlineMath', attrs: { latex: 'x^2' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const inlinePos = 1
|
||||
inlineEditor.commands.updateInlineMath({ latex: '', pos: inlinePos })
|
||||
const inlineEmpty = inlineEditor.state.doc.nodeAt(inlinePos)?.attrs.latex
|
||||
inlineEditor.destroy()
|
||||
|
||||
expect(blockEmpty).toBe(inlineEmpty)
|
||||
})
|
||||
})
|
||||
})
|
||||
302
packages/extension-mathematics/src/extensions/BlockMath.ts
Normal file
302
packages/extension-mathematics/src/extensions/BlockMath.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
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 BlockMath extension.
|
||||
*/
|
||||
export type BlockMathOptions = {
|
||||
/**
|
||||
* KaTeX specific options
|
||||
* @see https://katex.org/docs/options.html
|
||||
* @example
|
||||
* ```ts
|
||||
* katexOptions: {
|
||||
* displayMode: true,
|
||||
* throwOnError: false,
|
||||
* },
|
||||
*/
|
||||
katexOptions?: KatexOptions
|
||||
|
||||
/**
|
||||
* Optional click handler for block math nodes.
|
||||
* Called when a user clicks on a block math expression in the editor.
|
||||
*
|
||||
* @param node - The ProseMirror node representing the block math element
|
||||
* @param pos - The position of the node within the document
|
||||
* @example
|
||||
* ```ts
|
||||
* onClick: (node, pos) => {
|
||||
* console.log('Block math clicked:', node.attrs.latex, 'at position:', pos)
|
||||
* },
|
||||
* ```
|
||||
*/
|
||||
onClick?: (node: PMNode, pos: number) => void
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
insertBlockMath: {
|
||||
/**
|
||||
* Inserts a math block node with LaTeX string.
|
||||
* @param options - Options for inserting block math.
|
||||
* @returns ReturnType
|
||||
*/
|
||||
insertBlockMath: (options: { latex: string; pos?: number }) => ReturnType
|
||||
|
||||
/**
|
||||
* Deletes a block math node.
|
||||
* @returns ReturnType
|
||||
*/
|
||||
deleteBlockMath: (options?: { pos?: number }) => ReturnType
|
||||
|
||||
/**
|
||||
* Update block math node with optional LaTeX string.
|
||||
* @param options - Options for updating block math.
|
||||
* @returns ReturnType
|
||||
*/
|
||||
updateBlockMath: (options?: { latex: string; pos?: number }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BlockMath is a Tiptap extension for rendering block mathematical expressions using KaTeX.
|
||||
* It allows users to insert LaTeX formatted math expressions block within text.
|
||||
* It supports rendering, input rules for LaTeX syntax, and click handling for interaction.
|
||||
*
|
||||
* @example
|
||||
* ```javascript
|
||||
* import { BlockMath } from '@tiptap/extension-mathematics'
|
||||
* import { Editor } from '@tiptap/core'
|
||||
*
|
||||
* const editor = new Editor({
|
||||
* extensions: [
|
||||
* BlockMath.configure({
|
||||
* onClick: (node, pos) => {
|
||||
* console.log('Block math clicked:', node.attrs.latex, 'at position:', pos)
|
||||
* },
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
*/
|
||||
export const BlockMath = Node.create<BlockMathOptions>({
|
||||
name: 'blockMath',
|
||||
|
||||
group: 'block',
|
||||
|
||||
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 {
|
||||
insertBlockMath:
|
||||
options =>
|
||||
({ commands, editor }) => {
|
||||
const { latex, pos } = options
|
||||
|
||||
if (!latex) {
|
||||
return false
|
||||
}
|
||||
|
||||
return commands.insertContentAt(pos ?? editor.state.selection.from, {
|
||||
type: this.name,
|
||||
attrs: { latex },
|
||||
})
|
||||
},
|
||||
|
||||
deleteBlockMath:
|
||||
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
|
||||
},
|
||||
|
||||
updateBlockMath:
|
||||
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: latex ?? node.attrs.latex,
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="block-math"]',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'block-math' })]
|
||||
},
|
||||
|
||||
parseMarkdown: (token: any) => {
|
||||
return {
|
||||
type: 'blockMath',
|
||||
attrs: {
|
||||
latex: token.latex,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
renderMarkdown: node => {
|
||||
const latex = node.attrs?.latex || ''
|
||||
|
||||
const output = ['$$', latex, '$$']
|
||||
return output.join('\n')
|
||||
},
|
||||
|
||||
markdownTokenizer: {
|
||||
name: 'blockMath',
|
||||
level: 'block',
|
||||
start: (src: string) => src.indexOf('$$'),
|
||||
tokenize: (src: string) => {
|
||||
// Match $$latex$$ syntax for block math
|
||||
const match = src.match(/^\$\$([^$]+)\$\$/)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [fullMatch, latex] = match
|
||||
|
||||
return {
|
||||
type: 'blockMath',
|
||||
raw: fullMatch,
|
||||
latex: latex.trim(),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^\$\$\$([^$]+)\$\$\$$/,
|
||||
handler: ({ state, range, match }) => {
|
||||
const [, latex] = match
|
||||
const { tr } = state
|
||||
const $from = state.doc.resolve(range.from)
|
||||
const node = this.type.create({ latex })
|
||||
|
||||
const consumesHostTextblock =
|
||||
$from.depth > 0 &&
|
||||
$from.parent.isTextblock &&
|
||||
range.from === $from.start() &&
|
||||
range.to === $from.end()
|
||||
// Whether the node containing the host textblock can replace it with a blockMath node.
|
||||
const canReplaceHostTextblock =
|
||||
consumesHostTextblock &&
|
||||
$from.node(-1).canReplaceWith($from.index(-1), $from.indexAfter(-1), this.type)
|
||||
|
||||
const replacementRange = canReplaceHostTextblock
|
||||
? { from: $from.before(), to: $from.after() }
|
||||
: range
|
||||
|
||||
tr.replaceWith(replacementRange.from, replacementRange.to, node)
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
const { katexOptions } = this.options
|
||||
|
||||
return ({ node, getPos }) => {
|
||||
const wrapper = document.createElement('div')
|
||||
const innerWrapper = document.createElement('div')
|
||||
wrapper.className = 'tiptap-mathematics-render'
|
||||
|
||||
if (this.editor.isEditable) {
|
||||
wrapper.classList.add('tiptap-mathematics-render--editable')
|
||||
}
|
||||
|
||||
innerWrapper.className = 'block-math-inner'
|
||||
wrapper.dataset.type = 'block-math'
|
||||
wrapper.setAttribute('data-latex', node.attrs.latex)
|
||||
wrapper.appendChild(innerWrapper)
|
||||
|
||||
function renderMath() {
|
||||
try {
|
||||
katex.render(node.attrs.latex, innerWrapper, katexOptions)
|
||||
wrapper.classList.remove('block-math-error')
|
||||
} catch {
|
||||
wrapper.textContent = node.attrs.latex
|
||||
wrapper.classList.add('block-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)
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { InlineMath } from './InlineMath.js'
|
||||
|
||||
describe('InlineMath', () => {
|
||||
let editor: Editor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
})
|
||||
|
||||
it('preserves previous character to input rule match', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, InlineMath],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Hello $$x$' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.nodeSize)
|
||||
|
||||
editor.view.someProp('handleTextInput', f =>
|
||||
f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'),
|
||||
)
|
||||
|
||||
expect(editor.getJSON()).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
// Ensure previous character is preserved (e.g., space)
|
||||
text: 'Hello ',
|
||||
},
|
||||
{
|
||||
type: 'inlineMath',
|
||||
attrs: { latex: 'x' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('ensure unmatched triple $ expressions', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, InlineMath],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Hello $$$x$' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.nodeSize)
|
||||
|
||||
const handled = editor.view.someProp('handleTextInput', f =>
|
||||
f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'),
|
||||
)
|
||||
|
||||
// Expect no input rule to match
|
||||
expect(handled).toBeFalsy()
|
||||
})
|
||||
})
|
||||
288
packages/extension-mathematics/src/extensions/InlineMath.ts
Normal file
288
packages/extension-mathematics/src/extensions/InlineMath.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
2
packages/extension-mathematics/src/extensions/index.ts
Normal file
2
packages/extension-mathematics/src/extensions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './BlockMath.js'
|
||||
export * from './InlineMath.js'
|
||||
8
packages/extension-mathematics/src/index.ts
Normal file
8
packages/extension-mathematics/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Mathematics } from './mathematics.js'
|
||||
|
||||
export * from './extensions/index.js'
|
||||
export * from './mathematics.js'
|
||||
export * from './types.js'
|
||||
export * from './utils.js'
|
||||
|
||||
export default Mathematics
|
||||
77
packages/extension-mathematics/src/mathematics.ts
Normal file
77
packages/extension-mathematics/src/mathematics.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
import { BlockMath, InlineMath } from './extensions/index.js'
|
||||
import type { MathematicsOptions } from './types.js'
|
||||
|
||||
/**
|
||||
* Mathematics extension for Tiptap that provides both inline and block math support using KaTeX.
|
||||
* This extension combines the InlineMath and BlockMath extensions to provide a complete
|
||||
* mathematical expression solution for rich text editing. It supports LaTeX syntax,
|
||||
* custom rendering options, and interactive math nodes.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Editor } from '@tiptap/core'
|
||||
* import { Mathematics } from '@tiptap/extension-mathematics'
|
||||
* import { migrateMathStrings } from '@tiptap/extension-mathematics/utils'
|
||||
*
|
||||
* const editor = new Editor({
|
||||
* extensions: [
|
||||
* Mathematics.configure({
|
||||
* inlineOptions: {
|
||||
* onClick: (node, pos) => {
|
||||
* console.log('Inline math clicked:', node.attrs.latex)
|
||||
* }
|
||||
* },
|
||||
* blockOptions: {
|
||||
* onClick: (node, pos) => {
|
||||
* console.log('Block math clicked:', node.attrs.latex)
|
||||
* }
|
||||
* },
|
||||
* katexOptions: {
|
||||
* displayMode: false,
|
||||
* throwOnError: false,
|
||||
* macros: {
|
||||
* '\\RR': '\\mathbb{R}',
|
||||
* '\\ZZ': '\\mathbb{Z}'
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
* ],
|
||||
* content: `
|
||||
* <p>Inline math: $E = mc^2$</p>
|
||||
* <div data-type="block-math" data-latex="\\sum_{i=1}^{n} x_i = X"></div>
|
||||
* `,
|
||||
* onCreate({ editor }) {
|
||||
* // Optional: Migrate existing math strings to math nodes
|
||||
* migrateMathStrings(editor)
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const Mathematics = Extension.create<MathematicsOptions>({
|
||||
name: 'Mathematics',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
inlineOptions: undefined,
|
||||
blockOptions: undefined,
|
||||
katexOptions: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addExtensions() {
|
||||
return [
|
||||
BlockMath.configure({
|
||||
...this.options.blockOptions,
|
||||
katexOptions: this.options.katexOptions,
|
||||
}),
|
||||
InlineMath.configure({
|
||||
...this.options.inlineOptions,
|
||||
katexOptions: this.options.katexOptions,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export default Mathematics
|
||||
24
packages/extension-mathematics/src/types.ts
Normal file
24
packages/extension-mathematics/src/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { KatexOptions } from 'katex'
|
||||
|
||||
import type { BlockMathOptions, InlineMathOptions } from './extensions/index.js'
|
||||
|
||||
/**
|
||||
* Configuration options for the Mathematics extension.
|
||||
* This type defines the available customization options for both inline and block math rendering.
|
||||
*/
|
||||
export type MathematicsOptions = {
|
||||
/** Configuration options specific to inline math nodes */
|
||||
inlineOptions?: Omit<InlineMathOptions, 'katexOptions'>
|
||||
/** Configuration options specific to block math nodes */
|
||||
blockOptions?: Omit<BlockMathOptions, 'katexOptions'>
|
||||
/** KaTeX-specific rendering options passed to the KaTeX library */
|
||||
katexOptions?: KatexOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended mathematics options that include an editor instance.
|
||||
* This type combines the base mathematics options with an editor reference,
|
||||
* typically used internally by the extension for operations that require editor access.
|
||||
*/
|
||||
export type MathematicsOptionsWithEditor = MathematicsOptions & { editor: Editor }
|
||||
105
packages/extension-mathematics/src/utils.ts
Normal file
105
packages/extension-mathematics/src/utils.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
|
||||
/**
|
||||
* Regular expression to match LaTeX math strings wrapped in single dollar signs.
|
||||
* This should not catch dollar signs which are not part of a math expression,
|
||||
* like those used for currency or other purposes.
|
||||
* It ensures that the dollar signs are not preceded or followed by digits,
|
||||
* allowing for proper identification of inline math expressions.
|
||||
*
|
||||
* - `$x^2 + y^2 = z^2$` will match
|
||||
* - `This is $inline math$ in text.` will match
|
||||
* - `This is $100$ dollars.` will not match (as it is not a math expression)
|
||||
* - `This is $x^2 + y^2 = z^2$ and $100$ dollars.` will match both math expressions
|
||||
*/
|
||||
export const mathMigrationRegex = /\$(?!\d+\$)(.+?)\$(?!\d)/g
|
||||
|
||||
/**
|
||||
* Creates a transaction that migrates existing math strings in the document to new math nodes.
|
||||
* This function traverses the document and replaces LaTeX math syntax (wrapped in single dollar signs)
|
||||
* with proper inline math nodes, preserving the mathematical content.
|
||||
*
|
||||
* @param editor - The editor instance containing the schema and configuration
|
||||
* @param tr - The transaction to modify with the migration operations
|
||||
* @returns The modified transaction with math string replacements
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const editor = new Editor({ ... })
|
||||
* const tr = editor.state.tr
|
||||
* const updatedTr = createMathMigrateTransaction(editor, tr)
|
||||
* editor.view.dispatch(updatedTr)
|
||||
* ```
|
||||
*/
|
||||
export function createMathMigrateTransaction(
|
||||
editor: Editor,
|
||||
tr: Transaction,
|
||||
regex: RegExp = mathMigrationRegex,
|
||||
) {
|
||||
// we traverse the document and replace all math nodes with the new math nodes
|
||||
tr.doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text || !node.text.includes('$')) {
|
||||
return
|
||||
}
|
||||
|
||||
const { text } = node
|
||||
|
||||
const match = node.text.match(regex)
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
|
||||
match.forEach(mathMatch => {
|
||||
const start = text.indexOf(mathMatch)
|
||||
const end = start + mathMatch.length
|
||||
|
||||
const from = tr.mapping.map(pos + start)
|
||||
|
||||
const $from = tr.doc.resolve(from)
|
||||
const parent = $from.parent
|
||||
const index = $from.index()
|
||||
|
||||
const { inlineMath } = editor.schema.nodes
|
||||
|
||||
if (!parent.canReplaceWith(index, index + 1, inlineMath)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the math syntax with a new math node
|
||||
tr.replaceWith(
|
||||
tr.mapping.map(pos + start),
|
||||
tr.mapping.map(pos + end),
|
||||
inlineMath.create({ latex: mathMatch.slice(1, -1) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// don't add to history
|
||||
tr.setMeta('addToHistory', false)
|
||||
return tr
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates existing math strings in the editor document to math nodes.
|
||||
* This function creates and dispatches a transaction that converts LaTeX math syntax
|
||||
* (text wrapped in single dollar signs) into proper inline math nodes. The migration
|
||||
* happens immediately and is not added to the editor's history.
|
||||
*
|
||||
* @param editor - The editor instance to perform the migration on
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const editor = new Editor({
|
||||
* extensions: [Mathematics],
|
||||
* content: 'This is inline math: $x^2 + y^2 = z^2$ in text.'
|
||||
* })
|
||||
*
|
||||
* // Math strings will be automatically migrated to math nodes
|
||||
* migrateMathStrings(editor)
|
||||
* ```
|
||||
*/
|
||||
export function migrateMathStrings(editor: Editor, regex: RegExp = mathMigrationRegex) {
|
||||
const tr = createMathMigrateTransaction(editor, editor.state.tr, regex)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
Reference in New Issue
Block a user