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
@@ -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;