fix: edit display math as block property
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
40
src/components/editorExternalChangeEvents.ts
Normal file
40
src/components/editorExternalChangeEvents.ts
Normal 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)
|
||||
}
|
||||
61
src/components/editorSchema.math.test.tsx
Normal file
61
src/components/editorSchema.math.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user