diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 1157a0fc..b05c35b9 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 5c455069..cade4eb0 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -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 diff --git a/src/components/editorExternalChangeEvents.ts b/src/components/editorExternalChangeEvents.ts new file mode 100644 index 00000000..0bf336f7 --- /dev/null +++ b/src/components/editorExternalChangeEvents.ts @@ -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 { + return event instanceof CustomEvent && event.detail?.source === source +} + +export function dispatchRichEditorExternalChange( + source: RichEditorExternalChangeSource, + target: EventTarget = window, +) { + target.dispatchEvent(new CustomEvent( + 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) +} diff --git a/src/components/editorSchema.math.test.tsx b/src/components/editorSchema.math.test.tsx new file mode 100644 index 00000000..b46e271c --- /dev/null +++ b/src/components/editorSchema.math.test.tsx @@ -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() + + 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() + }) +}) diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index b7b1d973..015f6205 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -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): boolean { + return event.metaKey || event.ctrlKey +} + +function isCommitMathEditShortcut(event: KeyboardEvent): 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(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) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + cancelEditing() + return + } + + if (isCommitMathEditShortcut(event)) { + event.preventDefault() + event.stopPropagation() + finishEditing() + } + } + + return ( +
+ {editing ? ( +
+