diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 979f9075..88989184 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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 diff --git a/src/components/mathInputExtension.test.ts b/src/components/mathInputExtension.test.ts new file mode 100644 index 00000000..a928496a --- /dev/null +++ b/src/components/mathInputExtension.test.ts @@ -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) { + 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 = {}) { + 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() + }) +}) diff --git a/src/components/mathInputExtension.ts b/src/components/mathInputExtension.ts new file mode 100644 index 00000000..05a1d8ea --- /dev/null +++ b/src/components/mathInputExtension.ts @@ -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['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 +}) diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 683d5c41..14171d19 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -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 }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 39cd9793..e62dfeaf 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -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 diff --git a/src/utils/mathMarkdown.test.ts b/src/utils/mathMarkdown.test.ts index a01df89f..97aa1f4b 100644 --- a/src/utils/mathMarkdown.test.ts +++ b/src/utils/mathMarkdown.test.ts @@ -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() + }) }) diff --git a/src/utils/mathMarkdown.ts b/src/utils/mathMarkdown.ts index 59c84024..08bd859f 100644 --- a/src/utils/mathMarkdown.ts +++ b/src/utils/mathMarkdown.ts @@ -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