fix: render inline math while typing
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
} from './editorRawModeSync'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
@@ -185,7 +186,7 @@ function useEditorSetup({
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
|
||||
extensions: [createArrowLigaturesExtension()],
|
||||
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
|
||||
})
|
||||
useFilenameAutolinkGuard(editor)
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
|
||||
165
src/components/mathInputExtension.test.ts
Normal file
165
src/components/mathInputExtension.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
|
||||
function createTransaction() {
|
||||
const transaction = {
|
||||
replaceWith: vi.fn(() => transaction),
|
||||
insertText: vi.fn(() => transaction),
|
||||
scrollIntoView: vi.fn(() => transaction),
|
||||
}
|
||||
return transaction
|
||||
}
|
||||
|
||||
function createView(beforeText: string, transaction: ReturnType<typeof createTransaction>) {
|
||||
const mathNode = { nodeSize: 1 }
|
||||
const selection = {
|
||||
from: beforeText.length,
|
||||
to: beforeText.length,
|
||||
$from: {
|
||||
parent: {
|
||||
isTextblock: true,
|
||||
textBetween: vi.fn(() => beforeText),
|
||||
},
|
||||
parentOffset: beforeText.length,
|
||||
marks: vi.fn(() => []),
|
||||
},
|
||||
}
|
||||
const mathNodeType = { createChecked: vi.fn(() => mathNode) }
|
||||
const view = {
|
||||
composing: false,
|
||||
dispatch: vi.fn(),
|
||||
state: {
|
||||
schema: { nodes: { mathInline: mathNodeType } },
|
||||
selection,
|
||||
storedMarks: null as Array<{ type: { name: string } }> | null,
|
||||
tr: transaction,
|
||||
},
|
||||
}
|
||||
|
||||
return { mathNode, mathNodeType, view }
|
||||
}
|
||||
|
||||
function createDom(registerBeforeInput: (listener: (event: InputEvent) => void) => void) {
|
||||
const dom = {
|
||||
addEventListener: vi.fn((type: string, listener: (event: InputEvent) => void) => {
|
||||
if (type === 'beforeinput') {
|
||||
registerBeforeInput(listener)
|
||||
}
|
||||
}),
|
||||
}
|
||||
return dom
|
||||
}
|
||||
|
||||
function createFixture(beforeText = 'Inline $x^2$') {
|
||||
let beforeInputListener: ((event: InputEvent) => void) | null = null
|
||||
const transaction = createTransaction()
|
||||
const { mathNode, mathNodeType, view } = createView(beforeText, transaction)
|
||||
const dom = createDom((listener) => {
|
||||
beforeInputListener = listener
|
||||
})
|
||||
const editor = {
|
||||
_tiptapEditor: { view },
|
||||
prosemirrorView: view,
|
||||
}
|
||||
const extension = createMathInputExtension()({ editor: editor as never })
|
||||
|
||||
return {
|
||||
dom,
|
||||
extension,
|
||||
fireInput(event: Partial<InputEvent> = {}) {
|
||||
if (!beforeInputListener) {
|
||||
throw new Error('Math input extension did not register a beforeinput listener')
|
||||
}
|
||||
|
||||
const inputEvent = {
|
||||
data: ' ',
|
||||
inputType: 'insertText',
|
||||
isComposing: false,
|
||||
preventDefault: vi.fn(),
|
||||
...event,
|
||||
}
|
||||
|
||||
beforeInputListener(inputEvent as InputEvent)
|
||||
return inputEvent
|
||||
},
|
||||
mathNode,
|
||||
mathNodeType,
|
||||
mount() {
|
||||
const controller = new AbortController()
|
||||
extension.mount?.({
|
||||
dom: dom as never,
|
||||
root: document,
|
||||
signal: controller.signal,
|
||||
})
|
||||
return controller
|
||||
},
|
||||
transaction,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createMathInputExtension', () => {
|
||||
it('registers a beforeinput listener when the editor mounts', () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
fixture.mount()
|
||||
|
||||
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
|
||||
'beforeinput',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
capture: true,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces completed inline math before inserting whitespace', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.mount()
|
||||
|
||||
const event = fixture.fireInput()
|
||||
|
||||
expect(fixture.mathNodeType.createChecked).toHaveBeenCalledWith({ latex: 'x^2' })
|
||||
expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(7, 12, fixture.mathNode)
|
||||
expect(fixture.transaction.insertText).toHaveBeenCalledWith(' ', 8)
|
||||
expect(fixture.transaction.scrollIntoView).toHaveBeenCalled()
|
||||
expect(fixture.view.dispatch).toHaveBeenCalledWith(fixture.transaction)
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('replaces completed inline math before a new paragraph without swallowing the newline', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.mount()
|
||||
|
||||
const event = fixture.fireInput({ data: null, inputType: 'insertParagraph' })
|
||||
|
||||
expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(7, 12, fixture.mathNode)
|
||||
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
|
||||
expect(fixture.view.dispatch).toHaveBeenCalledWith(fixture.transaction)
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-whitespace text input', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.mount()
|
||||
|
||||
const event = fixture.fireInput({ data: '.', inputType: 'insertText' })
|
||||
|
||||
expect(fixture.transaction.replaceWith).not.toHaveBeenCalled()
|
||||
expect(fixture.view.dispatch).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores math-looking input inside inline code', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.view.state.storedMarks = [{ type: { name: 'code' } }]
|
||||
fixture.mount()
|
||||
|
||||
const event = fixture.fireInput()
|
||||
|
||||
expect(fixture.transaction.replaceWith).not.toHaveBeenCalled()
|
||||
expect(fixture.view.dispatch).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
112
src/components/mathInputExtension.ts
Normal file
112
src/components/mathInputExtension.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createExtension } from '@blocknote/core'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { MATH_INLINE_TYPE, readCompletedInlineMathAtEnd } from '../utils/mathMarkdown'
|
||||
|
||||
const INLINE_WHITESPACE_RE = /^[^\S\r\n]$/
|
||||
const NEWLINE_INPUT_TYPES = new Set(['insertParagraph', 'insertLineBreak'])
|
||||
type EditorViewLike = NonNullable<ReturnType<typeof useCreateBlockNote>['prosemirrorView']>
|
||||
|
||||
interface CursorText {
|
||||
beforeText: string
|
||||
parentStart: number
|
||||
}
|
||||
|
||||
interface InlineMathReplacement {
|
||||
from: number
|
||||
latex: string
|
||||
to: number
|
||||
}
|
||||
|
||||
function isInsertedInlineWhitespace(event: InputEvent): event is InputEvent & { data: string } {
|
||||
return event.inputType === 'insertText'
|
||||
&& typeof event.data === 'string'
|
||||
&& INLINE_WHITESPACE_RE.test(event.data)
|
||||
}
|
||||
|
||||
function shouldHandleInput(event: InputEvent): boolean {
|
||||
return isInsertedInlineWhitespace(event) || NEWLINE_INPUT_TYPES.has(event.inputType)
|
||||
}
|
||||
|
||||
function shouldSkipInput(event: InputEvent, view: EditorViewLike): boolean {
|
||||
if (event.isComposing) return true
|
||||
if (view.composing) return true
|
||||
return !shouldHandleInput(event)
|
||||
}
|
||||
|
||||
function selectionHasCodeMark(view: EditorViewLike): boolean {
|
||||
const marks = view.state.storedMarks ?? view.state.selection.$from.marks()
|
||||
return marks.some((mark: { type: { name: string } }) => mark.type.name === 'code')
|
||||
}
|
||||
|
||||
function readCursorText(view: EditorViewLike): CursorText | null {
|
||||
const { from, to, $from } = view.state.selection
|
||||
if (from !== to) return null
|
||||
if (!$from.parent.isTextblock) return null
|
||||
|
||||
return {
|
||||
beforeText: $from.parent.textBetween(0, $from.parentOffset, '', ''),
|
||||
parentStart: from - $from.parentOffset,
|
||||
}
|
||||
}
|
||||
|
||||
function readInlineMathReplacement(view: EditorViewLike): InlineMathReplacement | null {
|
||||
if (selectionHasCodeMark(view)) return null
|
||||
|
||||
const cursorText = readCursorText(view)
|
||||
if (!cursorText) return null
|
||||
|
||||
const math = readCompletedInlineMathAtEnd({ text: cursorText.beforeText })
|
||||
if (!math) return null
|
||||
|
||||
return {
|
||||
from: cursorText.parentStart + math.start,
|
||||
latex: math.latex,
|
||||
to: cursorText.parentStart + math.end + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCompletedInlineMath(
|
||||
view: EditorViewLike,
|
||||
trailingText?: string,
|
||||
): EditorViewLike['state']['tr'] | null {
|
||||
const replacement = readInlineMathReplacement(view)
|
||||
const mathNodeType = view.state.schema.nodes[MATH_INLINE_TYPE]
|
||||
if (!replacement || !mathNodeType) return null
|
||||
|
||||
const mathNode = mathNodeType.createChecked({ latex: replacement.latex })
|
||||
const transaction = view.state.tr.replaceWith(replacement.from, replacement.to, mathNode)
|
||||
|
||||
if (trailingText !== undefined) {
|
||||
transaction.insertText(trailingText, replacement.from + mathNode.nodeSize)
|
||||
}
|
||||
|
||||
return transaction.scrollIntoView()
|
||||
}
|
||||
|
||||
export const createMathInputExtension = createExtension(({ editor }) => {
|
||||
const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView
|
||||
|
||||
return {
|
||||
key: 'mathInput',
|
||||
mount: ({ dom, signal }) => {
|
||||
const handleBeforeInput = (event: InputEvent) => {
|
||||
const view = readView()
|
||||
if (!view || shouldSkipInput(event, view)) return
|
||||
|
||||
const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined
|
||||
const transaction = replaceCompletedInlineMath(view, trailingText)
|
||||
if (!transaction) return
|
||||
|
||||
view.dispatch(transaction)
|
||||
if (trailingText !== undefined) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
dom.addEventListener('beforeinput', handleBeforeInput as EventListener, {
|
||||
capture: true,
|
||||
signal,
|
||||
})
|
||||
},
|
||||
} as const
|
||||
})
|
||||
@@ -518,6 +518,52 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes rich inline math nodes back to Markdown on editor changes', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const onContentChange = vi.fn()
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
|
||||
(blocks as Array<{ content?: Array<{ text?: string }> }>)
|
||||
.map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
|
||||
.join('\n\n')
|
||||
))
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useEditorTabSwap({
|
||||
tabs: [tabA],
|
||||
activeTabPath: 'a.md',
|
||||
editor: mockEditor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
docRef.current = [{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Inline ', styles: {} },
|
||||
{ type: 'mathInline', props: { latex: 'x^2' } },
|
||||
],
|
||||
children: [],
|
||||
}]
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'a.md',
|
||||
'---\ntitle: Note A\n---\nInline $x^2$\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -364,8 +364,7 @@ function useEditorChangeHandler(options: {
|
||||
if (!tab) return
|
||||
|
||||
const blocks = editor.document
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
const rawBodyMarkdown = serializeEditorBody(editor)
|
||||
const bodyMarkdown = vaultPathRef.current
|
||||
? portableImageUrls(rawBodyMarkdown, vaultPathRef.current)
|
||||
: rawBodyMarkdown
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MATH_INLINE_TYPE,
|
||||
injectMathInBlocks,
|
||||
preProcessMathMarkdown,
|
||||
readCompletedInlineMathAtEnd,
|
||||
serializeMathAwareBlocks,
|
||||
} from './mathMarkdown'
|
||||
|
||||
@@ -107,4 +108,18 @@ describe('math markdown round-trip', () => {
|
||||
|
||||
expect(preProcessMathMarkdown({ markdown })).toBe(markdown)
|
||||
})
|
||||
|
||||
it('recognizes completed inline math at the end of text', () => {
|
||||
expect(readCompletedInlineMathAtEnd({ text: 'Energy is $E=mc^2$' })).toEqual({
|
||||
latex: 'E=mc^2',
|
||||
start: 10,
|
||||
end: 17,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores incomplete, escaped, and display-style dollar sequences at text end', () => {
|
||||
expect(readCompletedInlineMathAtEnd({ text: 'Energy is $E=mc^2' })).toBeNull()
|
||||
expect(readCompletedInlineMathAtEnd({ text: String.raw`Energy is $E=mc^2\$` })).toBeNull()
|
||||
expect(readCompletedInlineMathAtEnd({ text: '$$x^2$$' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,10 @@ interface InlineMathMatch extends LatexPayload {
|
||||
end: number
|
||||
}
|
||||
|
||||
interface CompletedInlineMathMatch extends InlineMathMatch {
|
||||
start: number
|
||||
}
|
||||
|
||||
interface MarkdownSource {
|
||||
markdown: string
|
||||
}
|
||||
@@ -137,6 +141,30 @@ function readInlineMath({ text, index }: TextPosition): InlineMathMatch | null {
|
||||
return isValidInlineLatex({ latex }) ? { latex, end } : null
|
||||
}
|
||||
|
||||
function isCompletedInlineMathEnd({ text, index }: TextPosition): boolean {
|
||||
return isInlineMathEnd({ text, index }) && text[index - 1] !== '$'
|
||||
}
|
||||
|
||||
function findCompletedInlineMathStart({ text, index: end }: TextPosition): number {
|
||||
for (let i = end - 1; i >= 0; i--) {
|
||||
if (isInlineMathEnd({ text, index: i }) && text[i - 1] !== '$') {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
export function readCompletedInlineMathAtEnd({ text }: { text: string }): CompletedInlineMathMatch | null {
|
||||
const end = text.length - 1
|
||||
if (end < 1 || !isCompletedInlineMathEnd({ text, index: end })) return null
|
||||
|
||||
const start = findCompletedInlineMathStart({ text, index: end })
|
||||
if (start === -1) return null
|
||||
|
||||
const latex = text.slice(start + 1, end)
|
||||
return isValidInlineLatex({ latex }) ? { latex, start, end } : null
|
||||
}
|
||||
|
||||
function replaceInlineMath({ line }: MarkdownLine): string {
|
||||
let result = ''
|
||||
let index = 0
|
||||
|
||||
Reference in New Issue
Block a user