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
@@ -21,6 +21,7 @@ const testState = vi.hoisted(() => ({
name?: string;
title?: string;
}>,
typesetCalls: [] as string[],
embeddableHosts: new Set<string>(),
cidToNumber: {} as Record<string, number>,
internalPathByHref: {} as Record<string, string | null>,
@@ -89,6 +90,15 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
}));
vi.mock('../../../lib/mathjax/mathjax-typeset', () => ({
typesetMathElement: (_element: HTMLElement, source: string) => {
testState.typesetCalls.push(source);
return Promise.resolve();
},
clearMathElement: () => undefined,
preloadMathJax: () => undefined,
}));
vi.mock('../../../lib/utils/media-utils', () => ({
getHasThumbnail: (linkMediaInfo?: { patternThumbnailUrl?: string; thumbnail?: string; type?: string }, href?: string) =>
Boolean(
@@ -236,6 +246,7 @@ describe('Markdown', () => {
vi.clearAllMocks();
testState.comments = {};
testState.directories = [{ address: 'music-posting.eth', name: 'music-posting.bso', title: '/mu/ - Music' }];
testState.typesetCalls = [];
testState.embeddableHosts = new Set<string>();
testState.cidToNumber = {};
testState.internalPathByHref = {};
@@ -685,4 +696,57 @@ describe('Markdown', () => {
expect(container.querySelector('[data-testid="comment-media"]')).toBeNull();
});
describe('math tags on /sci/', () => {
const useSciDirectory = () => {
testState.directories = [
{ address: 'music-posting.eth', name: 'music-posting.bso', title: '/mu/ - Music' },
{ address: 'science-and-math.eth', directoryCode: 'sci', name: 'science-and-math.bso', title: '/sci/ - Science & Math' },
];
};
it('hands [math] and [eqn] segments to MathJax on /sci/ routes, keeping the delimiters visible', async () => {
useSciDirectory();
await renderMarkdown({ content: 'inline [math]x_1[/math] then\n[eqn]\\int x\\\\dx[/eqn] end' }, '/sci/thread/post-1');
expect(container.textContent).toContain('[math]x_1[/math]');
expect(container.textContent).toContain('[eqn]\\int x\\\\dx[/eqn]');
expect(testState.typesetCalls).toEqual(['[math]x_1[/math]', '[eqn]\\int x\\\\dx[/eqn]']);
});
it('keeps multi-line math in a single segment instead of splitting on line breaks', async () => {
useSciDirectory();
await renderMarkdown({ content: '[math]a\\\\\nb[/math]' }, '/sci/thread/post-1');
expect(testState.typesetCalls).toEqual(['[math]a\\\\\nb[/math]']);
});
it('does not typeset math on non-math boards', async () => {
useSciDirectory();
await renderMarkdown({ content: '[math]x_1[/math]' }, '/mu/thread/post-1');
expect(container.textContent).toContain('[math]x_1[/math]');
expect(testState.typesetCalls).toEqual([]);
});
it('does not typeset math in the catalog, like 4chan teasers', async () => {
useSciDirectory();
await renderMarkdown({ content: '[math]x_1[/math]' }, '/sci/catalog');
expect(container.textContent).toContain('[math]x_1[/math]');
expect(testState.typesetCalls).toEqual([]);
});
it('typesets math for /sci/ posts rendered outside /sci/ routes via their community address', async () => {
useSciDirectory();
await renderMarkdown({ content: '[math]x_1[/math]', communityAddress: 'science-and-math.bso' }, '/all');
expect(testState.typesetCalls).toEqual(['[math]x_1[/math]']);
});
});
});
+24 -1
View File
@@ -7,6 +7,7 @@ import { isCatalogView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import CommentMedia from '../comment-media/comment-media';
import CodeBlock from '../code-block/code-block';
import TexMath from '../tex-math/tex-math';
import styles from './markdown.module.css';
import { Link, useLocation, useParams } from 'react-router-dom';
import { canEmbed } from '../embed/embed-utils';
@@ -27,6 +28,7 @@ import {
getMatchingFortuneEntry,
isFortuneDirectoryCode,
} from '../../lib/utils/post-options-utils';
import { HAS_MATH_TAG_REGEX, isMathDirectoryCode, splitMathSegments } from '../../lib/math-tags';
const safeParseUrl = (href: string): URL | null => {
try {
@@ -812,11 +814,32 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
const enableCodeTags =
getDirectoryCodeForIdentifier(getRouteBoardIdentifier(location.pathname), directories) === CODE_DIRECTORY_CODE ||
getDirectoryCodeForIdentifier(communityAddress, directories) === CODE_DIRECTORY_CODE;
// [math]/[eqn] TeX tags are a /sci/ feature (like 4chan). The catalog is excluded, matching
// 4chan, where teasers show the raw tags and MathJax only runs on board and thread pages.
const enableMathTags =
!isInCatalogView &&
(isMathDirectoryCode(getDirectoryCodeForIdentifier(getRouteBoardIdentifier(location.pathname), directories)) ||
isMathDirectoryCode(getDirectoryCodeForIdentifier(communityAddress, directories)));
const rendered = useMemo(() => {
const context = { isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers };
const raw = content || '';
if (enableMathTags && HAS_MATH_TAG_REGEX.test(raw)) {
const elements: React.ReactNode[] = [];
splitMathSegments(raw).forEach((segment) => {
if (segment.type === 'math') {
elements.push(<TexMath key={`math-${segment.start}`} source={segment.value} />);
return;
}
if (!segment.value) return;
elements.push(
<React.Fragment key={`text-${segment.start}`}>{renderTextLines(normalizeContent(segment.value), context, `${segment.start}:`)}</React.Fragment>,
);
});
return elements;
}
if (!enableCodeTags || !HAS_CODE_TAG_REGEX.test(raw)) {
return renderTextLines(normalizeContent(raw), context, '');
}
@@ -832,7 +855,7 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
});
return elements;
}, [content, isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers, enableCodeTags]);
}, [content, isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers, enableCodeTags, enableMathTags]);
return (
<span className={styles.markdown}>
+19
View File
@@ -34,6 +34,7 @@ import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags';
import { isMathDirectoryCode } from '../../lib/math-tags';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useDirectories } from '../../hooks/use-directories';
import { useDirectoryEntry } from '../../hooks/use-directory-entry';
@@ -54,6 +55,7 @@ import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import OekakiDrawingControls from '../oekaki-drawing-controls/oekaki-drawing-controls';
import TexLogo from '../tex-logo/tex-logo';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import styles from './post-form.module.css';
import capitalize from 'lodash/capitalize';
@@ -181,6 +183,7 @@ interface PostFormFieldsProps {
flashTagOptions: FlashTagOption[];
showFlashTagSelector: boolean;
showFlashUploadPrompt: boolean;
showMathTagsPrompt: boolean;
showBbcodeToolbar: boolean;
onBbcodePreviewToggle: () => void;
onPublishReply: () => void;
@@ -233,6 +236,7 @@ const PostFormFields = ({
flashTagOptions,
showFlashTagSelector,
showFlashUploadPrompt,
showMathTagsPrompt,
showBbcodeToolbar,
onBbcodePreviewToggle,
onPublishReply,
@@ -495,6 +499,19 @@ const PostFormFields = ({
/>
</li>
)}
{showMathTagsPrompt && (
<>
<li>
<Trans
i18nKey='post_form_math_tags_prompt'
components={{
tex: <TexLogo />,
}}
/>
</li>
<li>{t('post_form_math_right_click_prompt')}</li>
</>
)}
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
</ul>
</td>
@@ -557,6 +574,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
const showMathTagsPrompt = isMathDirectoryCode(postOptionsDirectoryCode) || isMathDirectoryCode(directoryEntry?.directoryCode);
const accountCommunityAddresses = useAccountCommunityAddresses();
const accountAddress = account?.author?.address;
@@ -940,6 +958,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
flashTagOptions={FLASH_TAG_OPTIONS}
showFlashTagSelector={showFlashTagSelector}
showFlashUploadPrompt={showFlashUploadPrompt}
showMathTagsPrompt={showMathTagsPrompt}
showBbcodeToolbar={showBbcodeToolbar}
onBbcodePreviewToggle={handleBbcodePreviewToggle}
onPublishReply={onPublishReply}
@@ -227,6 +227,12 @@ vi.mock('../../loading-ellipsis/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../lib/mathjax/mathjax-typeset', () => ({
clearMathElement: () => undefined,
preloadMathJax: () => undefined,
typesetMathElement: () => Promise.resolve(),
}));
vi.mock('lodash/debounce', () => ({
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -514,6 +520,30 @@ describe('ReplyModal', () => {
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
});
it('opens the TeX preview modal from the /sci/ reply modal button', async () => {
testState.directoryByAddress['science-and-math.bso'] = {
address: 'science-and-math.bso',
directoryCode: 'sci',
features: {},
title: '/sci/ - Science & Math',
};
testState.communities['science-and-math.bso'] = { address: 'science-and-math.bso' };
await renderReplyModal('/sci/thread/post-1', 'science-and-math.bso');
const texButton = container.querySelector<HTMLButtonElement>('button[aria-label="preview_tex_equations"]');
expect(texButton?.textContent).toBe('TEX');
expect(texButton?.querySelector('sub')?.textContent).toBe('E');
await act(async () => {
texButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const texPreview = container.querySelector('dialog[aria-labelledby="tex-preview-title"]');
expect(texPreview).toBeTruthy();
expect(texPreview?.textContent).toContain('tex_preview_title');
});
it('shows a flag selector on flag boards and publishes the default geographic request', async () => {
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
@@ -39,6 +39,41 @@
background-image: var(--close-button-background-image);
}
.texButtonTooltip {
float: left;
display: block;
height: 18px;
line-height: 18px;
margin: 0 0 0 3px;
}
.texButton {
all: unset;
display: block;
cursor: pointer;
color: var(--button-desktop-text-color);
font-size: 10.6667px;
font-weight: 700;
line-height: 18px;
text-decoration: none;
}
.texButton:hover,
.texButton:focus-visible {
color: var(--button-desktop-text-color-hover);
text-decoration: none;
}
.texButton:focus-visible {
outline: 1px dotted currentColor;
outline-offset: 1px;
}
.texButton sub {
font-size: 80%;
pointer-events: none;
}
.container input[type="text"], .container textarea, .container select:not(.flagSelector) {
border: var(--reply-modal-field-input-border, revert);
font-family: var(--post-form-field-font-family, revert);
+34 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
@@ -19,6 +19,7 @@ import {
isPostOptionsValidationError,
} from '../../lib/utils/post-options-utils';
import { isValidPublishURL } from '../../lib/utils/url-utils';
import { isMathDirectoryCode } from '../../lib/math-tags';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
@@ -39,6 +40,10 @@ import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import OekakiDrawingControls from '../oekaki-drawing-controls/oekaki-drawing-controls';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import TexLogo from '../tex-logo/tex-logo';
import TexPreviewModal from '../tex-preview-modal/tex-preview-modal';
import Tooltip from '../tooltip/tooltip';
import { preloadMathJax } from '../../lib/mathjax/mathjax-typeset';
import styles from './reply-modal.module.css';
import capitalize from 'lodash/capitalize';
import debounce from 'lodash/debounce';
@@ -71,6 +76,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
const showTexButton = isMathDirectoryCode(postOptionsDirectoryCode) || isMathDirectoryCode(directoryEntry?.directoryCode);
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
@@ -120,6 +126,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const [url, setUrl] = useState('');
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const [showTexPreview, setShowTexPreview] = useState(false);
const closeTexPreview = useCallback(() => setShowTexPreview(false), []);
const checkContentLengthRef = useRef(
debounce((content: string, t: TFunction, options: string, directoryCode: string | undefined) => {
@@ -349,6 +357,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
checkPostOptionsRef.current.cancel();
setIsBbcodePreviewing(false);
setBbcodePreviewContent('');
setShowTexPreview(false);
}
}, [showReplyModal]);
@@ -538,6 +547,22 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}}
>
<div id='reply-modal-title' className={`replyModalHandle ${styles.title}`} {...(!isMobile ? bind() : {})}>
{showTexButton && !isMobile && (
<Tooltip content={t('preview_tex_equations')} className={styles.texButtonTooltip}>
<button
type='button'
className={styles.texButton}
onClick={(e) => {
e.stopPropagation();
preloadMathJax();
setShowTexPreview(true);
}}
aria-label={t('preview_tex_equations')}
>
<TexLogo />
</button>
</Tooltip>
)}
{t('reply_to_no', { no: threadNumber ?? '?' })}
<button
type='button'
@@ -688,7 +713,14 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
</animated.div>
);
return showReplyModal && modalContent;
return (
showReplyModal && (
<>
{modalContent}
{showTexPreview && <TexPreviewModal closeModal={closeTexPreview} />}
</>
)
);
};
export default ReplyModal;
+9
View File
@@ -0,0 +1,9 @@
// The classic "TeX" wordmark with a subscripted "E", used by the /sci/ post form rules, the
// reply modal's TeX button and the TeX Preview modal title.
const TexLogo = ({ className }: { className?: string }) => (
<span className={className}>
T<sub>E</sub>X
</span>
);
export default TexLogo;
+23
View File
@@ -0,0 +1,23 @@
import { memo, useEffect, useRef } from 'react';
import { clearMathElement, typesetMathElement } from '../../lib/mathjax/mathjax-typeset';
// Renders a [math]/[eqn] segment. The raw TeX source (delimiters included) stays visible until
// MathJax lazy-loads and typesets it in place, like 4chan. MathJax owns this span's subtree after
// typesetting, so React must never re-render children inside it: the source text is rendered once
// and the effect below (a legit external-DOM-library sync, not data fetching) hands the node over.
const TexMath = ({ source }: { source: string }) => {
const elementRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) {
return;
}
typesetMathElement(element, source);
return () => clearMathElement(element);
}, [source]);
return <span ref={elementRef}>{source}</span>;
};
export default memo(TexMath);
@@ -0,0 +1,106 @@
/* Layout mirrors 4chan's TeX Preview panel: fullscreen dimmed overlay, 600px centered panel,
bold bordered header, hint line, 75px monospace input and a live preview area below it. */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.25);
font-size: 14px;
line-height: 14px;
}
.overlayButton {
all: unset;
position: absolute;
inset: 0;
cursor: default;
z-index: 0;
}
.panel {
box-sizing: border-box;
position: absolute;
display: inline-block;
width: 600px;
max-width: calc(100% - 20px);
max-height: calc(100vh - 20px);
overflow: auto;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
padding: 2px;
background-color: var(--challenge-modal-background-color, rgb(240, 224, 214));
border: var(--challenge-modal-border, 1px solid rgb(217, 191, 183));
color: var(--body-font-color, rgb(0, 0, 0));
z-index: 1;
}
.header {
position: relative;
font-weight: bold;
font-size: 16px;
line-height: 14px;
text-align: center;
margin: 0 0 5px;
padding: 5px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}
.titleText {
display: block;
}
.texLogo {
font-size: 80%;
font-weight: 700;
}
.texLogo sub {
font-size: 80%;
}
.closeIcon {
all: unset;
position: absolute;
top: 0;
right: 0;
cursor: pointer;
width: 18px;
height: 18px;
image-rendering: pixelated;
background-image: var(--close-button-background-image);
}
.protip {
font-size: 11px;
margin: 5px 0;
text-align: center;
}
.input {
display: block;
box-sizing: border-box;
min-width: 100%;
max-width: 100%;
height: 75px;
margin: 0 0 5px;
padding: 2px;
font-family: monospace;
font-size: 13px;
border: 1px solid #aaa;
outline: none;
background-color: var(--post-form-field-input-background-color, rgb(255, 255, 255));
color: var(--post-form-field-input-color, rgb(0, 0, 0));
}
.output {
box-sizing: border-box;
min-height: 75px;
white-space: pre;
padding: 0 3px;
overflow-x: auto;
}
@@ -0,0 +1,74 @@
import { useEffect, useRef } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { clearMathElement, typesetMathElement } from '../../lib/mathjax/mathjax-typeset';
import TexLogo from '../tex-logo/tex-logo';
import styles from './tex-preview-modal.module.css';
const TYPESET_DEBOUNCE_MS = 50;
// Live TeX preview opened from the reply modal's TeX button: a textarea over a preview area that
// re-typesets what you type, so equations can be checked before posting.
const TexPreviewModal = ({ closeModal }: { closeModal: () => void }) => {
const { t } = useTranslation();
const outputRef = useRef<HTMLDivElement>(null);
const typesetTimeoutRef = useRef<number>(undefined);
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = event.target;
window.clearTimeout(typesetTimeoutRef.current);
typesetTimeoutRef.current = window.setTimeout(() => {
if (outputRef.current) {
typesetMathElement(outputRef.current, value);
}
}, TYPESET_DEBOUNCE_MS);
};
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
closeModal();
}
};
// Capture phase so Escape closes this modal before the reply modal's own Escape handler.
document.addEventListener('keydown', closeOnEscape, true);
return () => document.removeEventListener('keydown', closeOnEscape, true);
}, [closeModal]);
// On close, cancel any pending typeset and drop MathJax's bookkeeping for the preview output,
// like TexMath does, so repeated open/close cycles do not accumulate detached math items.
useEffect(() => {
const output = outputRef.current;
return () => {
window.clearTimeout(typesetTimeoutRef.current);
if (output) {
clearMathElement(output);
}
};
}, []);
return (
<div className={styles.overlay}>
<button type='button' className={styles.overlayButton} aria-label={t('close')} onClick={closeModal} />
<dialog open className={styles.panel} aria-modal='true' aria-labelledby='tex-preview-title'>
<div id='tex-preview-title' className={styles.header}>
<span className={styles.titleText}>
<Trans
i18nKey='tex_preview_title'
components={{
tex: <TexLogo className={styles.texLogo} />,
}}
/>
</span>
<button type='button' className={styles.closeIcon} title={t('close')} aria-label={t('close')} onClick={closeModal} />
</div>
<div className={styles.protip}>{t('tex_preview_protip')}</div>
<textarea className={styles.input} aria-label={t('tex_preview_input_label')} spellCheck={false} onChange={handleInputChange} />
<div className={styles.output} ref={outputRef} />
</dialog>
</div>
);
};
export default TexPreviewModal;
+3 -2
View File
@@ -5,10 +5,11 @@ import styles from './tooltip.module.css';
interface TooltipProps {
content: ReactNode;
children: ReactNode;
className?: string;
showTooltip?: boolean;
}
const Tooltip = ({ content, children, showTooltip = true }: TooltipProps) => {
const Tooltip = ({ content, children, className, showTooltip = true }: TooltipProps) => {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
@@ -28,7 +29,7 @@ const Tooltip = ({ content, children, showTooltip = true }: TooltipProps) => {
return (
<>
<span ref={refs.setReference} {...getReferenceProps()}>
<span className={className} ref={refs.setReference} {...getReferenceProps()}>
{children}
</span>
{showTooltip && (
+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]');
});
});
+56
View File
@@ -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;
};
+46
View File
@@ -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',
},
},
},
};
+18
View File
@@ -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';
+62
View File
@@ -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(() => {});
};
+2
View File
@@ -0,0 +1,2 @@
// mathjax-full ships its component entry modules as plain untyped JS.
declare module 'mathjax-full/components/src/*';