mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// [math]/[eqn] TeX tags are a /sci/ feature (like 4chan): typeset client-side with MathJax,
|
||||
// enabled only when the post's board or the current route resolves to /sci/.
|
||||
export const MATH_DIRECTORY_CODE = 'sci';
|
||||
|
||||
export const isMathDirectoryCode = (directoryCode: string | undefined): boolean => directoryCode?.toLowerCase() === MATH_DIRECTORY_CODE;
|
||||
|
||||
// Tags are matched case-sensitively and must be properly closed, like 4chan's MathJax delimiters.
|
||||
// [\s\S] lets a single [math]/[eqn] region span multiple lines (e.g. pmatrix rows).
|
||||
const MATH_SEGMENT_REGEX = /\[(math|eqn)\]([\s\S]*?)\[\/\1\]/g;
|
||||
export const HAS_MATH_TAG_REGEX = /\[(math|eqn)\][\s\S]*?\[\/\1\]/;
|
||||
|
||||
// Math is not extracted inside [spoiler] regions so existing spoiler parsing keeps working there.
|
||||
const SPOILER_RANGE_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\][\s\S]*?\[\/[sS][pP][oO][iI][lL][eE][rR]\]/g;
|
||||
|
||||
export type MathSegment = { type: 'text' | 'math'; value: string; start: number };
|
||||
|
||||
const getSpoilerRanges = (raw: string): { start: number; end: number }[] => {
|
||||
const ranges: { start: number; end: number }[] = [];
|
||||
const regex = new RegExp(SPOILER_RANGE_REGEX.source, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(raw)) !== null) {
|
||||
ranges.push({ start: match.index, end: regex.lastIndex });
|
||||
}
|
||||
return ranges;
|
||||
};
|
||||
|
||||
// Splits content into text segments (normal markdown pipeline) and math segments (typeset as-is,
|
||||
// delimiters included). Unclosed tags stay plain text.
|
||||
export const splitMathSegments = (raw: string): MathSegment[] => {
|
||||
const segments: MathSegment[] = [];
|
||||
const spoilerRanges = getSpoilerRanges(raw);
|
||||
const regex = new RegExp(MATH_SEGMENT_REGEX.source, 'g');
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(raw)) !== null) {
|
||||
const matchStart = match.index;
|
||||
const matchEnd = regex.lastIndex;
|
||||
|
||||
if (spoilerRanges.some((range) => matchStart >= range.start && matchEnd <= range.end)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
segments.push({ type: 'text', value: raw.slice(lastIndex, matchStart), start: lastIndex });
|
||||
}
|
||||
segments.push({ type: 'math', value: match[0], start: matchStart });
|
||||
lastIndex = matchEnd;
|
||||
}
|
||||
|
||||
if (lastIndex < raw.length) {
|
||||
segments.push({ type: 'text', value: raw.slice(lastIndex), start: lastIndex });
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// MathJax v3 configuration matching 4chan's /sci/ setup: [math] inline and [eqn] display
|
||||
// delimiters only, left-aligned display equations, Safe mode, and shitposting-prone macros
|
||||
// (\color, \newcommand, \def, ...) neutered into no-ops so they silently do nothing.
|
||||
// Must run before the MathJax component modules are imported.
|
||||
|
||||
// Font files are emitted under <base>/mathjax/woff-v2/ by the vite plugin (see vite.config.js),
|
||||
// resolved like the Ruffle runtime so it works in dev, web builds, and Electron.
|
||||
const fontURL = new URL('mathjax/woff-v2', document.baseURI).href;
|
||||
|
||||
(window as unknown as { MathJax: object }).MathJax = {
|
||||
loader: { load: [] },
|
||||
startup: { typeset: false },
|
||||
tex: {
|
||||
// configmacros provides the `macros` option used to neuter the disallowed macros below
|
||||
packages: ['base', 'ams', 'noerrors', 'noundefined', 'configmacros'],
|
||||
inlineMath: [['[math]', '[/math]']],
|
||||
displayMath: [['[eqn]', '[/eqn]']],
|
||||
processEscapes: false,
|
||||
processEnvironments: false,
|
||||
processRefs: false,
|
||||
macros: {
|
||||
color: '{}',
|
||||
newcommand: '{}',
|
||||
renewcommand: '{}',
|
||||
newenvironment: '{}',
|
||||
renewenvironment: '{}',
|
||||
def: '{}',
|
||||
let: '{}',
|
||||
},
|
||||
},
|
||||
chtml: {
|
||||
fontURL,
|
||||
displayAlign: 'left',
|
||||
},
|
||||
options: {
|
||||
enableMenu: true,
|
||||
safeOptions: {
|
||||
allow: {
|
||||
URLs: 'none',
|
||||
classes: 'none',
|
||||
cssIDs: 'none',
|
||||
styles: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// Custom MathJax v3 component build (the official "making a custom build" recipe for bundlers):
|
||||
// the config module must execute first, then the components, then startup wires everything up.
|
||||
// Imported lazily (dynamic import) so MathJax stays out of the main bundle and only loads on
|
||||
// math-enabled boards that actually display equations.
|
||||
import './mathjax-config';
|
||||
import 'mathjax-full/components/src/startup/lib/startup.js';
|
||||
import 'mathjax-full/components/src/core/core.js';
|
||||
import 'mathjax-full/components/src/input/tex-base/tex-base.js';
|
||||
import 'mathjax-full/components/src/input/tex/extensions/ams/ams.js';
|
||||
import 'mathjax-full/components/src/input/tex/extensions/configmacros/configmacros.js';
|
||||
import 'mathjax-full/components/src/input/tex/extensions/noerrors/noerrors.js';
|
||||
import 'mathjax-full/components/src/input/tex/extensions/noundefined/noundefined.js';
|
||||
import 'mathjax-full/components/src/output/chtml/chtml.js';
|
||||
import 'mathjax-full/components/src/output/chtml/fonts/tex/tex.js';
|
||||
import 'mathjax-full/components/src/ui/safe/safe.js';
|
||||
import 'mathjax-full/components/src/ui/menu/menu.js';
|
||||
import 'mathjax-full/components/src/a11y/assistive-mml/assistive-mml.js';
|
||||
import 'mathjax-full/components/src/startup/startup.js';
|
||||
@@ -0,0 +1,62 @@
|
||||
// Lazy MathJax entry point: the heavy setup chunk is fetched once, on the first equation that
|
||||
// actually needs it, and typeset calls are serialized because MathJax's typesetPromise must not
|
||||
// run concurrently with itself.
|
||||
interface MathJaxApi {
|
||||
startup: { promise: Promise<void> };
|
||||
typesetPromise: (elements: HTMLElement[]) => Promise<void>;
|
||||
typesetClear: (elements: HTMLElement[]) => void;
|
||||
}
|
||||
|
||||
let mathJaxPromise: Promise<MathJaxApi | undefined> | undefined;
|
||||
let typesetQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const loadMathJax = (): Promise<MathJaxApi | undefined> => {
|
||||
if (!mathJaxPromise) {
|
||||
mathJaxPromise = import('./mathjax-setup')
|
||||
.then(async () => {
|
||||
const mathJax = (window as unknown as { MathJax: MathJaxApi }).MathJax;
|
||||
await mathJax.startup.promise;
|
||||
return mathJax;
|
||||
})
|
||||
.catch((error) => {
|
||||
// Allow a retry on the next equation (e.g. the chunk failed to download while offline).
|
||||
mathJaxPromise = undefined;
|
||||
console.error('failed to load MathJax', error);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
return mathJaxPromise;
|
||||
};
|
||||
|
||||
// Starts fetching the MathJax chunk ahead of the first typeset (e.g. when the TeX Preview modal
|
||||
// opens), like 4chan loading MathJax as soon as the preview panel is created.
|
||||
export const preloadMathJax = (): void => {
|
||||
loadMathJax();
|
||||
};
|
||||
|
||||
// Resets the element to the raw TeX source, then typesets it in place. Re-running on the same
|
||||
// element is safe (the source reset makes it idempotent), so re-mounts and StrictMode double
|
||||
// effects just re-typeset.
|
||||
export const typesetMathElement = (element: HTMLElement, source: string): Promise<void> => {
|
||||
typesetQueue = typesetQueue.then(async () => {
|
||||
const mathJax = await loadMathJax();
|
||||
if (!mathJax || !element.isConnected) {
|
||||
return;
|
||||
}
|
||||
element.textContent = source;
|
||||
try {
|
||||
await mathJax.typesetPromise([element]);
|
||||
} catch (error) {
|
||||
console.error('failed to typeset math', error);
|
||||
}
|
||||
});
|
||||
return typesetQueue;
|
||||
};
|
||||
|
||||
// Drops MathJax's internal bookkeeping for an element that is being unmounted.
|
||||
export const clearMathElement = (element: HTMLElement): void => {
|
||||
if (!mathJaxPromise) {
|
||||
return;
|
||||
}
|
||||
mathJaxPromise.then((mathJax) => mathJax?.typesetClear([element])).catch(() => {});
|
||||
};
|
||||
Reference in New Issue
Block a user