Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

MathJax works now + RN compatible DOM

uwx c89ca6f7 59874a64

+507 -538
+2
package.json
··· 109 109 "@growthbook/growthbook-react": "^1.6.5", 110 110 "@haileyok/bluesky-video": "0.3.2", 111 111 "@ipld/dag-cbor": "^9.2.0", 112 + "@journeyapps/domparser": "^0.4.1", 112 113 "@lingui/core": "^5.9.2", 113 114 "@lingui/react": "^5.9.2", 114 115 "@mathjax/src": "^4.1.1", ··· 243 244 "tldts": "^6.1.46", 244 245 "unicode-segmenter": "^0.14.5", 245 246 "uri-js": "^4.4.1", 247 + "xmldom": "^0.6.0", 246 248 "zod": "^3.20.2" 247 249 }, 248 250 "devDependencies": {
+366 -295
src/components/Post/MastodonHtmlContent.tsx
··· 7 7 type ViewStyle, 8 8 } from 'react-native' 9 9 import {type AppBskyFeedPost} from '@atproto/api' 10 + import {DOMParser, type XMLElement, type XMLNode} from '@journeyapps/domparser' 10 11 import {msg} from '@lingui/core/macro' 11 12 import {useLingui} from '@lingui/react' 12 13 import {Trans} from '@lingui/react/macro' 13 14 14 - import {MathJaxSvg} from '#/lib/mathjax' 15 + import {MathJaxSvgText} from '#/lib/mathjax' 15 16 import {useRenderMastodonHtml} from '#/state/preferences/render-mastodon-html' 16 17 import {atoms as a} from '#/alf' 17 18 import {Button, ButtonText} from '#/components/Button' ··· 19 20 import {P, Text} from '#/components/Typography' 20 21 import {RichTextTag} from '../RichTextTag' 21 22 23 + function toArray<T>(arrayLike: ArrayLike<T> | undefined): T[] | undefined { 24 + if (!arrayLike) return undefined 25 + const result: T[] = [] 26 + for (let i = 0; i < arrayLike.length; i++) { 27 + result.push(arrayLike[i]) 28 + } 29 + return result 30 + } 31 + 32 + function map<T, U>( 33 + array: ArrayLike<T> | undefined, 34 + fn: (item: T, index: number) => U, 35 + ): U[] | undefined { 36 + if (!array) return undefined 37 + const result: U[] = [] 38 + for (let i = 0; i < array.length; i++) { 39 + result.push(fn(array[i], i)) 40 + } 41 + return result 42 + } 43 + 22 44 interface MastodonHtmlContentProps { 23 45 record: AppBskyFeedPost.Record 24 46 style?: StyleProp<ViewStyle> ··· 35 57 const fullText = record.fullText as string | undefined 36 58 const bridgyOriginalText = record.bridgyOriginalText as string | undefined 37 59 38 - return ( 39 - !!(fullText || bridgyOriginalText) && typeof DOMParser !== 'undefined' 40 - ) 60 + return !!(fullText || bridgyOriginalText) 41 61 }, [record, renderMastodonHtml]) 42 62 } 43 63 ··· 114 134 _numberOfLines?: number, 115 135 inputTextStyle?: StyleProp<TextStyle>, 116 136 ): React.ReactNode { 117 - if (typeof DOMParser === 'undefined') { 118 - return null 119 - } 137 + const doc = new DOMParser().parseFromString( 138 + `<html>${html}</html>`, 139 + 'text/html', 140 + ) 120 141 121 - const parser = new DOMParser() 122 - const doc = parser.parseFromString(html, 'text/html') 142 + console.log(doc) 123 143 124 144 const base = null // TODO: find instance URL, allow relative URLs. mastodon allows this 125 145 ··· 129 149 inputTextStyle, 130 150 ] 131 151 152 + console.log(textStyle) 153 + 132 154 // Sanitize and render in a single pass 133 155 const renderNode = ( 134 - node: Node, 156 + node: XMLNode, 135 157 key: string, 136 158 insideLink = false, 137 159 listItemIndex?: number, 138 160 ): React.ReactNode => { 139 - if (node.nodeType === Node.TEXT_NODE) { 161 + key = `${key}-${node.nodeName.toLowerCase()}` 162 + if (node.nodeName === '#text') { 140 163 // Don't wrap text in styled Text component if inside a link 141 164 if (insideLink) { 142 165 return node.nodeValue ··· 148 171 ) 149 172 } 150 173 151 - if (node.nodeType === Node.ELEMENT_NODE) { 152 - const element = node as Element 153 - const tagName = element.tagName.toLowerCase() 174 + if (node.nodeName === '#comment') { 175 + return null 176 + } 154 177 155 - switch (tagName) { 156 - case 'math': 157 - const mathText = extractMathAnnotation(element) 158 - if (mathText) { 159 - // return null 160 - return ( 161 - <MathJaxSvg key={key} fontCache={true}> 162 - {mathText} 163 - </MathJaxSvg> 164 - ) 165 - } 166 - return null 167 - case 'p': { 168 - const children = [...element.childNodes].map((child, i) => 169 - renderNode(child, String(i), insideLink), 170 - ) 178 + if (node.nodeName === '#cdata-section') { 179 + return ( 180 + <Text key={key} style={textStyle} emoji> 181 + {node.nodeValue} 182 + </Text> 183 + ) 184 + } 171 185 186 + if (node.nodeName === '#document') { 187 + return ( 188 + <> 189 + {map(node.childNodes, (child, i) => 190 + renderNode(child, `${key}-${i}`, insideLink), 191 + )} 192 + </> 193 + ) 194 + } 195 + 196 + const element = node as XMLElement 197 + const tagName = element.tagName.toLowerCase() 198 + 199 + console.log(tagName) 200 + 201 + switch (tagName) { 202 + case 'math': 203 + const mathText = extractMathAnnotation(element) 204 + if (mathText) { 172 205 return ( 173 - <P key={key} style={textStyle} emoji> 174 - {children} 175 - </P> 206 + <MathJaxSvgText key={key} fontCache={true}> 207 + {`$${mathText}$`} 208 + </MathJaxSvgText> 176 209 ) 177 210 } 178 - case 'blockquote': { 179 - const children = [...element.childNodes].map((child, i) => 211 + return null 212 + case 'p': { 213 + const children = 214 + map(element.childNodes, (child, i) => 180 215 renderNode(child, String(i), insideLink), 181 - ) 216 + ) ?? [] 182 217 183 - return ( 184 - <View 185 - key={key} 186 - style={{ 187 - borderLeftWidth: 3, 188 - borderLeftColor: '#888', 189 - paddingLeft: 12, 190 - marginVertical: 4, 191 - }}> 192 - <P style={textStyle} emoji> 193 - {children} 194 - </P> 195 - </View> 196 - ) 197 - } 198 - case 'pre': { 199 - const children = [...element.childNodes].map((child, i) => 218 + return ( 219 + <P key={key} style={textStyle} emoji> 220 + {children} 221 + </P> 222 + ) 223 + } 224 + case 'blockquote': { 225 + const children = 226 + map(element.childNodes, (child, i) => 200 227 renderNode(child, String(i), insideLink), 201 - ) 228 + ) ?? [] 202 229 203 - return ( 204 - <View 205 - key={key} 206 - style={{ 207 - padding: 8, 208 - borderRadius: 4, 209 - marginVertical: 4, 210 - }}> 211 - <P style={[textStyle, {fontFamily: 'monospace'}]} emoji> 212 - {children} 213 - </P> 214 - </View> 215 - ) 216 - } 217 - case 'code': { 218 - const children = [...element.childNodes].map((child, i) => 230 + return ( 231 + <View 232 + key={key} 233 + style={{ 234 + borderLeftWidth: 3, 235 + borderLeftColor: '#888', 236 + paddingLeft: 12, 237 + marginVertical: 4, 238 + }}> 239 + <P style={textStyle} emoji> 240 + {children} 241 + </P> 242 + </View> 243 + ) 244 + } 245 + case 'pre': { 246 + const children = 247 + map(element.childNodes, (child, i) => 219 248 renderNode(child, String(i), insideLink), 220 - ) 249 + ) ?? [] 221 250 222 - return ( 223 - <Text 224 - key={key} 225 - style={[ 226 - textStyle, 227 - { 228 - fontFamily: 'monospace', 229 - paddingHorizontal: 4, 230 - borderRadius: 2, 231 - }, 232 - ]} 233 - emoji> 251 + return ( 252 + <View 253 + key={key} 254 + style={{ 255 + padding: 8, 256 + borderRadius: 4, 257 + marginVertical: 4, 258 + }}> 259 + <P style={[textStyle, {fontFamily: 'monospace'}]} emoji> 234 260 {children} 235 - </Text> 236 - ) 237 - } 238 - case 'strong': 239 - case 'b': { 240 - const children = [...element.childNodes].map((child, i) => 261 + </P> 262 + </View> 263 + ) 264 + } 265 + case 'code': { 266 + const children = 267 + map(element.childNodes, (child, i) => 268 + renderNode(child, String(i), insideLink), 269 + ) ?? [] 270 + 271 + return ( 272 + <Text 273 + key={key} 274 + style={[ 275 + textStyle, 276 + { 277 + fontFamily: 'monospace', 278 + paddingHorizontal: 4, 279 + borderRadius: 2, 280 + }, 281 + ]} 282 + emoji> 283 + {children} 284 + </Text> 285 + ) 286 + } 287 + case 'strong': 288 + case 'b': { 289 + const children = 290 + map(element.childNodes, (child, i) => 291 + renderNode(child, String(i), insideLink), 292 + ) ?? [] 293 + 294 + return ( 295 + <Text key={key} style={[textStyle, a.font_bold]} emoji> 296 + {children} 297 + </Text> 298 + ) 299 + } 300 + case 'em': 301 + case 'i': { 302 + const children = 303 + map(element.childNodes, (child, i) => 304 + renderNode(child, String(i), insideLink), 305 + ) ?? [] 306 + 307 + return ( 308 + <Text key={key} style={[textStyle, a.italic]} emoji> 309 + {children} 310 + </Text> 311 + ) 312 + } 313 + case 'u': { 314 + const children = 315 + map(element.childNodes, (child, i) => 241 316 renderNode(child, String(i), insideLink), 242 - ) 317 + ) ?? [] 243 318 244 - return ( 245 - <Text key={key} style={[textStyle, a.font_bold]} emoji> 246 - {children} 247 - </Text> 248 - ) 249 - } 250 - case 'em': 251 - case 'i': { 252 - const children = [...element.childNodes].map((child, i) => 319 + return ( 320 + <Text key={key} style={[textStyle, a.underline]} emoji> 321 + {children} 322 + </Text> 323 + ) 324 + } 325 + case 'del': 326 + case 's': { 327 + const children = 328 + map(element.childNodes, (child, i) => 253 329 renderNode(child, String(i), insideLink), 254 - ) 330 + ) ?? [] 255 331 256 - return ( 257 - <Text key={key} style={[textStyle, a.italic]} emoji> 258 - {children} 259 - </Text> 260 - ) 261 - } 262 - case 'u': { 263 - const children = [...element.childNodes].map((child, i) => 332 + return ( 333 + <Text key={key} style={[textStyle, a.strike_through]} emoji> 334 + {children} 335 + </Text> 336 + ) 337 + } 338 + case 'ul': { 339 + const children = 340 + map(element.childNodes, (child, i) => 264 341 renderNode(child, String(i), insideLink), 265 - ) 342 + ) ?? [] 266 343 267 - return ( 268 - <Text key={key} style={[textStyle, a.underline]} emoji> 269 - {children} 270 - </Text> 271 - ) 272 - } 273 - case 'del': 274 - case 's': { 275 - const children = [...element.childNodes].map((child, i) => 344 + return ( 345 + <View key={key} style={{marginVertical: 4}}> 346 + {children} 347 + </View> 348 + ) 349 + } 350 + case 'ol': { 351 + const start = element.getAttribute('start') 352 + const startNum = start ? parseInt(start, 10) : 1 353 + return ( 354 + <View key={key} style={{marginVertical: 4}}> 355 + {toArray(element.childNodes) 356 + ?.filter(child => child.nodeName === 'LI') 357 + .map((child, i) => 358 + renderNode(child, `${key}-${i}`, insideLink, startNum + i), 359 + ) ?? []} 360 + </View> 361 + ) 362 + } 363 + case 'li': { 364 + const children = 365 + map(element.childNodes, (child, i) => 276 366 renderNode(child, String(i), insideLink), 277 - ) 367 + ) ?? [] 278 368 279 - return ( 280 - <Text key={key} style={[textStyle, a.strike_through]} emoji> 369 + const marker = 370 + listItemIndex !== undefined ? `${listItemIndex}.` : '\u2022' 371 + return ( 372 + <View key={key} style={{flexDirection: 'row', marginVertical: 2}}> 373 + <Text style={[textStyle, {marginRight: 8}]} emoji> 374 + {marker} 375 + </Text> 376 + <Text style={[textStyle, {flex: 1}]} emoji> 281 377 {children} 282 378 </Text> 283 - ) 284 - } 285 - case 'ul': { 286 - const children = [...element.childNodes].map((child, i) => 379 + </View> 380 + ) 381 + } 382 + case 'ruby': { 383 + const children = 384 + map(element.childNodes, (child, i) => 287 385 renderNode(child, String(i), insideLink), 288 - ) 386 + ) ?? [] 289 387 290 - return ( 291 - <View key={key} style={{marginVertical: 4}}> 292 - {children} 293 - </View> 294 - ) 295 - } 296 - case 'ol': { 297 - const start = element.getAttribute('start') 298 - const startNum = start ? parseInt(start, 10) : 1 299 - return ( 300 - <View key={key} style={{marginVertical: 4}}> 301 - {Array.from(element.childNodes) 302 - .filter( 303 - child => 304 - child.nodeType === Node.ELEMENT_NODE && 305 - (child as Element).tagName.toLowerCase() === 'li', 306 - ) 307 - .map((child, i) => 308 - renderNode(child, `${key}-${i}`, insideLink, startNum + i), 309 - )} 310 - </View> 311 - ) 312 - } 313 - case 'li': { 314 - const children = [...element.childNodes].map((child, i) => 315 - renderNode(child, String(i), insideLink), 316 - ) 388 + return ( 389 + <Text key={key} style={textStyle} emoji> 390 + {children} 391 + </Text> 392 + ) 393 + } 394 + case 'rt': 395 + case 'rp': 396 + return null // TODO support ruby text rendering 397 + case 'a': { 398 + const children = 399 + map(element.childNodes, (child, i) => 400 + renderNode(child, String(i), true), 401 + ) ?? [] 317 402 318 - const marker = 319 - listItemIndex !== undefined ? `${listItemIndex}.` : '\u2022' 320 - return ( 321 - <View key={key} style={{flexDirection: 'row', marginVertical: 2}}> 322 - <Text style={[textStyle, {marginRight: 8}]} emoji> 323 - {marker} 324 - </Text> 325 - <Text style={[textStyle, {flex: 1}]} emoji> 403 + const href = element.getAttribute('href') 404 + if (href) { 405 + // Returns null if invalid or unsupported URL, otherwise returns an absolute URL string 406 + const url = constructHref(href, base) 407 + if (url) { 408 + const linkText = 409 + element.textContent || 410 + element.getAttribute('aria-label') || 411 + getDisplayUrl(url) 412 + const isInvisible = hasClass(element, 'invisible') 413 + return ( 414 + <InlineLinkText 415 + key={key} 416 + to={url.toString()} 417 + label={linkText} 418 + shouldProxy 419 + style={isInvisible ? {display: 'none'} : textStyle}> 326 420 {children} 327 - </Text> 328 - </View> 329 - ) 421 + </InlineLinkText> 422 + ) 423 + } 330 424 } 331 - case 'ruby': { 332 - const children = [...element.childNodes].map((child, i) => 425 + return ( 426 + <Text key={key} style={textStyle} emoji> 427 + {children} 428 + </Text> 429 + ) 430 + } 431 + case 'br': 432 + return ( 433 + <Text key={key} style={textStyle} emoji> 434 + {'\n'} 435 + </Text> 436 + ) 437 + case 'span': { 438 + const children = 439 + map(element.childNodes, (child, i) => 333 440 renderNode(child, String(i), insideLink), 334 - ) 441 + ) ?? [] 335 442 443 + // Handle invisible/ellipsis classes for link formatting 444 + if (hasClass(element, 'invisible')) { 336 445 return ( 337 - <Text key={key} style={textStyle} emoji> 446 + <Text key={key} style={{display: 'none'}} aria-hidden emoji> 338 447 {children} 339 448 </Text> 340 449 ) 341 450 } 342 - case 'rt': 343 - case 'rp': 344 - return null // TODO support ruby text rendering 345 - case 'a': { 346 - const children = [...element.childNodes].map((child, i) => 347 - renderNode(child, String(i), true), 348 - ) 349 - 350 - const href = element.getAttribute('href') 351 - if (href) { 352 - // Returns null if invalid or unsupported URL, otherwise returns an absolute URL string 353 - const url = constructHref(href, base) 354 - if (url) { 355 - const linkText = 356 - element.textContent || 357 - element.getAttribute('aria-label') || 358 - getDisplayUrl(url) 359 - const isInvisible = hasClass(element, 'invisible') 360 - return ( 361 - <InlineLinkText 362 - key={key} 363 - to={url.toString()} 364 - label={linkText} 365 - shouldProxy 366 - style={isInvisible ? {display: 'none'} : textStyle}> 367 - {children} 368 - </InlineLinkText> 369 - ) 370 - } 451 + if (hasClass(element, 'ellipsis')) { 452 + // If inside a link, return plain text, otherwise wrapped 453 + if (insideLink) { 454 + return '\u2026' 371 455 } 372 456 return ( 373 457 <Text key={key} style={textStyle} emoji> 374 - {children} 458 + {'\u2026'} 375 459 </Text> 376 460 ) 377 461 } 378 - case 'br': 379 - return ( 380 - <Text key={key} style={textStyle} emoji> 381 - {'\n'} 382 - </Text> 383 - ) 384 - case 'span': { 385 - const children = [...element.childNodes].map((child, i) => 386 - renderNode(child, String(i), insideLink), 387 - ) 388 - 389 - // Handle invisible/ellipsis classes for link formatting 390 - if (hasClass(element, 'invisible')) { 391 - return ( 392 - <Text key={key} style={{display: 'none'}} aria-hidden emoji> 393 - {children} 394 - </Text> 395 - ) 462 + // Handle hashtags 463 + if (hasClass(element, 'hashtag')) { 464 + // If inside a link, return children as-is without wrapping 465 + if (insideLink) { 466 + return children 396 467 } 397 - if (hasClass(element, 'ellipsis')) { 398 - // If inside a link, return plain text, otherwise wrapped 399 - if (insideLink) { 400 - return '\u2026' 401 - } 402 - return ( 403 - <Text key={key} style={textStyle} emoji> 404 - {'\u2026'} 405 - </Text> 406 - ) 468 + 469 + const tagText = element.textContent?.trim().replace(/^#/, '') 470 + if (!tagText) { 471 + return null 407 472 } 408 - // Handle hashtags 409 - if (hasClass(element, 'hashtag')) { 410 - // If inside a link, return children as-is without wrapping 411 - if (insideLink) { 412 - return children 413 - } 414 473 415 - const tagText = element.textContent?.trim().replace(/^#/, '') 416 - if (!tagText) { 417 - return null 418 - } 419 - 420 - return ( 421 - <RichTextTag 422 - key={key} 423 - display={tagText} 424 - tag={tagText} 425 - textStyle={[textStyle, a.underline]} 426 - /> 427 - ) 428 - } 429 - // Handle mentions 430 - if (hasClass(element, 'mention')) { 431 - // If inside a link, return children as-is without wrapping 432 - if (insideLink) { 433 - return children 434 - } 435 - return ( 436 - <Text key={key} style={textStyle} emoji> 437 - {children} 438 - </Text> 439 - ) 440 - } 441 - // For spans inside links, return children without wrapping 474 + return ( 475 + <RichTextTag 476 + key={key} 477 + display={tagText} 478 + tag={tagText} 479 + textStyle={[textStyle, a.underline]} 480 + /> 481 + ) 482 + } 483 + // Handle mentions 484 + if (hasClass(element, 'mention')) { 485 + // If inside a link, return children as-is without wrapping 442 486 if (insideLink) { 443 487 return children 444 488 } ··· 448 492 </Text> 449 493 ) 450 494 } 451 - case 'h1': 452 - case 'h2': 453 - case 'h3': 454 - case 'h4': 455 - case 'h5': 456 - case 'h6': { 457 - const children = [...element.childNodes].map((child, i) => 495 + // For spans inside links, return children without wrapping 496 + if (insideLink) { 497 + return children 498 + } 499 + return ( 500 + <Text key={key} style={textStyle} emoji> 501 + {children} 502 + </Text> 503 + ) 504 + } 505 + case 'h1': 506 + case 'h2': 507 + case 'h3': 508 + case 'h4': 509 + case 'h5': 510 + case 'h6': { 511 + const children = 512 + map(element.childNodes, (child, i) => 458 513 renderNode(child, String(i), insideLink), 459 - ) 514 + ) ?? [] 460 515 461 - return ( 462 - <P key={key} style={textStyle} emoji> 463 - <Text style={[textStyle, {fontWeight: 'bold'}]} emoji> 464 - {children} 465 - </Text> 466 - </P> 467 - ) 468 - } 469 - default: 470 - // Render node contents 471 - return ( 472 - <> 473 - {[...element.childNodes].map((child, i) => 474 - renderNode(child, `${key}-${i}`, insideLink), 475 - )} 476 - </> 477 - ) 516 + return ( 517 + <P key={key} style={textStyle} emoji> 518 + <Text style={[textStyle, {fontWeight: 'bold'}]} emoji> 519 + {children} 520 + </Text> 521 + </P> 522 + ) 478 523 } 524 + default: 525 + // Render node contents 526 + return ( 527 + <> 528 + {map(element.childNodes, (child, i) => 529 + renderNode(child, `${key}-${i}`, insideLink), 530 + ) ?? []} 531 + </> 532 + ) 479 533 } 480 - 481 - return null 482 534 } 483 535 484 - const content = Array.from(doc.body.childNodes).map((node, i) => 485 - renderNode(node, String(i)), 486 - ) 536 + const content = 537 + map(doc.childNodes, (node, i) => renderNode(node, String(i))) ?? [] 487 538 488 539 return <View style={{gap: 8}}>{content}</View> 489 540 } 490 541 491 - function hasClass(element: Element, name: string): boolean { 492 - return element.classList.contains(name) 542 + function hasClass(element: XMLElement, name: string): boolean { 543 + return element.getAttribute('class')?.split(/\s+/).includes(name) ?? false 493 544 } 494 545 495 546 function constructHref(href: string, base: string | null): URL | null { ··· 518 569 return null 519 570 } 520 571 521 - function extractMathAnnotation(mathElement: Element): string | null { 572 + function getElementsByTagName( 573 + element: XMLElement, 574 + tagName: string, 575 + ): XMLElement[] { 576 + const result: XMLElement[] = [] 577 + const children = element.children 578 + if (!children) return result 579 + 580 + for (let i = 0; i < children.length; i++) { 581 + const child = children[i] 582 + if (child.tagName.toLowerCase() === tagName.toLowerCase()) { 583 + result.push(child) 584 + } 585 + result.push(...getElementsByTagName(child, tagName)) 586 + } 587 + return result 588 + } 589 + 590 + function extractMathAnnotation(mathElement: XMLElement): string | null { 522 591 // look for <annotation encoding="application/x-tex"> or <annotation encoding="text/plain"> 523 592 return ( 524 - mathElement 525 - .querySelector( 526 - 'annotation[encoding="application/x-tex"], annotation[encoding="text/plain"]', 593 + getElementsByTagName(mathElement, 'annotation') 594 + .find( 595 + e => 596 + e.getAttribute('encoding') === 'application/x-tex' || 597 + e.getAttribute('encoding') === 'text/plain', 527 598 ) 528 599 ?.textContent?.trim() || null 529 600 )
+129 -243
src/lib/mathjax/index.tsx
··· 23 23 */ 24 24 25 25 // Side-effect imports to register TeX extensions with ConfigurationHandler 26 - import '@mathjax/src/js/input/tex/action/ActionConfiguration.js' 27 26 import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js' 28 - import '@mathjax/src/js/input/tex/amscd/AmsCdConfiguration.js' 29 - import '@mathjax/src/js/input/tex/autoload/AutoloadConfiguration.js' 30 - import '@mathjax/src/js/input/tex/bbm/BbmConfiguration.js' 31 - import '@mathjax/src/js/input/tex/bboldx/BboldxConfiguration.js' 32 - import '@mathjax/src/js/input/tex/bbox/BboxConfiguration.js' 33 - import '@mathjax/src/js/input/tex/begingroup/BegingroupConfiguration.js' 34 - import '@mathjax/src/js/input/tex/boldsymbol/BoldsymbolConfiguration.js' 35 - import '@mathjax/src/js/input/tex/braket/BraketConfiguration.js' 36 - import '@mathjax/src/js/input/tex/bussproofs/BussproofsConfiguration.js' 37 - import '@mathjax/src/js/input/tex/cancel/CancelConfiguration.js' 38 - import '@mathjax/src/js/input/tex/cases/CasesConfiguration.js' 39 - import '@mathjax/src/js/input/tex/centernot/CenternotConfiguration.js' 40 - import '@mathjax/src/js/input/tex/color/ColorConfiguration.js' 41 - import '@mathjax/src/js/input/tex/colortbl/ColortblConfiguration.js' 42 - import '@mathjax/src/js/input/tex/colorv2/ColorV2Configuration.js' 43 - import '@mathjax/src/js/input/tex/configmacros/ConfigMacrosConfiguration.js' 44 - import '@mathjax/src/js/input/tex/dsfont/DsfontConfiguration.js' 45 - import '@mathjax/src/js/input/tex/empheq/EmpheqConfiguration.js' 46 - import '@mathjax/src/js/input/tex/enclose/EncloseConfiguration.js' 47 - import '@mathjax/src/js/input/tex/extpfeil/ExtpfeilConfiguration.js' 48 - import '@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js' 49 - import '@mathjax/src/js/input/tex/html/HtmlConfiguration.js' 50 - import '@mathjax/src/js/input/tex/mathtools/MathtoolsConfiguration.js' 51 - import '@mathjax/src/js/input/tex/mhchem/MhchemConfiguration.js' 52 27 import '@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js' 53 28 import '@mathjax/src/js/input/tex/noerrors/NoErrorsConfiguration.js' 54 29 import '@mathjax/src/js/input/tex/noundefined/NoUndefinedConfiguration.js' 55 - import '@mathjax/src/js/input/tex/physics/PhysicsConfiguration.js' 56 - import '@mathjax/src/js/input/tex/require/RequireConfiguration.js' 57 - import '@mathjax/src/js/input/tex/setoptions/SetOptionsConfiguration.js' 58 - import '@mathjax/src/js/input/tex/tagformat/TagFormatConfiguration.js' 59 - import '@mathjax/src/js/input/tex/texhtml/TexHtmlConfiguration.js' 60 - import '@mathjax/src/js/input/tex/textcomp/TextcompConfiguration.js' 61 - import '@mathjax/src/js/input/tex/textmacros/TextMacrosConfiguration.js' 62 - import '@mathjax/src/js/input/tex/unicode/UnicodeConfiguration.js' 63 - import '@mathjax/src/js/input/tex/units/UnitsConfiguration.js' 64 - import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js' 65 - import '@mathjax/src/js/input/tex/verb/VerbConfiguration.js' 66 30 67 - import React, {Fragment, memo, type ReactNode} from 'react' 31 + import {memo, type ReactNode} from 'react' 68 32 import { 69 33 type StyleProp, 70 34 Text, ··· 72 36 View, 73 37 type ViewStyle, 74 38 } from 'react-native' 75 - import {SvgFromXml} from 'react-native-svg' 39 + import {SvgXml} from 'react-native-svg' 40 + import { 41 + type LiteElement, 42 + type LiteNode, 43 + } from '@mathjax/src/js/adaptors/lite/Element.js' 76 44 import {liteAdaptor} from '@mathjax/src/js/adaptors/liteAdaptor.js' 45 + import {type AbstractMathDocument} from '@mathjax/src/js/core/MathDocument.js' 46 + import {type MathItem} from '@mathjax/src/js/core/MathItem.js' 77 47 import {RegisterHTMLHandler} from '@mathjax/src/js/handlers/html.js' 78 48 import {TeX} from '@mathjax/src/js/input/tex.js' 79 49 import {mathjax} from '@mathjax/src/js/mathjax.js' 80 50 import {SVG} from '@mathjax/src/js/output/svg.js' 51 + import {} from '@mathjax/src/mjs/util/entities/all.js' 81 52 import {decode} from 'html-entities' 82 53 83 54 import {cssStringToRNStyle} from './HTMLStyles' 84 55 85 56 const packageList = [ 86 - 'action', 57 + 'base', 87 58 'ams', 88 - 'amscd', 89 - 'autoload', 90 - 'bbm', 91 - 'bboldx', 92 - 'bbox', 93 - 'begingroup', 94 - 'boldsymbol', 95 - 'braket', 96 - 'bussproofs', 97 - 'cancel', 98 - 'cases', 99 - 'centernot', 100 - 'color', 101 - 'colortbl', 102 - 'colorv2', 103 - 'configmacros', 104 - 'dsfont', 105 - 'empheq', 106 - 'enclose', 107 - 'extpfeil', 108 - 'gensymb', 109 - 'html', 110 - 'mathtools', 111 - 'mhchem', 112 59 'newcommand', 113 60 'noerrors', 114 61 'noundefined', 115 - 'physics', 116 - 'require', 117 - 'setoptions', 118 - 'tagformat', 119 - 'texhtml', 120 - 'textcomp', 121 - 'textmacros', 122 - 'unicode', 123 - 'units', 124 - 'upgreek', 125 - 'verb', 126 62 ].sort() 127 63 128 - import { 129 - type LiteElement, 130 - type LiteNode, 131 - } from '@mathjax/src/js/adaptors/lite/Element.js' 132 - import {} from '@mathjax/src/mjs/util/entities/all.js' 133 - 134 64 const adaptor = liteAdaptor() 135 65 136 66 RegisterHTMLHandler(adaptor) 137 67 68 + function toPixels(value: string, exSize: number): number { 69 + const match = value.match(/^([\d.]+)(ex|px)$/) 70 + if (match) { 71 + const num = parseFloat(match[1]) 72 + const unit = match[2] 73 + if (unit === 'ex') { 74 + return num * exSize 75 + } else if (unit === 'px') { 76 + return num 77 + } 78 + } 79 + return 0 80 + } 81 + 82 + const RenderSvgElement = ({ 83 + element, 84 + fontSize, 85 + color, 86 + style, 87 + }: { 88 + element: LiteElement 89 + fontSize: number 90 + color: string 91 + style?: StyleProp<TextStyle> 92 + }) => { 93 + console.log(element.kind) 94 + const svgString = adaptor.outerHTML(element) 95 + // Extract the width/height in ex units and viewBox for aspect ratio 96 + const widthMatch = adaptor.getAttribute(element, 'width') 97 + const heightMatch = adaptor.getAttribute(element, 'height') 98 + 99 + if (!widthMatch || !heightMatch) return null 100 + 101 + // Convert ex to px (1ex ≈ fontSize * 0.5) 102 + const exSize = fontSize * 0.5 103 + const pxWidth = toPixels(widthMatch, exSize) 104 + const pxHeight = toPixels(heightMatch, exSize) 105 + 106 + return ( 107 + <SvgXml 108 + xml={svgString} 109 + color={color} 110 + width={pxWidth} 111 + height={pxHeight} 112 + viewBox={adaptor.getAttribute(element, 'viewBox')} 113 + style={style} 114 + /> 115 + ) 116 + } 117 + 138 118 const tagToStyle: Record<string, StyleProp<TextStyle>> = { 139 119 u: {textDecorationLine: 'underline'}, 140 120 ins: {textDecorationLine: 'underline'}, ··· 150 130 small: {fontSize: 8}, 151 131 } 152 132 153 - const getScale = (_svgString: string): [number, number] => { 154 - const svgString = _svgString.match(/<svg([^\>]+)>/gi)?.join('') 155 - 156 - let [width, height] = (svgString || '') 157 - .replace( 158 - /.* width=\"([\d\.]*)[ep]x\".*height=\"([\d\.]*)[ep]x\".*/gi, 159 - '$1,$2', 160 - ) 161 - .split(/\,/gi) 162 - 163 - let [width2, height2] = [parseFloat(width), parseFloat(height)] 164 - 165 - return [width2, height2] 166 - } 167 - 168 - const applyScale = (svgString: string, [width, height]: [number, number]) => { 169 - let retSvgString = svgString.replace( 170 - /(<svg[^\>]+height=\")([\d\.]+)([ep]x\"[^\>]+>)/gi, 171 - `$1${height}$3`, 172 - ) 173 - 174 - retSvgString = retSvgString.replace( 175 - /(<svg[^\>]+width=\")([\d\.]+)([ep]x\"[^\>]+>)/gi, 176 - `$1${width}$3`, 177 - ) 178 - 179 - retSvgString = retSvgString.replace( 180 - /(<svg[^\>]+width=\")([0]+[ep]?x?)(\"[^\>]+>)/gi, 181 - '$10$3', 182 - ) 183 - retSvgString = retSvgString.replace( 184 - /(<svg[^\>]+height=\")([0]+[ep]?x?)(\"[^\>]+>)/gi, 185 - '$10$3', 186 - ) 187 - 188 - return retSvgString 189 - } 190 - 191 - const applyColor = (svgString: string, fillColor?: string) => { 192 - return svgString.replace(/currentColor/gim, `${fillColor}`) 193 - } 194 - 195 - const GenerateSvgComponent = ({ 196 - item, 197 - fontSize, 198 - color, 199 - }: { 200 - item: LiteElement 201 - fontSize: number 202 - color?: string 203 - }) => { 204 - let svgText = adaptor.innerHTML(item) 205 - 206 - const [width, height] = getScale(svgText) 207 - 208 - svgText = svgText.replace(/font-family=\"([^\"]*)\"/gim, '') 209 - 210 - svgText = applyScale(svgText, [width * fontSize, height * fontSize]) 211 - 212 - svgText = applyColor(svgText, color) 213 - 214 - return ( 215 - <Text> 216 - <SvgFromXml xml={svgText} /> 217 - </Text> 218 - ) 219 - } 220 - 221 - const GenerateTextComponent = ({ 133 + const RenderLiteNode = ({ 222 134 fontSize, 223 135 color, 224 - index, 225 136 item, 226 137 parentStyle = null, 227 138 textStyle, 228 139 }: { 229 140 fontSize: number 230 - color?: string 231 - index: number 141 + color: string 232 142 item: LiteNode 233 143 parentStyle?: StyleProp<TextStyle> | null 234 144 textStyle?: StyleProp<TextStyle> ··· 236 146 let rnStyle: StyleProp<TextStyle> | null = null 237 147 let text = null 238 148 239 - if ( 240 - item?.kind !== '#text' && 241 - item?.kind !== 'mjx-container' && 242 - item?.kind !== '#comment' 243 - ) { 149 + if (item?.kind === '#text') { 150 + text = decode(adaptor.value(item) || '') 151 + rnStyle = parentStyle ? parentStyle : null 152 + } else if (item?.kind === 'br') { 153 + text = '\n\n' 154 + rnStyle = {width: '100%', overflow: 'hidden', height: 0} 155 + } else if (item?.kind !== '#comment') { 244 156 let htmlStyle = adaptor.allStyles(item as LiteElement) || null 245 157 246 158 if (htmlStyle) { ··· 250 162 rnStyle = [tagToStyle[item?.kind] || null, rnStyle] 251 163 } 252 164 253 - if (item?.kind === '#text') { 254 - text = decode(adaptor.value(item) || '') 255 - rnStyle = parentStyle ? parentStyle : null 256 - } else if (item?.kind === 'br') { 257 - text = '\n\n' 258 - rnStyle = {width: '100%', overflow: 'hidden', height: 0} 259 - } 260 - 261 165 return ( 262 - <Fragment> 166 + <> 263 167 {text ? ( 264 168 <Text 265 - key={`sub-${index}`} 266 169 style={[{fontSize: fontSize * 2, color}, rnStyle ?? {}, textStyle]}> 267 170 {text} 268 171 </Text> 269 - ) : item?.kind === 'mjx-container' ? ( 270 - <GenerateSvgComponent 271 - key={`sub-${index}`} 272 - item={item as LiteElement} 172 + ) : item?.kind === 'svg' ? ( 173 + <RenderSvgElement 174 + element={item as LiteElement} 273 175 fontSize={fontSize} 274 176 color={color} 177 + style={rnStyle} 275 178 /> 179 + ) : item?.kind === 'mjx-break' ? ( 180 + <Text>&#8203;</Text> 276 181 ) : (item as LiteElement).children?.length ? ( 277 182 (item as LiteElement).children.map((subItem, subIndex) => ( 278 - <GenerateTextComponent 279 - key={`sub-${index}-${subIndex}`} 183 + <RenderLiteNode 184 + key={`${subIndex}`} 280 185 color={color} 281 186 fontSize={fontSize} 282 187 item={subItem} 283 - index={subIndex} 284 188 parentStyle={rnStyle} 285 189 /> 286 190 )) 287 191 ) : null} 288 - </Fragment> 192 + </> 289 193 ) 290 194 } 291 195 292 - const ConvertToComponent = ({ 293 - texString = '', 294 - fontSize = 12, 295 - fontCache = false, 296 - color, 297 - textStyle, 196 + export const MathJaxSvgText = memo(function MathJaxSvg({ 197 + children: texText, 198 + fontSize = 28, 199 + color = 'black', 200 + style, 201 + fontCache, 298 202 }: { 299 - texString?: ReactNode 300 203 fontSize?: number 204 + color?: string 301 205 fontCache?: boolean 302 - color?: string 303 - textStyle?: TextStyle 304 - }) => { 305 - if (!texString) { 306 - return '' 307 - } 206 + style?: ViewStyle 207 + children?: ReactNode 208 + }) { 209 + if (!texText || typeof texText !== 'string') return null 308 210 309 211 const tex = new TeX({ 310 212 packages: packageList, ··· 325 227 merrorInheritFont: true, 326 228 }) 327 229 328 - const html = mathjax.document(texString, { 230 + const html = mathjax.document(texText, { 329 231 InputJax: tex, 330 232 OutputJax: svg, 331 233 renderActions: {assistiveMml: []}, 234 + compileError( 235 + doc: AbstractMathDocument<any, any, any>, 236 + math: MathItem<any, any, any>, 237 + err: Error, 238 + ) { 239 + console.error('MathJax compile error:', err) 240 + return doc.compileError(math, err) 241 + }, 332 242 }) 333 243 334 244 html.render() 335 245 336 - if (Array.from(html.math).length === 0) { 337 - adaptor.remove(html.outputJax.styleSheet(html.document)) 338 - const cache = adaptor.elementById( 339 - adaptor.body(html.document), 340 - 'MJX-SVG-global-cache', 341 - ) 342 - if (cache) adaptor.remove(cache) 343 - } 246 + console.log(adaptor.outerHTML(adaptor.body(html.document))) 247 + 248 + // // Get the full rendered output as an HTML string 249 + // const body = adaptor.body(html.document) 250 + 251 + // // Extract just the <svg>...</svg> from the output 252 + // const svgMatch = bodyHtml.match(/<svg[\s\S]*<\/svg>/) 253 + // if (!svgMatch) return null 254 + 255 + // let svgString = svgMatch[0] 256 + 257 + // // Replace currentColor with the specified color 258 + // svgString = svgString.replace(/currentColor/g, color) 259 + 260 + // // Extract the width/height in ex units and viewBox for aspect ratio 261 + // const widthMatch = svgString.match(/width="([\d.]+)ex"/) 262 + // const heightMatch = svgString.match(/height="([\d.]+)ex"/) 344 263 345 - const nodes = adaptor.childNodes(adaptor.body(html.document)) 264 + // if (!widthMatch || !heightMatch) return null 346 265 347 - return ( 348 - <> 349 - {nodes?.map((item, index) => ( 350 - <GenerateTextComponent 351 - key={index} 352 - textStyle={textStyle} 353 - item={item} 354 - index={index} 355 - fontSize={fontSize} 356 - color={color} 357 - /> 358 - ))} 359 - </> 360 - ) 361 - } 266 + // const exWidth = parseFloat(widthMatch[1]) 267 + // const exHeight = parseFloat(heightMatch[1]) 362 268 363 - export const MathJaxSvg = memo(function MathJaxSvg(props: { 364 - fontSize?: number 365 - color?: string 366 - fontCache?: boolean 367 - style?: ViewStyle 368 - textStyle?: TextStyle 369 - children?: ReactNode 370 - }) { 371 - const textext = props.children || '' 372 - const fontSize = props.fontSize ? props.fontSize / 2 : 14 373 - const color = props.color ? props.color : 'black' 374 - const fontCache = props.fontCache 375 - const style = props.style ? props.style : null 269 + // // Convert ex to px (1ex ≈ fontSize * 0.5) 270 + // const exSize = fontSize * 0.5 271 + // const pxWidth = exWidth * exSize 272 + // const pxHeight = exHeight * exSize 376 273 377 274 return ( 378 - <View 379 - style={{ 380 - flexDirection: 'row', 381 - flexWrap: 'wrap', 382 - flexShrink: 1, 383 - alignItems: 'center', 384 - ...style, 385 - }}> 386 - {textext ? ( 387 - <ConvertToComponent 388 - textStyle={props.textStyle} 389 - fontSize={fontSize} 390 - color={color} 391 - texString={textext} 392 - fontCache={fontCache} 393 - /> 394 - ) : null} 275 + <View style={[{flexDirection: 'row', alignItems: 'center'}, style]}> 276 + <RenderLiteNode 277 + item={adaptor.body(html.document)} 278 + fontSize={fontSize} 279 + color={color} 280 + /> 395 281 </View> 396 282 ) 397 283 })
+10
yarn.lock
··· 3943 3943 "@types/yargs" "^17.0.8" 3944 3944 chalk "^4.0.0" 3945 3945 3946 + "@journeyapps/domparser@^0.4.1": 3947 + version "0.4.1" 3948 + resolved "https://registry.yarnpkg.com/@journeyapps/domparser/-/domparser-0.4.1.tgz#c327da9dcf5678ce9ad2c46f2cb7530a1036e973" 3949 + integrity sha512-rZ2gxAVVwE6tklS2lm04nkI52BCUjECVO8R9+bG9Uz8L9/ZBqxhuXu23jKX66G0Dc3evx5HWmuEuunOW7/Mahw== 3950 + 3946 3951 "@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": 3947 3952 version "0.3.3" 3948 3953 resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" ··· 17502 17507 version "2.2.0" 17503 17508 resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" 17504 17509 integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== 17510 + 17511 + xmldom@^0.6.0: 17512 + version "0.6.0" 17513 + resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.6.0.tgz#43a96ecb8beece991cef382c08397d82d4d0c46f" 17514 + integrity sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg== 17505 17515 17506 17516 y18n@^4.0.0: 17507 17517 version "4.0.3"