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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user