fix: edit display math as block property

This commit is contained in:
Tolaria
2026-06-01 10:05:11 +02:00
parent 74891e0283
commit 7fa284f0d0
7 changed files with 248 additions and 37 deletions

View File

@@ -606,7 +606,7 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
- Double-clicking rendered math, or pressing `Enter`/`F2` when a math node is selected, restores the Markdown source and selects only the formula body for editing.
- Double-clicking rendered display math edits the math block's `latex` property in-place; Markdown delimiters remain owned by serialization. Inline math can still be reopened as source text for direct editing.
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.

View File

@@ -52,6 +52,7 @@ import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
import { ImageLightbox } from './ImageLightbox'
import { ActionTooltip } from './ui/action-tooltip'
import { Button } from './ui/button'
import { subscribeRichEditorExternalChange } from './editorExternalChangeEvents'
import {
activatePlainTextPasteTarget,
registerPlainTextPasteTarget,
@@ -1345,6 +1346,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
_wikilinkEntriesRef.current = entries
}, [entries])
useEffect(() => {
return subscribeRichEditorExternalChange(editor, handleEditorChange)
}, [editor, handleEditorChange])
useEffect(() => {
const container = containerRef.current
if (!container) return

View File

@@ -0,0 +1,40 @@
export const RICH_EDITOR_EXTERNAL_CHANGE_EVENT = 'tolaria:rich-editor-external-change'
export type RichEditorExternalChangeSource = object
type RichEditorExternalChangeDetail = {
source: RichEditorExternalChangeSource
}
function isExternalChangeForSource(
event: Event,
source: RichEditorExternalChangeSource,
): event is CustomEvent<RichEditorExternalChangeDetail> {
return event instanceof CustomEvent && event.detail?.source === source
}
export function dispatchRichEditorExternalChange(
source: RichEditorExternalChangeSource,
target: EventTarget = window,
) {
target.dispatchEvent(new CustomEvent<RichEditorExternalChangeDetail>(
RICH_EDITOR_EXTERNAL_CHANGE_EVENT,
{
bubbles: true,
composed: true,
detail: { source },
},
))
}
export function subscribeRichEditorExternalChange(
source: RichEditorExternalChangeSource,
onChange: () => void,
) {
const handleExternalChange = (event: Event) => {
if (isExternalChangeForSource(event, source)) onChange()
}
window.addEventListener(RICH_EDITOR_EXTERNAL_CHANGE_EVENT, handleExternalChange)
return () => window.removeEventListener(RICH_EDITOR_EXTERNAL_CHANGE_EVENT, handleExternalChange)
}

View File

@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MathBlockEditor } from './editorSchema'
import { subscribeRichEditorExternalChange } from './editorExternalChangeEvents'
function renderMathBlockEditor(latex = '\\sqrt{x}') {
const editor = {
focus: vi.fn(),
updateBlock: vi.fn(),
}
const block = {
id: 'math-block',
props: { latex },
}
render(<MathBlockEditor block={block} editor={editor} />)
return { block, editor }
}
describe('MathBlockEditor', () => {
it('renders display math without exposing Markdown delimiters as editor content', () => {
renderMathBlockEditor()
expect(document.querySelector('.math--block')).toHaveAttribute('data-latex', '\\sqrt{x}')
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('edits the math block latex prop instead of inserting Markdown source', () => {
const { editor } = renderMathBlockEditor()
const onExternalChange = vi.fn()
const unsubscribe = subscribeRichEditorExternalChange(editor, onExternalChange)
fireEvent.doubleClick(document.querySelector('.math--block')!)
const source = screen.getByRole('textbox')
fireEvent.change(source, { target: { value: '\\frac{1}{2}' } })
fireEvent.blur(source)
expect(editor.updateBlock).toHaveBeenCalledWith('math-block', {
props: { latex: '\\frac{1}{2}' },
})
expect(editor.updateBlock).not.toHaveBeenCalledWith('math-block', {
props: { latex: '$$\\frac{1}{2}$$' },
})
expect(onExternalChange).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('cancels math block editing without changing the block', () => {
const { editor } = renderMathBlockEditor()
fireEvent.doubleClick(document.querySelector('.math--block')!)
const source = screen.getByRole('textbox')
fireEvent.change(source, { target: { value: '\\frac{1}{2}' } })
fireEvent.keyDown(source, { key: 'Escape' })
fireEvent.blur(source)
expect(editor.updateBlock).not.toHaveBeenCalled()
expect(editor.focus).toHaveBeenCalled()
})
})

View File

@@ -16,7 +16,7 @@ import {
VideoBlock,
VideoToExternalHTML,
} from '@blocknote/react'
import { lazy, Suspense, type ComponentProps } from 'react'
import { lazy, Suspense, useEffect, useRef, useState, type ComponentProps, type KeyboardEvent } from 'react'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { resolveEntry } from '../utils/wikilink'
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown'
@@ -29,6 +29,8 @@ import { MermaidDiagram } from './MermaidDiagram'
import { SafeHtmlSpan } from './SafeMarkup'
import { updateTldrawBlockPropsSafely } from './tldrawBlockProps'
import { useExternalMediaPreview } from '../utils/mediaPreviewRuntime'
import { Textarea } from './ui/textarea'
import { dispatchRichEditorExternalChange } from './editorExternalChangeEvents'
const TldrawWhiteboard = lazy(() => import('./TldrawWhiteboard').then(module => ({
default: module.TldrawWhiteboard,
@@ -107,6 +109,111 @@ function MathRender({ latex, displayMode }: { latex: string; displayMode: boolea
)
}
type MathBlockEditorProps = {
block: {
id: string
props: {
latex: string
}
}
editor: {
domElement?: EventTarget | null
focus?: () => void
updateBlock: (blockId: string, update: { props: { latex: string } }) => void
}
}
function stopMathEditorEvent(event: { stopPropagation: () => void }) {
event.stopPropagation()
}
function isCommandModifierPressed(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.metaKey || event.ctrlKey
}
function isCommitMathEditShortcut(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.key === 'Enter' && isCommandModifierPressed(event)
}
export function MathBlockEditor({ block, editor }: MathBlockEditorProps) {
const currentLatex = block.props.latex
const editingSessionRef = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [draftLatex, setDraftLatex] = useState(currentLatex)
const [editing, setEditing] = useState(false)
useEffect(() => {
if (!editing) return
textareaRef.current?.focus()
textareaRef.current?.select()
}, [editing])
const startEditing = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
event.preventDefault()
event.stopPropagation()
setDraftLatex(currentLatex)
editingSessionRef.current = true
setEditing(true)
}
const finishEditing = () => {
if (!editingSessionRef.current) return
editingSessionRef.current = false
setEditing(false)
if (draftLatex !== currentLatex) {
editor.updateBlock(block.id, { props: { latex: draftLatex } })
dispatchRichEditorExternalChange(editor, editor.domElement ?? undefined)
}
editor.focus?.()
}
const cancelEditing = () => {
if (!editingSessionRef.current) return
editingSessionRef.current = false
setDraftLatex(currentLatex)
setEditing(false)
editor.focus?.()
}
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelEditing()
return
}
if (isCommitMathEditShortcut(event)) {
event.preventDefault()
event.stopPropagation()
finishEditing()
}
}
return (
<div
className={editing ? 'math-block-shell math-block-shell--editing' : 'math-block-shell'}
onDoubleClick={editing ? stopMathEditorEvent : startEditing}
>
{editing ? (
<div contentEditable={false} onMouseDown={stopMathEditorEvent}>
<Textarea
ref={textareaRef}
aria-label={`Math: ${currentLatex}`}
className="min-h-24 font-mono text-sm"
value={draftLatex}
onBlur={finishEditing}
onChange={(event) => setDraftLatex(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
) : (
<MathRender latex={currentLatex} displayMode />
)}
</div>
)
}
export const MathInline = createReactInlineContentSpec(
{
type: MATH_INLINE_TYPE,
@@ -132,9 +239,7 @@ const MathBlock = createReactBlockSpec(
},
{
render: (props) => (
<div className="math-block-shell">
<MathRender latex={props.block.props.latex} displayMode />
</div>
<MathBlockEditor block={props.block} editor={props.editor} />
),
},
)

View File

@@ -309,10 +309,9 @@ describe('createMathInputExtension', () => {
})
})
it('restores rendered block math source on double click', () => {
it('leaves rendered block math intact on double click', () => {
const fixture = createFixture()
const latex = '\\sqrt{x}'
const source = `$$\n${latex}\n$$`
const mathElement = document.createElement('span')
mathElement.className = 'math math--block'
mathElement.dataset.latex = latex
@@ -323,23 +322,11 @@ describe('createMathInputExtension', () => {
const event = fixture.fireMathDoubleClick(mathElement)
expect(fixture.textNodeFor).toHaveBeenCalledWith(source)
expect(fixture.paragraphNodeType.createChecked).toHaveBeenCalledWith({}, {
text: source,
type: 'text',
})
expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(20, 21, {
attrs: {},
content: {
text: source,
type: 'text',
},
type: 'paragraph',
})
expect(fixture.setTextSelection).toHaveBeenCalledWith({ from: 24, to: 32 })
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(trackEvent).toHaveBeenCalledWith('math_source_edit_reopened', {
expect(fixture.transaction.replaceWith).not.toHaveBeenCalled()
expect(fixture.setTextSelection).not.toHaveBeenCalled()
expect(event.preventDefault).not.toHaveBeenCalled()
expect(event.stopPropagation).not.toHaveBeenCalled()
expect(trackEvent).not.toHaveBeenCalledWith('math_source_edit_reopened', {
activation: 'pointer',
math_mode: 'block',
})
@@ -370,4 +357,22 @@ describe('createMathInputExtension', () => {
math_mode: 'inline',
})
})
it('leaves selected block math intact from the keyboard', () => {
const fixture = createFixture()
const selectedNode = createMathNode(MATH_BLOCK_TYPE, '\\sqrt{x}')
fixture.view.state.selection = {
from: 12,
node: selectedNode,
to: 13,
}
fixture.mount()
const event = fixture.fireKeyDown({ key: 'Enter' })
expect(fixture.transaction.replaceWith).not.toHaveBeenCalled()
expect(fixture.setTextSelection).not.toHaveBeenCalled()
expect(event.preventDefault).not.toHaveBeenCalled()
expect(event.stopPropagation).not.toHaveBeenCalled()
})
})

View File

@@ -134,12 +134,12 @@ function readMathInputTransaction(
}
}
function mathSource({ kind, latex }: { kind: MathKind; latex: string }): string {
return kind === 'block' ? `$$\n${latex}\n$$` : `$${latex}$`
function mathSource({ latex }: { latex: string }): string {
return `$${latex}$`
}
function mathLatexSelectionRange({ from, kind, latex }: { from: number; kind: MathKind; latex: string }) {
const sourceStart = kind === 'block' ? from + 1 + '$$\n'.length : from + 1
function mathLatexSelectionRange({ from, latex }: { from: number; latex: string }) {
const sourceStart = from + 1
return { from: sourceStart, to: sourceStart + latex.length }
}
@@ -250,7 +250,7 @@ function readSelectedMathLocation(view: EditorViewLike): MathNodeLocation | null
const kind = mathKindForNode(selection.node)
const latex = readLatexAttr(selection.node)
if (!kind || latex === null) return null
if (kind !== 'inline' || latex === null) return null
return {
from: selection.from,
@@ -264,14 +264,9 @@ function readSelectedMathLocation(view: EditorViewLike): MathNodeLocation | null
function replacementForMathSource(
view: EditorViewLike,
location: MathNodeLocation,
): ReturnType<EditorViewLike['state']['schema']['text']> | MathNodeLike | null {
): ReturnType<EditorViewLike['state']['schema']['text']> {
const source = mathSource(location)
const textNode = view.state.schema.text(source)
if (location.kind === 'inline') return textNode
const paragraphType = view.state.schema.nodes.paragraph
return paragraphType?.createChecked({}, textNode) ?? null
return view.state.schema.text(source)
}
function restoreMathSource({
@@ -324,7 +319,7 @@ function handleRenderedMathDoubleClick(
) {
const target = readRenderedMathTarget(event.target)
const view = readView()
if (!target || !view) return
if (!target || target.kind !== 'inline' || !view) return
const location = readRenderedMathLocation({ ...target, view })
if (!location) return