From b7b05dce97e401b9b473993759f19b5c05556d16 Mon Sep 17 00:00:00 2001 From: Abraham Date: Fri, 17 Jul 2026 16:59:14 -0700 Subject: [PATCH] feat(open-knowledge): math WYSIWYG autoconvert + inline popover polish (#2715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(open-knowledge): autoconvert $$…$$ / $…$ in WYSIWYG and polish the inline math popover Adds a TipTap InputRule that collapses $$…$$ and (currency-safe) $…$ to a mathInline atom on the closing delimiter, using the same dispatchAsOwnUndoStep contract inline-link-input-rule uses so Ctrl+Z restores the raw literal without also undoing the preceding typing. Inline-math edit popover now closes on Enter (via PropPanel's onDismiss wiring) and gains a Done button matching the JsxComponentView pattern. On close the caret lands right after the atom so typing continues inline instead of the NodeSelection swallowing the next keystroke. * test(open-knowledge): cover math input rule + refresh i18n catalogs Adds 10 vitest cases mirroring inline-link-input-rule.test.ts: conversion for $$…$$ and $…$, currency-safety ($5.00, foo$x$, $ x $ all stay literal), code-block exclusion, and the one-undo-restores-the-literal contract against a real y-undo binding. Also picks up the two new po entries lingui extract generates for the Inline Math Properties popover header and the Done button. GitOrigin-RevId: e5f7aa04f32b3253d5cd1d6ab0f4288b9168e253 --- .changeset/math-wysiwyg-autoconvert.md | 8 + .../src/editor/extensions/MathInlineView.tsx | 48 +++- packages/app/src/editor/extensions/shared.ts | 5 + .../app/src/editor/math-input-rule.test.ts | 252 ++++++++++++++++++ packages/app/src/editor/math-input-rule.ts | 159 +++++++++++ packages/app/src/locales/en/messages.po | 1 + packages/app/src/locales/pseudo/messages.po | 1 + 7 files changed, 471 insertions(+), 3 deletions(-) create mode 100644 .changeset/math-wysiwyg-autoconvert.md create mode 100644 packages/app/src/editor/math-input-rule.test.ts create mode 100644 packages/app/src/editor/math-input-rule.ts diff --git a/.changeset/math-wysiwyg-autoconvert.md b/.changeset/math-wysiwyg-autoconvert.md new file mode 100644 index 000000000..c82bfe1cb --- /dev/null +++ b/.changeset/math-wysiwyg-autoconvert.md @@ -0,0 +1,8 @@ +--- +'@inkeep/open-knowledge': patch +--- + +**Feature:** typing `$$…$$` or `$…$` in the WYSIWYG editor now autoconverts to +an inline math atom on the closing delimiter (currency-safe; Ctrl+Z restores +the raw literal). The inline-math edit popover closes on Enter, gains a Done +button, and drops the caret right after the atom so typing continues inline. diff --git a/packages/app/src/editor/extensions/MathInlineView.tsx b/packages/app/src/editor/extensions/MathInlineView.tsx index 609649a77..429bab420 100644 --- a/packages/app/src/editor/extensions/MathInlineView.tsx +++ b/packages/app/src/editor/extensions/MathInlineView.tsx @@ -36,10 +36,11 @@ import { incrementJsxRenderFailure } from '@inkeep/open-knowledge-core'; import { Trans } from '@lingui/react/macro'; import type { NodeViewProps } from '@tiptap/core'; -import { NodeSelection } from '@tiptap/pm/state'; +import { NodeSelection, TextSelection } from '@tiptap/pm/state'; import { NodeViewWrapper } from '@tiptap/react'; import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { Button } from '../../components/ui/button.tsx'; import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover.tsx'; import { PropPanel } from '../components/PropPanel.tsx'; import type { JsxComponentDescriptor } from '../registry/types.ts'; @@ -256,7 +257,7 @@ export function MathInlineView({ node, selected, getPos, editor }: NodeViewProps -
+
Inline Math Properties
setPopoverOpen(false)} /> + {/* Explicit confirmation affordance mirroring + `JsxComponentView`'s Done button. PropPanel auto-saves on + every keystroke; this button is closure UX, not a save gate. + Click closes the popover; `onCloseAutoFocus` above hands + focus back to the editor view. */} +
+ +
diff --git a/packages/app/src/editor/extensions/shared.ts b/packages/app/src/editor/extensions/shared.ts index c7e880c5c..f4365c5ca 100644 --- a/packages/app/src/editor/extensions/shared.ts +++ b/packages/app/src/editor/extensions/shared.ts @@ -11,6 +11,7 @@ import { TiptapFindReplace } from '../find-replace/tiptap-find-replace-extension import { GfmAutolink } from '../gfm-autolink-plugin'; import { uploadAndInsert } from '../image-upload/index.ts'; import { InlineLinkInputRule } from '../inline-link-input-rule'; +import { MathInputRule } from '../math-input-rule'; import { getComponentItems, getInlineComponentItems } from '../slash-command/component-items'; import { getEmbedStarterItems } from '../slash-command/embed-starter-items'; import { getSlashCommandItems } from '../slash-command/items'; @@ -164,6 +165,10 @@ export const sharedExtensions = [ // client-side, foreground-only surface as GfmAutolink; keeps its own undo // step so one Cmd+Z restores the literal. InlineLinkInputRule, + // Typed `$$…$$` / `$…$` → mathInline atom on the closing delimiter. Same + // client-side, foreground-only, own-undo-step contract as + // InlineLinkInputRule; single-dollar rule is currency-safe. + MathInputRule, KeyboardNav, // Selection layer — must come after BridgeIdPlugin so ancestor-chain // lookups resolve stable IDs. Order is load-bearing only wrt BridgeId; diff --git a/packages/app/src/editor/math-input-rule.test.ts b/packages/app/src/editor/math-input-rule.test.ts new file mode 100644 index 000000000..011f9a047 --- /dev/null +++ b/packages/app/src/editor/math-input-rule.test.ts @@ -0,0 +1,252 @@ +/** + * Typed `$$…$$` / `$…$` input rule — conversion, currency safety, exclusion + * contexts, and the one-undo-restores-the-literal contract against a real + * y-undo binding. + * + * Input rules fire only from `handleTextInput`, so these rigs type through + * `view.someProp('handleTextInput', …)` with the same unhandled-fallback + * insertion the real DOM input path performs — a bare `insertText` dispatch + * never triggers a rule. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { MathInline } from '@inkeep/open-knowledge-core'; +import type { Editor } from '@tiptap/core'; +import * as Y from 'yjs'; +import { mountCollabEditor, mountLightEditor, readUndoManager } from './editor-rig.test-helper'; +import { MathInputRule } from './math-input-rule'; +import { flushMicrotasksAndTimers, installDomGlobals } from './walk-currency-test-harness'; + +let restoreDomGlobals: (() => void) | null = null; + +beforeAll(() => { + restoreDomGlobals = installDomGlobals(); +}); + +afterAll(() => { + restoreDomGlobals?.(); + restoreDomGlobals = null; +}); + +function makeEditor(opts: { content?: string } = {}): Editor { + return mountLightEditor({ + content: opts.content, + extensions: [MathInline, MathInputRule], + }); +} + +/** + * Type text the way the DOM input path does: each character goes through + * `handleTextInput` (where input rules run) and falls back to a plain + * insertion when no rule claims it. + */ +function typeText(editor: Editor, text: string): void { + for (const char of text) { + const { from, to } = editor.state.selection; + const deflt = () => editor.state.tr.insertText(char, from, to); + const handled = editor.view.someProp('handleTextInput', (handleTextInput) => + handleTextInput(editor.view, from, to, char, deflt), + ); + if (!handled) { + editor.view.dispatch(deflt()); + } + } +} + +/** All `mathInline` atoms in the doc, in order. Returned as their attr snapshot + * so a test can assert on `formula` + `sourceDelimiter` in one shot. */ +function mathAtoms(editor: Editor): Array<{ formula: string; sourceDelimiter: string | null }> { + const atoms: Array<{ formula: string; sourceDelimiter: string | null }> = []; + editor.state.doc.descendants((node) => { + if (node.type.name === 'mathInline') { + atoms.push({ + formula: (node.attrs.formula as string) ?? '', + sourceDelimiter: (node.attrs.sourceDelimiter as string | null) ?? null, + }); + } + return undefined; + }); + return atoms; +} + +// --------------------------------------------------------------------------- +// Conversion +// --------------------------------------------------------------------------- + +describe('math input rule — conversion', () => { + test('typing `$$x+y$$` collapses to a mathInline atom on the closing `$$`', async () => { + const editor = makeEditor(); + try { + typeText(editor, '$$x+y$$'); + await flushMicrotasksAndTimers(); + + const atoms = mathAtoms(editor); + expect(atoms).toEqual([{ formula: 'x+y', sourceDelimiter: '$$' }]); + // The raw literal is gone from textContent; PM renders the atom as a + // single non-text position. + expect(editor.state.doc.textContent).toBe(''); + } finally { + editor.destroy(); + } + }); + + test('typing `$c=d$` collapses to a mathInline atom carrying the pandoc-style single-delim', async () => { + const editor = makeEditor(); + try { + typeText(editor, '$c=d$'); + await flushMicrotasksAndTimers(); + + expect(mathAtoms(editor)).toEqual([{ formula: 'c=d', sourceDelimiter: '$' }]); + } finally { + editor.destroy(); + } + }); + + test('mid-paragraph `$…$` after preceding text still collapses', async () => { + const editor = makeEditor(); + try { + typeText(editor, 'sum: $a+b$'); + await flushMicrotasksAndTimers(); + + expect(mathAtoms(editor)).toEqual([{ formula: 'a+b', sourceDelimiter: '$' }]); + expect(editor.state.doc.textContent).toBe('sum: '); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Currency safety — pandoc-style guard for the single-dollar form +// --------------------------------------------------------------------------- + +describe('math input rule — currency safety', () => { + test('`$5.00` typed alone stays literal (single trailing `$` never fires)', async () => { + const editor = makeEditor(); + try { + typeText(editor, '$5.00'); + await flushMicrotasksAndTimers(); + + expect(mathAtoms(editor)).toEqual([]); + expect(editor.state.doc.textContent).toBe('$5.00'); + } finally { + editor.destroy(); + } + }); + + test('`between $5 and $10` — pandoc lookbehind blocks the `$…$` around a real price range', async () => { + const editor = makeEditor(); + try { + typeText(editor, 'between $5 and $10'); + await flushMicrotasksAndTimers(); + + // Opening `$` after "and " has a space before it (allowed), but content + // is `5 and $` — the `\s$` inside forces a mismatch; even if it matched + // once, the leading `5 and ` would violate the trim-safe outer edges. + expect(mathAtoms(editor)).toEqual([]); + } finally { + editor.destroy(); + } + }); + + test('`foo$x$` — opening `$` preceded by a word char is refused', async () => { + const editor = makeEditor(); + try { + typeText(editor, 'foo$x$'); + await flushMicrotasksAndTimers(); + + expect(mathAtoms(editor)).toEqual([]); + expect(editor.state.doc.textContent).toBe('foo$x$'); + } finally { + editor.destroy(); + } + }); + + test('`$ x $` — whitespace-flanked content stays literal (pandoc contract)', async () => { + const editor = makeEditor(); + try { + typeText(editor, '$ x $'); + await flushMicrotasksAndTimers(); + + expect(mathAtoms(editor)).toEqual([]); + expect(editor.state.doc.textContent).toBe('$ x $'); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Exclusion contexts +// --------------------------------------------------------------------------- + +describe('math input rule — exclusions', () => { + test('does not fire inside a code block', async () => { + const editor = makeEditor({ content: '
x
' }); + try { + // Caret at the end of the code block content. + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + typeText(editor, '$$a+b$$'); + await flushMicrotasksAndTimers(); + + expect(mathAtoms(editor)).toEqual([]); + expect(editor.state.doc.textContent).toBe('x$$a+b$$'); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Undo isolation (real y-undo binding) +// --------------------------------------------------------------------------- + +describe('math input rule — one undo restores the literal', () => { + test('a single undo brings back `$$x+y$$` as raw text and drops the atom', async () => { + const ydoc = new Y.Doc(); + const editor = mountCollabEditor(ydoc, [MathInline, MathInputRule]); + try { + typeText(editor, '$$x+y$$'); + await flushMicrotasksAndTimers(); + expect(mathAtoms(editor)).toEqual([{ formula: 'x+y', sourceDelimiter: '$$' }]); + + const undoManager = readUndoManager(editor); + expect(undoManager).not.toBeNull(); + undoManager?.undo(); + await flushMicrotasksAndTimers(); + + expect(mathAtoms(editor)).toEqual([]); + expect(editor.state.doc.textContent).toBe('$$x+y$$'); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); + + test('typing after a collapse stays its own undo step (no merge into the collapse)', async () => { + const ydoc = new Y.Doc(); + const editor = mountCollabEditor(ydoc, [MathInline, MathInputRule]); + try { + typeText(editor, '$$x+y$$'); + await flushMicrotasksAndTimers(); + expect(mathAtoms(editor)).toEqual([{ formula: 'x+y', sourceDelimiter: '$$' }]); + + // Keystrokes after the collapse land within captureTimeout of it; the + // closing stopCapturing must keep them OUT of the collapse's undo item. + typeText(editor, ' more'); + await flushMicrotasksAndTimers(); + expect(editor.state.doc.textContent).toBe(' more'); + + const undoManager = readUndoManager(editor); + undoManager?.undo(); + await flushMicrotasksAndTimers(); + + // Only the trailing typing is removed; the converted atom survives. + expect(mathAtoms(editor)).toEqual([{ formula: 'x+y', sourceDelimiter: '$$' }]); + expect(editor.state.doc.textContent).toBe(''); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); +}); diff --git a/packages/app/src/editor/math-input-rule.ts b/packages/app/src/editor/math-input-rule.ts new file mode 100644 index 000000000..d9a8e1109 --- /dev/null +++ b/packages/app/src/editor/math-input-rule.ts @@ -0,0 +1,159 @@ +/** + * Typed `$$…$$` / `$…$` input rule for the WYSIWYG editor. + * + * When a local user closes a well-formed math literal by typing the final + * delimiter, the raw text collapses to a `mathInline` PM atom carrying the + * inner LaTeX as its `formula` attr. The atom's `sourceDelimiter` records + * which delimiter (`$` vs `$$`) the user typed, so serialization replays the + * same shape. + * + * Two regexes, both currency-safe: + * + * 1. `$$formula$$` — the double-delimiter form. Its literal `$$` bookends + * preclude the pandoc-style single-dollar ambiguity, so no lookbehind + * is needed. Matches single-line math only (content excludes `\n` and + * `$`); multi-line block math continues to arrive through the parse-time + * remark-math `$$\n…\n$$` route. + * 2. `$formula$` — the pandoc-style single-delimiter form. The opening `$` + * must NOT be preceded by a word char / digit / another `$` (so `$5.00`, + * `foo$bar$`, `$$$` all stay untouched); the inner content must not have + * leading or trailing whitespace (so `$ x $` stays a literal price + * range, matching the parse-time single-dollar promoter's stance). + * + * Same undo-isolation contract as `inline-link-input-rule.ts`: the rule does + * NOT consume the closing delimiter; it lets that char land through normal + * typing, then a microtask later collapses the completed literal to a math + * atom via `dispatchAsOwnUndoStep`. One Cmd+Z reverts just the collapse and + * restores the escaped literal — the user can keep editing the source text + * or retype the closing delimiter to re-collapse. + * + * Origin safety is inherited from InputRule's `handleTextInput` gating — + * remote-peer / agent / disk / observer-echo writes bypass it entirely. + */ + +import { Extension, InputRule } from '@tiptap/core'; +import type { EditorView } from '@tiptap/pm/view'; +import { dispatchAsOwnUndoStep } from './undo-isolation'; + +const MATH_INLINE = 'mathInline'; + +/** `$$formula$$` at the caret. Content: one+ non-`$` non-`\n`. */ +const DOUBLE_DOLLAR_RE = /\$\$([^$\n]+)\$\$$/; + +/** `$formula$` at the caret. Opening `$` must NOT be preceded by a word/digit/ + * another `$` (currency-safe); content must not lead or trail with whitespace + * (pandoc-style — `$ foo $` isn't math). Enforces content length ≥ 1 via the + * `[^\s$\n]` core (with an optional inner span that may include internal + * whitespace like `$a + b$`). */ +const SINGLE_DOLLAR_RE = /(?formula` (already complete in the doc, including the + * just-typed closing delimiter) at `[from, from+len]` down to a `mathInline` + * atom. Runs a microtask after the closing char landed, so it re-validates the + * span first — the doc may have shifted or been rewritten in the gap. + */ +function collapseToMath(view: EditorView, from: number, match: MatchedMath): void { + if (view.isDestroyed || view.composing) return; + const { state } = view; + const nodeType = state.schema.nodes[MATH_INLINE]; + if (!nodeType) return; + + const to = from + match.literalLength; + if (from < 0 || to > state.doc.content.size) return; + const literal = `${match.delimiter}${match.formula}${match.delimiter}`; + if (state.doc.textBetween(from, to) !== literal) return; + + // Refuse to collapse if the range contains a non-text inline child (a + // sibling atom, image, etc.) — the regex only inspects textBetween's flat + // string projection, so a mixed-content match would silently swallow the + // sibling atom. + let hasNonText = false; + state.doc.nodesBetween(from, to, (node) => { + if (node.isInline && !node.isText) hasNonText = true; + return !hasNonText; + }); + if (hasNonText) return; + + const atom = nodeType.create({ + formula: match.formula, + sourceDelimiter: match.delimiter, + }); + + // Own undo step so one Cmd+Z reverts JUST the collapse, restoring the raw + // `$$…$$` literal. Only the dispatch is guarded — it crosses into third- + // party plugin hooks; the guards above are internal-trusted and fail loud. + try { + dispatchAsOwnUndoStep(view, state.tr.replaceRangeWith(from, to, atom)); + } catch (err) { + console.warn( + '[math-input-rule] collapse dispatch failed', + { from, formula: match.formula, delimiter: match.delimiter }, + err, + ); + } +} + +function makeInputRule(editor: { view: EditorView }, re: RegExp, delimiter: MatchedDelimiter) { + return new InputRule({ + find: re, + // Returning null (no steps) means TipTap does not consume the match: + // the closing delimiter char inserts through the normal path and the + // deferred collapse below does the math-ification. Code-block / inline- + // code contexts are already refused upstream by TipTap's input-rule + // runner. + handler: ({ state, range, match }) => { + const parsed = tryMatch(match[0], re, delimiter); + if (!parsed) return null; + if (!state.schema.nodes[MATH_INLINE]) return null; + + // range.from is the start of the opening delimiter in the pre-close- + // char doc; positions before the caret are stable across the closing + // char's insertion, so the completed literal spans + // [range.from, range.from + literalLength]. + const from = range.from; + // Capture the view eagerly: input rules fire from handleTextInput (the + // view is mounted here), while `editor.view` re-read inside the + // microtask is a throwing proxy if a recycle starts in the gap. + // collapseToMath's isDestroyed guard covers teardown after capture. + const view = editor.view; + queueMicrotask(() => collapseToMath(view, from, parsed)); + return null; + }, + }); +} + +export const MathInputRule = Extension.create({ + name: 'mathInputRule', + + addInputRules() { + // Order matters: the `$$…$$` rule runs FIRST because both regexes would + // fire on `$$…$$` (the single-dollar rule sees the trailing `$…$` slice), + // and TipTap runs input rules in declaration order until one matches. + return [ + makeInputRule(this.editor, DOUBLE_DOLLAR_RE, '$$'), + makeInputRule(this.editor, SINGLE_DOLLAR_RE, '$'), + ]; + }, +}); diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 5ec78ff2a..84a5829fd 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -2867,6 +2867,7 @@ msgstr "Don't see <0>Skills? Enable <1>Settings <2/> Capabilities <3/> Code #: src/components/PublishToGitHubDialog.tsx #: src/components/ReportBugDialogBody.tsx #: src/editor/extensions/JsxComponentView.tsx +#: src/editor/extensions/MathInlineView.tsx #: src/editor/slash-command/items.tsx msgid "Done" msgstr "Done" diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 6a644c386..130c3fbf3 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -2863,6 +2863,7 @@ msgstr "" #: src/components/PublishToGitHubDialog.tsx #: src/components/ReportBugDialogBody.tsx #: src/editor/extensions/JsxComponentView.tsx +#: src/editor/extensions/MathInlineView.tsx #: src/editor/slash-command/items.tsx msgid "Done" msgstr ""