Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/math-wysiwyg-autoconvert.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 45 additions & 3 deletions packages/app/src/editor/extensions/MathInlineView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -256,7 +257,7 @@ export function MathInlineView({ node, selected, getPos, editor }: NodeViewProps
</span>
</PopoverTrigger>
<PopoverContent
className="z-[60] w-72 p-0"
className="z-[60] w-72 p-3"
side="bottom"
align="start"
// Keep the content inside the editor's React tree so PM
Expand All @@ -274,11 +275,31 @@ export function MathInlineView({ node, selected, getPos, editor }: NodeViewProps
// target. `e.preventDefault()` blocks Radix's default focus
// restore (which would target the trigger span and leave PM
// unfocused on Escape / outside-click).
//
// Then drop the caret right AFTER the atom so the author can
// just keep typing — leaving the NodeSelection intact
// would swallow the next keystroke into a select-and-replace
// gesture instead. Guard against a shifted position: the atom
// may have been deleted or replaced by a remote peer between
// popover open and close.
e.preventDefault();
const p = typeof getPos === 'function' ? getPos() : undefined;
if (typeof p === 'number') {
const state = editor.state;
const atomNode = state.doc.nodeAt(p);
if (atomNode?.type.name === 'mathInline') {
const after = p + atomNode.nodeSize;
if (after <= state.doc.content.size) {
editor.view.dispatch(
state.tr.setSelection(TextSelection.create(state.doc, after)),
);
}
}
}
editor.view.focus();
}}
>
<div className="text-xs font-medium text-muted-foreground px-3 pt-2">
<div className="text-xs font-medium text-muted-foreground mb-2">
<Trans>Inline Math Properties</Trans>
</div>
<PropPanel
Expand Down Expand Up @@ -308,7 +329,28 @@ export function MathInlineView({ node, selected, getPos, editor }: NodeViewProps
tr.setSelection(NodeSelection.create(tr.doc, p));
editor.view.dispatch(tr);
}}
// Enter in the single-line formula input closes the popover —
// PropPanel already auto-saves on every keystroke, so Enter is
// pure acknowledgement (same contract as every block-descriptor
// PropPanel). Mirrors `JsxComponentView` for pattern parity.
onDismiss={() => 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. */}
<div className="mt-3 flex justify-end border-t border-border pt-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setPopoverOpen(false)}
className="h-7 px-3 text-xs"
>
<Trans>Done</Trans>
</Button>
</div>
</PopoverContent>
</Popover>
</NodeViewWrapper>
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/editor/extensions/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
252 changes: 252 additions & 0 deletions packages/app/src/editor/math-input-rule.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<pre><code>x</code></pre>' });
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();
}
});
});
Loading