diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 7c847f59..68695ca2 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -597,6 +597,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. - `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/mathInputExtension.test.ts b/src/components/mathInputExtension.test.ts index c4967c6b..9c81c55d 100644 --- a/src/components/mathInputExtension.test.ts +++ b/src/components/mathInputExtension.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import { createMathInputExtension } from './mathInputExtension' import { trackEvent } from '../lib/telemetry' +import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE } from '../utils/mathMarkdown' vi.mock('../lib/telemetry', () => ({ trackEvent: vi.fn(), @@ -21,8 +22,24 @@ function createTransaction() { return transaction } +function createMathNode(type: string, latex: string, nodeSize = 1) { + return { + attrs: { latex }, + nodeSize, + type: { name: type }, + } +} + function createView(beforeText: string, transaction: ReturnType) { const mathNode = { nodeSize: 1 } + const textNodeFor = vi.fn((text: string) => ({ text, type: 'text' })) + const paragraphNodeType = { + createChecked: vi.fn((attrs: Record, content: unknown) => ({ + attrs, + content, + type: 'paragraph', + })), + } const selection = { from: beforeText.length, to: beforeText.length, @@ -36,48 +53,76 @@ function createView(beforeText: string, transaction: ReturnType mathNode) } + const docNodes: Array<{ node: ReturnType; pos: number }> = [] + const doc = { + content: { size: 100 }, + nodesBetween: vi.fn(( + from: number, + to: number, + visit: (node: ReturnType, pos: number) => boolean | void, + ) => { + for (const item of docNodes) { + const nodeEnd = item.pos + item.node.nodeSize + if (nodeEnd < from || item.pos > to) continue + if (visit(item.node, item.pos) === false) return + } + }), + } const view = { composing: false, dispatch: vi.fn(), + posAtDOM: vi.fn(() => 0), state: { - schema: { nodes: { mathInline: mathNodeType } }, + doc, + schema: { + nodes: { + mathInline: mathNodeType, + paragraph: paragraphNodeType, + }, + text: textNodeFor, + }, selection, storedMarks: null as Array<{ type: { name: string } }> | null, tr: transaction, }, } - return { mathNode, mathNodeType, view } + return { docNodes, mathNode, mathNodeType, paragraphNodeType, textNodeFor, view } } -function createDom(registerBeforeInput: (listener: (event: InputEvent) => void) => void) { +function createDom(registerListener: (type: string, listener: EventListener) => void) { const dom = { - addEventListener: vi.fn((type: string, listener: (event: InputEvent) => void) => { - if (type === 'beforeinput') { - registerBeforeInput(listener) - } + addEventListener: vi.fn((type: string, listener: EventListener) => { + registerListener(type, listener) }), } return dom } function createFixture(beforeText = 'Inline $x^2$') { - let beforeInputListener: ((event: InputEvent) => void) | null = null + const listeners = new Map() const transaction = createTransaction() - const { mathNode, mathNodeType, view } = createView(beforeText, transaction) - const dom = createDom((listener) => { - beforeInputListener = listener + const { docNodes, mathNode, mathNodeType, paragraphNodeType, textNodeFor, view } = createView(beforeText, transaction) + const dom = createDom((type, listener) => { + listeners.set(type, listener) }) + dom.addEventListener.mockImplementation((type: string, listener: EventListener) => { + listeners.set(type, listener) + }) + const setTextSelection = vi.fn() const editor = { - _tiptapEditor: { view }, + _tiptapEditor: { commands: { setTextSelection }, view }, prosemirrorView: view, } const extension = createMathInputExtension()({ editor: editor as never }) return { + docNodes, dom, + editor, extension, fireInput(event: Partial = {}) { + const beforeInputListener = listeners.get('beforeinput') if (!beforeInputListener) { throw new Error('Math input extension did not register a beforeinput listener') } @@ -93,6 +138,37 @@ function createFixture(beforeText = 'Inline $x^2$') { beforeInputListener(inputEvent as InputEvent) return inputEvent }, + fireKeyDown(event: Partial = {}) { + const keyDownListener = listeners.get('keydown') + if (!keyDownListener) { + throw new Error('Math input extension did not register a keydown listener') + } + + const keyDownEvent = { + key: 'F2', + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...event, + } + + keyDownListener(keyDownEvent as KeyboardEvent) + return keyDownEvent + }, + fireMathDoubleClick(target: EventTarget) { + const doubleClickListener = listeners.get('dblclick') + if (!doubleClickListener) { + throw new Error('Math input extension did not register a dblclick listener') + } + + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + target, + } + + doubleClickListener(event as unknown as MouseEvent) + return event + }, mathNode, mathNodeType, mount() { @@ -104,6 +180,9 @@ function createFixture(beforeText = 'Inline $x^2$') { }) return controller }, + paragraphNodeType, + setTextSelection, + textNodeFor, transaction, view, } @@ -198,4 +277,97 @@ describe('createMathInputExtension', () => { reason: 'transform_error', }) }) + + it('restores rendered inline math source on double click', () => { + const fixture = createFixture() + const latex = 'x^2' + const mathElement = document.createElement('span') + mathElement.className = 'math math--inline' + mathElement.dataset.latex = latex + const glyphText = document.createTextNode('x') + mathElement.append(glyphText) + const renderedNode = createMathNode(MATH_INLINE_TYPE, latex) + fixture.docNodes.push({ node: renderedNode, pos: 7 }) + fixture.view.posAtDOM.mockReturnValue(7) + fixture.mount() + + const event = fixture.fireMathDoubleClick(glyphText) + + expect(fixture.textNodeFor).toHaveBeenCalledWith('$x^2$') + expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(7, 8, { + text: '$x^2$', + type: 'text', + }) + expect(fixture.transaction.scrollIntoView).toHaveBeenCalled() + expect(fixture.view.dispatch).toHaveBeenCalledWith(fixture.transaction) + expect(fixture.setTextSelection).toHaveBeenCalledWith({ from: 8, to: 11 }) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(trackEvent).toHaveBeenCalledWith('math_source_edit_reopened', { + activation: 'pointer', + math_mode: 'inline', + }) + }) + + it('restores rendered block math source 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 + const renderedNode = createMathNode(MATH_BLOCK_TYPE, latex) + fixture.docNodes.push({ node: renderedNode, pos: 20 }) + fixture.view.posAtDOM.mockReturnValue(20) + fixture.mount() + + 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', { + activation: 'pointer', + math_mode: 'block', + }) + }) + + it('restores selected math source from the keyboard', () => { + const fixture = createFixture() + const latex = 'E=mc^2' + const selectedNode = createMathNode(MATH_INLINE_TYPE, latex) + fixture.view.state.selection = { + from: 12, + node: selectedNode, + to: 13, + } + fixture.mount() + + const event = fixture.fireKeyDown({ key: 'Enter' }) + + expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(12, 13, { + text: '$E=mc^2$', + type: 'text', + }) + expect(fixture.setTextSelection).toHaveBeenCalledWith({ from: 13, to: 19 }) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(trackEvent).toHaveBeenCalledWith('math_source_edit_reopened', { + activation: 'keyboard', + math_mode: 'inline', + }) + }) }) diff --git a/src/components/mathInputExtension.ts b/src/components/mathInputExtension.ts index 304dc392..53d39e0e 100644 --- a/src/components/mathInputExtension.ts +++ b/src/components/mathInputExtension.ts @@ -1,6 +1,7 @@ import { createExtension } from '@blocknote/core' import type { useCreateBlockNote } from '@blocknote/react' -import { MATH_INLINE_TYPE, readCompletedInlineMathAtEnd } from '../utils/mathMarkdown' +import { trackEvent } from '../lib/telemetry' +import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, readCompletedInlineMathAtEnd } from '../utils/mathMarkdown' import { isRecoverableEditorTransformError, reportRecoveredEditorTransformError, @@ -8,7 +9,34 @@ import { const INLINE_WHITESPACE_RE = /^[^\S\r\n]$/ const NEWLINE_INPUT_TYPES = new Set(['insertParagraph', 'insertLineBreak']) +const MATH_SELECTOR = '.math[data-latex]' +const MATH_NODE_SEARCH_RADIUS = 8 type EditorViewLike = NonNullable['prosemirrorView']> +type EditorLike = ReturnType +type MathKind = 'inline' | 'block' +type MathActivation = 'keyboard' | 'pointer' +type MathNodeLike = { + attrs?: Record + nodeSize: number + type: { name: string } +} +type MathNodeLocation = { + from: number + kind: MathKind + latex: string + node: MathNodeLike + to: number +} +type MathSelection = { + from: number + node?: unknown + to: number +} +type ReadEditorView = () => EditorViewLike | undefined +type MathExtensionContext = { + editor: EditorLike + readView: ReadEditorView +} interface CursorText { beforeText: string @@ -106,6 +134,223 @@ function readMathInputTransaction( } } +function mathSource({ kind, latex }: { kind: MathKind; latex: string }): string { + return kind === 'block' ? `$$\n${latex}\n$$` : `$${latex}$` +} + +function mathLatexSelectionRange({ from, kind, latex }: { from: number; kind: MathKind; latex: string }) { + const sourceStart = kind === 'block' ? from + 1 + '$$\n'.length : from + 1 + return { from: sourceStart, to: sourceStart + latex.length } +} + +function mathKindForNode(node: MathNodeLike): MathKind | null { + if (node.type.name === MATH_INLINE_TYPE) return 'inline' + if (node.type.name === MATH_BLOCK_TYPE) return 'block' + return null +} + +function readLatexAttr(node: MathNodeLike): string | null { + const latex = node.attrs?.latex + return typeof latex === 'string' ? latex : null +} + +function isMathNode(node: MathNodeLike, kind: MathKind): boolean { + return mathKindForNode(node) === kind && readLatexAttr(node) !== null +} + +function isMathNodeLike(node: unknown): node is MathNodeLike { + if (!node || typeof node !== 'object') return false + + const candidate = node as Partial + return typeof candidate.nodeSize === 'number' + && Boolean(candidate.type) + && typeof candidate.type?.name === 'string' +} + +function targetElement(target: EventTarget | null): Element | null { + if (target instanceof Element) return target + if (target instanceof Node) return target.parentElement + return null +} + +function readRenderedMathTarget(target: EventTarget | null): { element: HTMLElement; kind: MathKind; latex: string } | null { + const element = targetElement(target)?.closest(MATH_SELECTOR) + const latex = element?.dataset.latex + if (!element || latex === undefined) return null + + return { + element, + kind: element.classList.contains('math--block') ? 'block' : 'inline', + latex, + } +} + +function searchMathNodeNearPosition({ + kind, + latex, + position, + view, +}: { + kind: MathKind + latex: string + position: number + view: EditorViewLike +}): MathNodeLocation | null { + const doc = view.state.doc + const size = doc.content.size + const from = Math.max(0, position - MATH_NODE_SEARCH_RADIUS) + const to = Math.min(size, position + MATH_NODE_SEARCH_RADIUS) + let fallback: MathNodeLocation | null = null + let exact: MathNodeLocation | null = null + + doc.nodesBetween(from, to, (node: MathNodeLike, nodePos: number) => { + if (!isMathNode(node, kind)) return true + + const nodeLatex = readLatexAttr(node) ?? latex + const location = { + from: nodePos, + kind, + latex, + node, + to: nodePos + node.nodeSize, + } + + fallback ??= location + if (nodeLatex === latex) { + exact = location + return false + } + + return true + }) + + return exact ?? fallback +} + +function readRenderedMathLocation({ + element, + kind, + latex, + view, +}: { + element: HTMLElement + kind: MathKind + latex: string + view: EditorViewLike +}): MathNodeLocation | null { + const position = view.posAtDOM(element, 0) + if (!Number.isFinite(position)) return null + + return searchMathNodeNearPosition({ kind, latex, position, view }) +} + +function readSelectedMathLocation(view: EditorViewLike): MathNodeLocation | null { + const selection = view.state.selection as MathSelection + if (!isMathNodeLike(selection.node)) return null + + const kind = mathKindForNode(selection.node) + const latex = readLatexAttr(selection.node) + if (!kind || latex === null) return null + + return { + from: selection.from, + kind, + latex, + node: selection.node, + to: selection.to, + } +} + +function replacementForMathSource( + view: EditorViewLike, + location: MathNodeLocation, +): ReturnType | MathNodeLike | null { + 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 +} + +function restoreMathSource({ + activation, + editor, + location, + view, +}: { + activation: MathActivation + editor: EditorLike + location: MathNodeLocation + view: EditorViewLike +}): boolean { + const replacement = replacementForMathSource(view, location) + if (!replacement) return false + + const transaction = view.state.tr + .replaceWith(location.from, location.to, replacement) + .scrollIntoView() + + if (!dispatchMathInputTransaction(view, transaction)) return false + + editor._tiptapEditor?.commands?.setTextSelection?.( + mathLatexSelectionRange(location), + ) + trackEvent('math_source_edit_reopened', { + activation, + math_mode: location.kind, + }) + return true +} + +function handleBeforeInputEvent(event: InputEvent, readView: ReadEditorView) { + const view = readView() + if (!view || shouldSkipInput(event, view)) return + + const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined + const transaction = readMathInputTransaction(view, trailingText) + if (!transaction) return + + if (!dispatchMathInputTransaction(view, transaction)) return + if (trailingText !== undefined) { + event.preventDefault() + } +} + +function handleRenderedMathDoubleClick( + event: MouseEvent, + { editor, readView }: MathExtensionContext, +) { + const target = readRenderedMathTarget(event.target) + const view = readView() + if (!target || !view) return + + const location = readRenderedMathLocation({ ...target, view }) + if (!location) return + + if (!restoreMathSource({ activation: 'pointer', editor, location, view })) return + + event.preventDefault() + event.stopPropagation() +} + +function handleMathKeyDown( + event: KeyboardEvent, + { editor, readView }: MathExtensionContext, +) { + if (event.key !== 'Enter' && event.key !== 'F2') return + + const view = readView() + const location = view ? readSelectedMathLocation(view) : null + if (!view || !location) return + + if (!restoreMathSource({ activation: 'keyboard', editor, location, view })) return + + event.preventDefault() + event.stopPropagation() +} + function dispatchMathInputTransaction( view: EditorViewLike, transaction: EditorViewLike['state']['tr'], @@ -125,21 +370,23 @@ export const createMathInputExtension = createExtension(({ editor }) => { return { key: 'mathInput', mount: ({ dom, signal }) => { - const handleBeforeInput = (event: InputEvent) => { - const view = readView() - if (!view || shouldSkipInput(event, view)) return + const context = { editor, readView } - const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined - const transaction = readMathInputTransaction(view, trailingText) - if (!transaction) return - - if (!dispatchMathInputTransaction(view, transaction)) return - if (trailingText !== undefined) { - event.preventDefault() - } - } - - dom.addEventListener('beforeinput', handleBeforeInput as EventListener, { + dom.addEventListener('beforeinput', ((event: InputEvent) => { + handleBeforeInputEvent(event, readView) + }) as EventListener, { + capture: true, + signal, + }) + dom.addEventListener('dblclick', (event) => { + handleRenderedMathDoubleClick(event, context) + }, { + capture: true, + signal, + }) + dom.addEventListener('keydown', (event) => { + handleMathKeyDown(event, context) + }, { capture: true, signal, })