this repo has no description
1import 'temml/dist/Temml-Local.css';
2
3import { useLingui } from '@lingui/react/macro';
4import { useCallback, useState } from 'preact/hooks';
5
6import showToast from '../utils/show-toast';
7
8import Icon from './icon';
9
10// Follow https://mathstodon.xyz/about
11// > You can use LaTeX in toots here! Use \( and \) for inline, and \[ and \] for display mode.
12const DELIMITERS_PATTERNS = [
13 // '\\$\\$[\\s\\S]*?\\$\\$', // $$...$$
14 '\\\\\\[[\\s\\S]*?\\\\\\]', // \[...\]
15 '\\\\\\([\\s\\S]*?\\\\\\)', // \(...\)
16 // '\\\\begin\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}[\\s\\S]*?\\\\end\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}', // AMS environments
17 // '\\\\(?:ref|eqref)\\{[^}]*\\}', // \ref{...}, \eqref{...}
18];
19const DELIMITERS_REGEX = new RegExp(DELIMITERS_PATTERNS.join('|'), 'g');
20
21function cleanDOMForTemml(dom) {
22 // Define start and end delimiter patterns
23 const START_DELIMITERS = ['\\\\\\[', '\\\\\\(']; // \[ and \(
24 const startRegex = new RegExp(`(${START_DELIMITERS.join('|')})`);
25
26 // Walk through all text nodes
27 const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT);
28 const textNodes = [];
29 let node;
30 while ((node = walker.nextNode())) {
31 textNodes.push(node);
32 }
33
34 for (const textNode of textNodes) {
35 const text = textNode.textContent;
36 const startMatch = text.match(startRegex);
37
38 if (!startMatch) continue; // No start delimiter in this text node
39
40 // Find the matching end delimiter
41 const startDelimiter = startMatch[0];
42 const endDelimiter = startDelimiter === '\\[' ? '\\]' : '\\)';
43
44 // Collect nodes from start delimiter until end delimiter
45 const nodesToCombine = [textNode];
46 let currentNode = textNode;
47 let foundEnd = false;
48 let combinedText = text;
49
50 // Check if end delimiter is in the same text node
51 if (text.includes(endDelimiter)) {
52 foundEnd = true;
53 } else {
54 // Look through sibling nodes
55 while (currentNode.nextSibling && !foundEnd) {
56 const nextSibling = currentNode.nextSibling;
57
58 if (nextSibling.nodeType === Node.TEXT_NODE) {
59 nodesToCombine.push(nextSibling);
60 combinedText += nextSibling.textContent;
61 if (nextSibling.textContent.includes(endDelimiter)) {
62 foundEnd = true;
63 }
64 } else if (
65 nextSibling.nodeType === Node.ELEMENT_NODE &&
66 nextSibling.tagName === 'BR'
67 ) {
68 nodesToCombine.push(nextSibling);
69 combinedText += '\n';
70 } else {
71 // Found a non-BR element, stop and don't process
72 break;
73 }
74
75 currentNode = nextSibling;
76 }
77 }
78
79 // Only process if we found the end delimiter and have nodes to combine
80 if (foundEnd && nodesToCombine.length > 1) {
81 // Replace the first text node with combined text
82 textNode.textContent = combinedText;
83
84 // Remove the other nodes
85 for (let i = 1; i < nodesToCombine.length; i++) {
86 nodesToCombine[i].remove();
87 }
88 }
89 }
90}
91
92const MathBlock = ({ content, contentRef, onRevert }) => {
93 DELIMITERS_REGEX.lastIndex = 0; // Reset index to prevent g trap
94 const hasLatexContent = DELIMITERS_REGEX.test(content);
95
96 if (!hasLatexContent) return null;
97
98 const { t } = useLingui();
99 const [mathRendered, setMathRendered] = useState(false);
100 const toggleMathRendering = useCallback(
101 async (e) => {
102 e.preventDefault();
103 e.stopPropagation();
104 if (mathRendered) {
105 // Revert to original content by refreshing PostContent
106 setMathRendered(false);
107 onRevert();
108 } else {
109 // Render math
110 try {
111 // This needs global because the codebase inside temml is calling a function from global.temml 🤦♂️
112 const temml =
113 window.temml || (window.temml = (await import('temml'))?.default);
114
115 cleanDOMForTemml(contentRef.current);
116 const originalContentRefHTML = contentRef.current.innerHTML;
117 temml.renderMathInElement(contentRef.current, {
118 fences: '(', // This should sync with DELIMITERS_REGEX
119 annotate: true,
120 throwOnError: true,
121 errorCallback: (err) => {
122 console.warn('Failed to render LaTeX:', err);
123 },
124 });
125
126 const hasMath = contentRef.current.querySelector('math.tml-display');
127 const htmlChanged =
128 contentRef.current.innerHTML !== originalContentRefHTML;
129 if (hasMath && htmlChanged) {
130 setMathRendered(true);
131 } else {
132 showToast(t`Unable to format math`);
133 setMathRendered(false);
134 onRevert(); // Revert because DOM modified by cleanDOMForTemml
135 }
136 } catch (e) {
137 console.error('Failed to LaTeX:', e);
138 }
139 }
140 },
141 [mathRendered],
142 );
143
144 return (
145 <div class="math-block">
146 <Icon icon="formula" size="s" /> <span>{t`Math expressions found.`}</span>{' '}
147 <button type="button" class="light small" onClick={toggleMathRendering}>
148 {mathRendered
149 ? t({
150 comment:
151 'Action to switch from rendered math back to raw (LaTeX) markup',
152 message: 'Show markup',
153 })
154 : t({
155 comment:
156 'Action to render math expressions from raw (LaTeX) markup',
157 message: 'Format math',
158 })}
159 </button>
160 </div>
161 );
162};
163
164export default MathBlock;