feat(reply modal): add sci tex preview button (#1170)

* feat(sci): add 4chan-style TeX support with MathJax on /sci/

- [math]/[eqn] tags typeset with MathJax 3 (lazy chunk, only on /sci/ with math present)
- 4chan-identical config: Safe mode, left-aligned eqn, neutered \color/\newcommand macros
- TeX button in reply modal title bar opens live TeX Preview modal
- /sci/ post form rules bullets for [math]/[eqn] usage and right-click source
- MathJax context menu on right-click (Show Math As > TeX Commands)
- woff fonts served from node_modules in dev and emitted into build

* feat(sci): finish TeX support: configmacros fix, preview preload, translations, tests

- add configmacros package so the 4chan macro neutering (\color, \newcommand, ...) applies
- preload MathJax when the TeX Preview opens, like 4chan
- stable closeModal callback for the preview modal
- pre-bundle mathjax components in vite optimizeDeps to avoid dev mid-session reload
- translate the 6 new TeX keys into all 35 languages
- markdown math segment component tests + math-tags unit tests

* feat(reply modal): add sci tex preview button

* fix(tex-preview): clear MathJax bookkeeping and pending typeset on close

Addresses Cursor Bugbot: the preview output was typeset via typesetMathElement but
never passed to clearMathElement on unmount, so repeated open/close cycles kept
detached nodes in MathJax's math list. Also cancels the pending debounce timer.

* fix(reply-modal): reset TeX preview state when the reply modal closes

Addresses CodeRabbit: showTexPreview persisted across close/reopen like the
bbcode preview flags, so the TeX preview would auto-open on the next reply.
This commit is contained in:
Tommaso Casaburi
2026-06-11 16:12:38 +07:00
committed by GitHub
parent fa7cab0699
commit 17c63bb2e6
55 changed files with 1042 additions and 48 deletions
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { HAS_MATH_TAG_REGEX, isMathDirectoryCode, splitMathSegments } from '../math-tags';
describe('math-tags', () => {
it('enables math tags only for /sci/', () => {
expect(isMathDirectoryCode('sci')).toBe(true);
expect(isMathDirectoryCode('SCI')).toBe(true);
expect(isMathDirectoryCode('g')).toBe(false);
expect(isMathDirectoryCode('b')).toBe(false);
expect(isMathDirectoryCode(undefined)).toBe(false);
});
it('detects closed math and eqn tags', () => {
expect(HAS_MATH_TAG_REGEX.test('[math]x[/math]')).toBe(true);
expect(HAS_MATH_TAG_REGEX.test('[eqn]\\int x dx[/eqn]')).toBe(true);
expect(HAS_MATH_TAG_REGEX.test('[math]unclosed')).toBe(false);
expect(HAS_MATH_TAG_REGEX.test('[math]mismatch[/eqn]')).toBe(false);
expect(HAS_MATH_TAG_REGEX.test('no tags at all')).toBe(false);
});
it('splits text and math segments with offsets, keeping the delimiters', () => {
expect(splitMathSegments('a [math]x_1[/math] b')).toEqual([
{ type: 'text', value: 'a ', start: 0 },
{ type: 'math', value: '[math]x_1[/math]', start: 2 },
{ type: 'text', value: ' b', start: 18 },
]);
});
it('keeps multi-line eqn content in one math segment', () => {
const raw = 'before\n[eqn]\\begin{pmatrix}a & b\\\\c & d\\end{pmatrix}[/eqn]\nafter';
const segments = splitMathSegments(raw);
expect(segments.map((segment) => segment.type)).toEqual(['text', 'math', 'text']);
expect(segments[1].value).toContain('pmatrix');
expect(segments[1].value).toContain('\\\\');
});
it('leaves unclosed or mismatched tags as plain text', () => {
expect(splitMathSegments('[math]x')).toEqual([{ type: 'text', value: '[math]x', start: 0 }]);
expect(splitMathSegments('[math]x[/eqn]')).toEqual([{ type: 'text', value: '[math]x[/eqn]', start: 0 }]);
});
it('handles back-to-back and repeated math segments', () => {
expect(splitMathSegments('[math]a[/math][eqn]b[/eqn]')).toEqual([
{ type: 'math', value: '[math]a[/math]', start: 0 },
{ type: 'math', value: '[eqn]b[/eqn]', start: 14 },
]);
});
it('does not extract math inside spoilers so spoiler parsing keeps working', () => {
expect(splitMathSegments('[spoiler]a [math]x[/math][/spoiler]')).toEqual([
{ type: 'text', value: '[spoiler]a [math]x[/math][/spoiler]', start: 0 },
]);
const mixed = splitMathSegments('[spoiler][math]a[/math][/spoiler] [math]b[/math]');
expect(mixed.map((segment) => segment.type)).toEqual(['text', 'math']);
expect(mixed[1].value).toBe('[math]b[/math]');
});
});