A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo, useRef } from "react"; 2import { useDidResolution } from "../hooks/useDidResolution"; 3import { useBlob } from "../hooks/useBlob"; 4import { 5 parseAtUri, 6 formatDidForLabel, 7 toBlueskyPostUrl, 8 leafletRkeyUrl, 9 normalizeLeafletBasePath, 10} from "../utils/at-uri"; 11import { BlueskyPost } from "../components/BlueskyPost"; 12import type { 13 LeafletDocumentRecord, 14 LeafletLinearDocumentPage, 15 LeafletLinearDocumentBlock, 16 LeafletBlock, 17 LeafletTextBlock, 18 LeafletHeaderBlock, 19 LeafletBlockquoteBlock, 20 LeafletImageBlock, 21 LeafletUnorderedListBlock, 22 LeafletListItem, 23 LeafletWebsiteBlock, 24 LeafletIFrameBlock, 25 LeafletMathBlock, 26 LeafletCodeBlock, 27 LeafletBskyPostBlock, 28 LeafletAlignmentValue, 29 LeafletRichTextFacet, 30 LeafletRichTextFeature, 31 LeafletPublicationRecord, 32} from "../types/leaflet"; 33 34export interface LeafletDocumentRendererProps { 35 record: LeafletDocumentRecord; 36 loading: boolean; 37 error?: Error; 38 did: string; 39 rkey: string; 40 canonicalUrl?: string; 41 publicationBaseUrl?: string; 42 publicationRecord?: LeafletPublicationRecord; 43} 44 45export const LeafletDocumentRenderer: React.FC< 46 LeafletDocumentRendererProps 47> = ({ 48 record, 49 loading, 50 error, 51 did, 52 rkey, 53 canonicalUrl, 54 publicationBaseUrl, 55 publicationRecord, 56}) => { 57 const authorDid = record.author?.startsWith("did:") 58 ? record.author 59 : undefined; 60 const publicationUri = useMemo( 61 () => parseAtUri(record.publication), 62 [record.publication], 63 ); 64 const postUrl = useMemo(() => { 65 const postRefUri = record.postRef?.uri; 66 if (!postRefUri) return undefined; 67 const parsed = parseAtUri(postRefUri); 68 return parsed ? toBlueskyPostUrl(parsed) : undefined; 69 }, [record.postRef?.uri]); 70 const { handle: publicationHandle } = useDidResolution(publicationUri?.did); 71 const fallbackAuthorLabel = useAuthorLabel(record.author, authorDid); 72 const resolvedPublicationLabel = 73 publicationRecord?.name?.trim() ?? 74 (publicationHandle 75 ? `@${publicationHandle}` 76 : publicationUri 77 ? formatDidForLabel(publicationUri.did) 78 : undefined); 79 const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel; 80 const authorHref = publicationUri 81 ? `https://bsky.app/profile/${publicationUri.did}` 82 : undefined; 83 84 if (error) 85 return ( 86 <div style={{ padding: 12, color: "crimson" }}> 87 Failed to load leaflet. 88 </div> 89 ); 90 if (loading && !record) 91 return <div style={{ padding: 12 }}>Loading leaflet</div>; 92 if (!record) 93 return ( 94 <div style={{ padding: 12, color: "crimson" }}> 95 Leaflet record missing. 96 </div> 97 ); 98 99 const publishedAt = record.publishedAt 100 ? new Date(record.publishedAt) 101 : undefined; 102 const publishedLabel = publishedAt 103 ? publishedAt.toLocaleString(undefined, { 104 dateStyle: "long", 105 timeStyle: "short", 106 }) 107 : undefined; 108 const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`; 109 const publicationRoot = 110 publicationBaseUrl ?? publicationRecord?.base_path ?? undefined; 111 const resolvedPublicationRoot = publicationRoot 112 ? normalizeLeafletBasePath(publicationRoot) 113 : undefined; 114 const publicationLeafletUrl = leafletRkeyUrl(publicationRoot, rkey); 115 const viewUrl = 116 canonicalUrl ?? 117 publicationLeafletUrl ?? 118 postUrl ?? 119 (publicationUri 120 ? `https://bsky.app/profile/${publicationUri.did}` 121 : undefined) ?? 122 fallbackLeafletUrl; 123 124 const metaItems: React.ReactNode[] = []; 125 if (authorLabel) { 126 const authorNode = authorHref ? ( 127 <a 128 href={authorHref} 129 target="_blank" 130 rel="noopener noreferrer" 131 style={{ color: `var(--atproto-color-link)`, textDecoration: "none" }} 132 > 133 {authorLabel} 134 </a> 135 ) : ( 136 authorLabel 137 ); 138 metaItems.push(<span>By {authorNode}</span>); 139 } 140 if (publishedLabel) 141 metaItems.push( 142 <time dateTime={record.publishedAt}>{publishedLabel}</time>, 143 ); 144 if (resolvedPublicationRoot) { 145 metaItems.push( 146 <a 147 href={resolvedPublicationRoot} 148 target="_blank" 149 rel="noopener noreferrer" 150 style={{ color: `var(--atproto-color-link)`, textDecoration: "none" }} 151 > 152 {resolvedPublicationRoot.replace(/^https?:\/\//, "")} 153 </a>, 154 ); 155 } 156 if (viewUrl) { 157 metaItems.push( 158 <a 159 href={viewUrl} 160 target="_blank" 161 rel="noopener noreferrer" 162 style={{ color: `var(--atproto-color-link)`, textDecoration: "none" }} 163 > 164 View source 165 </a>, 166 ); 167 } 168 169 return ( 170 <article style={{ ...base.container, background: `var(--atproto-color-bg)`, borderWidth: "1px", borderStyle: "solid", borderColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}> 171 <header style={{ ...base.header }}> 172 <div style={base.headerContent}> 173 <h1 style={{ ...base.title, color: `var(--atproto-color-text)` }}> 174 {record.title} 175 </h1> 176 {record.description && ( 177 <p style={{ ...base.subtitle, color: `var(--atproto-color-text-secondary)` }}> 178 {record.description} 179 </p> 180 )} 181 </div> 182 <div style={{ ...base.meta, color: `var(--atproto-color-text-secondary)` }}> 183 {metaItems.map((item, idx) => ( 184 <React.Fragment key={`meta-${idx}`}> 185 {idx > 0 && ( 186 <span style={{ margin: "0 4px" }}></span> 187 )} 188 {item} 189 </React.Fragment> 190 ))} 191 </div> 192 </header> 193 <div style={base.body}> 194 {record.pages?.map((page, pageIndex) => ( 195 <LeafletPageRenderer 196 key={`page-${pageIndex}`} 197 page={page} 198 documentDid={did} 199 /> 200 ))} 201 </div> 202 </article> 203 ); 204}; 205 206const LeafletPageRenderer: React.FC<{ 207 page: LeafletLinearDocumentPage; 208 documentDid: string; 209}> = ({ page, documentDid }) => { 210 if (!page.blocks?.length) return null; 211 return ( 212 <div style={base.page}> 213 {page.blocks.map((blockWrapper, idx) => ( 214 <LeafletBlockRenderer 215 key={`block-${idx}`} 216 wrapper={blockWrapper} 217 documentDid={documentDid} 218 isFirst={idx === 0} 219 /> 220 ))} 221 </div> 222 ); 223}; 224 225interface LeafletBlockRendererProps { 226 wrapper: LeafletLinearDocumentBlock; 227 documentDid: string; 228 isFirst?: boolean; 229} 230 231const LeafletBlockRenderer: React.FC<LeafletBlockRendererProps> = ({ 232 wrapper, 233 documentDid, 234 isFirst, 235}) => { 236 const block = wrapper.block; 237 if (!block || !("$type" in block) || !block.$type) { 238 return null; 239 } 240 const alignment = alignmentValue(wrapper.alignment); 241 242 switch (block.$type) { 243 case "pub.leaflet.blocks.header": 244 return ( 245 <LeafletHeaderBlockView 246 block={block} 247 alignment={alignment} 248 isFirst={isFirst} 249 /> 250 ); 251 case "pub.leaflet.blocks.blockquote": 252 return ( 253 <LeafletBlockquoteBlockView 254 block={block} 255 alignment={alignment} 256 isFirst={isFirst} 257 /> 258 ); 259 case "pub.leaflet.blocks.image": 260 return ( 261 <LeafletImageBlockView 262 block={block} 263 alignment={alignment} 264 documentDid={documentDid} 265 /> 266 ); 267 case "pub.leaflet.blocks.unorderedList": 268 return ( 269 <LeafletListBlockView 270 block={block} 271 alignment={alignment} 272 documentDid={documentDid} 273 /> 274 ); 275 case "pub.leaflet.blocks.website": 276 return ( 277 <LeafletWebsiteBlockView 278 block={block} 279 alignment={alignment} 280 documentDid={documentDid} 281 /> 282 ); 283 case "pub.leaflet.blocks.iframe": 284 return ( 285 <LeafletIframeBlockView block={block} alignment={alignment} /> 286 ); 287 case "pub.leaflet.blocks.math": 288 return ( 289 <LeafletMathBlockView 290 block={block} 291 alignment={alignment} 292 /> 293 ); 294 case "pub.leaflet.blocks.code": 295 return ( 296 <LeafletCodeBlockView 297 block={block} 298 alignment={alignment} 299 /> 300 ); 301 case "pub.leaflet.blocks.horizontalRule": 302 return ( 303 <LeafletHorizontalRuleBlockView 304 alignment={alignment} 305 /> 306 ); 307 case "pub.leaflet.blocks.bskyPost": 308 return ( 309 <LeafletBskyPostBlockView 310 block={block} 311 /> 312 ); 313 case "pub.leaflet.blocks.text": 314 default: 315 return ( 316 <LeafletTextBlockView 317 block={block as LeafletTextBlock} 318 alignment={alignment} 319 isFirst={isFirst} 320 /> 321 ); 322 } 323}; 324 325const LeafletTextBlockView: React.FC<{ 326 block: LeafletTextBlock; 327 alignment?: React.CSSProperties["textAlign"]; 328 isFirst?: boolean; 329}> = ({ block, alignment, isFirst }) => { 330 const segments = useMemo( 331 () => createFacetedSegments(block.plaintext, block.facets), 332 [block.plaintext, block.facets], 333 ); 334 const textContent = block.plaintext ?? ""; 335 if (!textContent.trim() && segments.length === 0) { 336 return null; 337 } 338 const style: React.CSSProperties = { 339 ...base.paragraph, 340 color: `var(--atproto-color-text)`, 341 ...(alignment ? { textAlign: alignment } : undefined), 342 ...(isFirst ? { marginTop: 0 } : undefined), 343 }; 344 return ( 345 <p style={style}> 346 {segments.map((segment, idx) => ( 347 <React.Fragment key={`text-${idx}`}> 348 {renderSegment(segment)} 349 </React.Fragment> 350 ))} 351 </p> 352 ); 353}; 354 355const LeafletHeaderBlockView: React.FC<{ 356 block: LeafletHeaderBlock; 357 alignment?: React.CSSProperties["textAlign"]; 358 isFirst?: boolean; 359}> = ({ block, alignment, isFirst }) => { 360 const level = 361 block.level && block.level >= 1 && block.level <= 6 ? block.level : 2; 362 const segments = useMemo( 363 () => createFacetedSegments(block.plaintext, block.facets), 364 [block.plaintext, block.facets], 365 ); 366 const normalizedLevel = Math.min(Math.max(level, 1), 6) as 367 | 1 368 | 2 369 | 3 370 | 4 371 | 5 372 | 6; 373 const headingTag = (["h1", "h2", "h3", "h4", "h5", "h6"] as const)[ 374 normalizedLevel - 1 375 ]; 376 const style: React.CSSProperties = { 377 ...base.heading, 378 color: `var(--atproto-color-text)`, 379 fontSize: normalizedLevel === 1 ? 30 : normalizedLevel === 2 ? 28 : normalizedLevel === 3 ? 24 : normalizedLevel === 4 ? 20 : normalizedLevel === 5 ? 18 : 16, 380 ...(alignment ? { textAlign: alignment } : undefined), 381 ...(isFirst ? { marginTop: 0 } : undefined), 382 }; 383 384 return React.createElement( 385 headingTag, 386 { style }, 387 segments.map((segment, idx) => ( 388 <React.Fragment key={`header-${idx}`}> 389 {renderSegment(segment)} 390 </React.Fragment> 391 )), 392 ); 393}; 394 395const LeafletBlockquoteBlockView: React.FC<{ 396 block: LeafletBlockquoteBlock; 397 alignment?: React.CSSProperties["textAlign"]; 398 isFirst?: boolean; 399}> = ({ block, alignment, isFirst }) => { 400 const segments = useMemo( 401 () => createFacetedSegments(block.plaintext, block.facets), 402 [block.plaintext, block.facets], 403 ); 404 const textContent = block.plaintext ?? ""; 405 if (!textContent.trim() && segments.length === 0) { 406 return null; 407 } 408 return ( 409 <blockquote 410 style={{ 411 ...base.blockquote, 412 background: `var(--atproto-color-bg-elevated)`, 413 borderLeftWidth: "4px", 414 borderLeftStyle: "solid", 415 borderColor: `var(--atproto-color-border)`, 416 color: `var(--atproto-color-text)`, 417 ...(alignment ? { textAlign: alignment } : undefined), 418 ...(isFirst ? { marginTop: 0 } : undefined), 419 }} 420 > 421 {segments.map((segment, idx) => ( 422 <React.Fragment key={`quote-${idx}`}> 423 {renderSegment(segment)} 424 </React.Fragment> 425 ))} 426 </blockquote> 427 ); 428}; 429 430const LeafletImageBlockView: React.FC<{ 431 block: LeafletImageBlock; 432 alignment?: React.CSSProperties["textAlign"]; 433 documentDid: string; 434}> = ({ block, alignment, documentDid }) => { 435 const cid = block.image?.ref?.$link ?? block.image?.cid; 436 const { url, loading, error } = useBlob(documentDid, cid); 437 const aspectRatio = 438 block.aspectRatio?.height && block.aspectRatio?.width 439 ? `${block.aspectRatio.width} / ${block.aspectRatio.height}` 440 : undefined; 441 442 return ( 443 <figure 444 style={{ 445 ...base.figure, 446 ...(alignment ? { textAlign: alignment } : undefined), 447 }} 448 > 449 <div 450 style={{ 451 ...base.imageWrapper, 452 background: `var(--atproto-color-bg-elevated)`, 453 ...(aspectRatio ? { aspectRatio } : {}), 454 }} 455 > 456 {url && !error ? ( 457 <img 458 src={url} 459 alt={block.alt ?? ""} 460 style={{ ...base.image }} 461 /> 462 ) : ( 463 <div 464 style={{ 465 ...base.imagePlaceholder, 466 color: `var(--atproto-color-text-secondary)`, 467 }} 468 > 469 {loading 470 ? "Loading image…" 471 : error 472 ? "Image unavailable" 473 : "No image"} 474 </div> 475 )} 476 </div> 477 {block.alt && block.alt.trim().length > 0 && ( 478 <figcaption style={{ ...base.caption, color: `var(--atproto-color-text-secondary)` }}> 479 {block.alt} 480 </figcaption> 481 )} 482 </figure> 483 ); 484}; 485 486const LeafletListBlockView: React.FC<{ 487 block: LeafletUnorderedListBlock; 488 alignment?: React.CSSProperties["textAlign"]; 489 documentDid: string; 490}> = ({ block, alignment, documentDid }) => { 491 return ( 492 <ul 493 style={{ 494 ...base.list, 495 color: `var(--atproto-color-text)`, 496 ...(alignment ? { textAlign: alignment } : undefined), 497 }} 498 > 499 {block.children?.map((child, idx) => ( 500 <LeafletListItemRenderer 501 key={`list-item-${idx}`} 502 item={child} 503 documentDid={documentDid} 504 alignment={alignment} 505 /> 506 ))} 507 </ul> 508 ); 509}; 510 511const LeafletListItemRenderer: React.FC<{ 512 item: LeafletListItem; 513 documentDid: string; 514 alignment?: React.CSSProperties["textAlign"]; 515}> = ({ item, documentDid, alignment }) => { 516 return ( 517 <li 518 style={{ 519 ...base.listItem, 520 ...(alignment ? { textAlign: alignment } : undefined), 521 }} 522 > 523 <div> 524 <LeafletInlineBlock 525 block={item.content} 526 documentDid={documentDid} 527 alignment={alignment} 528 /> 529 </div> 530 {item.children && item.children.length > 0 && ( 531 <ul 532 style={{ 533 ...base.nestedList, 534 ...(alignment ? { textAlign: alignment } : undefined), 535 }} 536 > 537 {item.children.map((child, idx) => ( 538 <LeafletListItemRenderer 539 key={`nested-${idx}`} 540 item={child} 541 documentDid={documentDid} 542 alignment={alignment} 543 /> 544 ))} 545 </ul> 546 )} 547 </li> 548 ); 549}; 550 551const LeafletInlineBlock: React.FC<{ 552 block: LeafletBlock; 553 documentDid: string; 554 alignment?: React.CSSProperties["textAlign"]; 555}> = ({ block, documentDid, alignment }) => { 556 switch (block.$type) { 557 case "pub.leaflet.blocks.header": 558 return ( 559 <LeafletHeaderBlockView 560 block={block as LeafletHeaderBlock} 561 alignment={alignment} 562 /> 563 ); 564 case "pub.leaflet.blocks.blockquote": 565 return ( 566 <LeafletBlockquoteBlockView 567 block={block as LeafletBlockquoteBlock} 568 alignment={alignment} 569 /> 570 ); 571 case "pub.leaflet.blocks.image": 572 return ( 573 <LeafletImageBlockView 574 block={block as LeafletImageBlock} 575 documentDid={documentDid} 576 alignment={alignment} 577 /> 578 ); 579 default: 580 return ( 581 <LeafletTextBlockView 582 block={block as LeafletTextBlock} 583 alignment={alignment} 584 /> 585 ); 586 } 587}; 588 589const LeafletWebsiteBlockView: React.FC<{ 590 block: LeafletWebsiteBlock; 591 alignment?: React.CSSProperties["textAlign"]; 592 documentDid: string; 593}> = ({ block, alignment, documentDid }) => { 594 const previewCid = 595 block.previewImage?.ref?.$link ?? block.previewImage?.cid; 596 const { url, loading, error } = useBlob(documentDid, previewCid); 597 598 return ( 599 <a 600 href={block.src} 601 target="_blank" 602 rel="noopener noreferrer" 603 style={{ 604 ...base.linkCard, 605 borderWidth: "1px", 606 borderStyle: "solid", 607 borderColor: `var(--atproto-color-border)`, 608 background: `var(--atproto-color-bg-elevated)`, 609 color: `var(--atproto-color-text)`, 610 ...(alignment ? { textAlign: alignment } : undefined), 611 }} 612 > 613 {url && !error ? ( 614 <img 615 src={url} 616 alt={block.title ?? "Website preview"} 617 style={{ ...base.linkPreview }} 618 /> 619 ) : ( 620 <div 621 style={{ 622 ...base.linkPreviewPlaceholder, 623 background: `var(--atproto-color-bg-elevated)`, 624 color: `var(--atproto-color-text-secondary)`, 625 }} 626 > 627 {loading ? "Loading preview…" : "Open link"} 628 </div> 629 )} 630 <div style={base.linkContent}> 631 {block.title && ( 632 <strong style={{ fontSize: 16, color: `var(--atproto-color-text)` }}>{block.title}</strong> 633 )} 634 {block.description && ( 635 <p style={{ margin: 0, fontSize: 14, color: `var(--atproto-color-text-secondary)`, lineHeight: 1.5 }}>{block.description}</p> 636 )} 637 <span style={{ fontSize: 13, color: `var(--atproto-color-link)`, wordBreak: "break-all" }}>{block.src}</span> 638 </div> 639 </a> 640 ); 641}; 642 643const LeafletIframeBlockView: React.FC<{ 644 block: LeafletIFrameBlock; 645 alignment?: React.CSSProperties["textAlign"]; 646}> = ({ block, alignment }) => { 647 return ( 648 <div style={{ ...(alignment ? { textAlign: alignment } : undefined) }}> 649 <iframe 650 src={block.url} 651 title={block.url} 652 style={{ 653 ...base.iframe, 654 ...(block.height 655 ? { height: Math.min(Math.max(block.height, 120), 800) } 656 : {}), 657 }} 658 loading="lazy" 659 allowFullScreen 660 /> 661 </div> 662 ); 663}; 664 665const LeafletMathBlockView: React.FC<{ 666 block: LeafletMathBlock; 667 alignment?: React.CSSProperties["textAlign"]; 668}> = ({ block, alignment }) => { 669 return ( 670 <pre 671 style={{ 672 ...base.math, 673 background: `var(--atproto-color-bg-elevated)`, 674 color: `var(--atproto-color-text)`, 675 border: `1px solid var(--atproto-color-border)`, 676 ...(alignment ? { textAlign: alignment } : undefined), 677 }} 678 > 679 {block.tex} 680 </pre> 681 ); 682}; 683 684const LeafletCodeBlockView: React.FC<{ 685 block: LeafletCodeBlock; 686 alignment?: React.CSSProperties["textAlign"]; 687}> = ({ block, alignment }) => { 688 const codeRef = useRef<HTMLElement | null>(null); 689 const langClass = block.language 690 ? `language-${block.language.toLowerCase()}` 691 : undefined; 692 return ( 693 <pre 694 style={{ 695 ...base.code, 696 background: `var(--atproto-color-bg)`, 697 color: `var(--atproto-color-text)`, 698 ...(alignment ? { textAlign: alignment } : undefined), 699 }} 700 > 701 <code ref={codeRef} className={langClass}> 702 {block.plaintext} 703 </code> 704 </pre> 705 ); 706}; 707 708const LeafletHorizontalRuleBlockView: React.FC<{ 709 alignment?: React.CSSProperties["textAlign"]; 710}> = ({ alignment }) => { 711 return ( 712 <hr 713 style={{ 714 ...base.hr, 715 borderTopWidth: "1px", 716 borderTopStyle: "solid", 717 borderColor: `var(--atproto-color-border)`, 718 marginLeft: alignment ? "auto" : undefined, 719 marginRight: alignment ? "auto" : undefined, 720 }} 721 /> 722 ); 723}; 724 725const LeafletBskyPostBlockView: React.FC<{ 726 block: LeafletBskyPostBlock; 727}> = ({ block }) => { 728 const parsed = parseAtUri(block.postRef?.uri); 729 if (!parsed) { 730 return ( 731 <div style={base.embedFallback}>Referenced post unavailable.</div> 732 ); 733 } 734 return ( 735 <BlueskyPost 736 did={parsed.did} 737 rkey={parsed.rkey} 738 iconPlacement="linkInline" 739 /> 740 ); 741}; 742 743function alignmentValue( 744 value?: LeafletAlignmentValue, 745): React.CSSProperties["textAlign"] | undefined { 746 if (!value) return undefined; 747 let normalized = value.startsWith("#") ? value.slice(1) : value; 748 if (normalized.includes("#")) { 749 normalized = normalized.split("#").pop() ?? normalized; 750 } 751 if (normalized.startsWith("lex:")) { 752 normalized = normalized.split(":").pop() ?? normalized; 753 } 754 switch (normalized) { 755 case "textAlignLeft": 756 return "left"; 757 case "textAlignCenter": 758 return "center"; 759 case "textAlignRight": 760 return "right"; 761 case "textAlignJustify": 762 return "justify"; 763 default: 764 return undefined; 765 } 766} 767 768function useAuthorLabel( 769 author: string | undefined, 770 authorDid: string | undefined, 771): string | undefined { 772 const { handle } = useDidResolution(authorDid); 773 if (!author) return undefined; 774 if (handle) return `@${handle}`; 775 if (authorDid) return formatDidForLabel(authorDid); 776 return author; 777} 778 779interface Segment { 780 text: string; 781 features: LeafletRichTextFeature[]; 782} 783 784function createFacetedSegments( 785 plaintext: string, 786 facets?: LeafletRichTextFacet[], 787): Segment[] { 788 if (!facets?.length) { 789 return [{ text: plaintext, features: [] }]; 790 } 791 const prefix = buildBytePrefix(plaintext); 792 const startEvents = new Map<number, LeafletRichTextFeature[]>(); 793 const endEvents = new Map<number, LeafletRichTextFeature[]>(); 794 const boundaries = new Set<number>([0, prefix.length - 1]); 795 for (const facet of facets) { 796 const { byteStart, byteEnd } = facet.index ?? {}; 797 if ( 798 typeof byteStart !== "number" || 799 typeof byteEnd !== "number" || 800 byteStart >= byteEnd 801 ) 802 continue; 803 const start = byteOffsetToCharIndex(prefix, byteStart); 804 const end = byteOffsetToCharIndex(prefix, byteEnd); 805 if (start >= end) continue; 806 boundaries.add(start); 807 boundaries.add(end); 808 if (facet.features?.length) { 809 startEvents.set(start, [ 810 ...(startEvents.get(start) ?? []), 811 ...facet.features, 812 ]); 813 endEvents.set(end, [ 814 ...(endEvents.get(end) ?? []), 815 ...facet.features, 816 ]); 817 } 818 } 819 const sortedBounds = Array.from(boundaries).sort((a, b) => a - b); 820 const segments: Segment[] = []; 821 let active: LeafletRichTextFeature[] = []; 822 for (let i = 0; i < sortedBounds.length - 1; i++) { 823 const boundary = sortedBounds[i]; 824 const next = sortedBounds[i + 1]; 825 const endFeatures = endEvents.get(boundary); 826 if (endFeatures?.length) { 827 active = active.filter((feature) => !endFeatures.includes(feature)); 828 } 829 const startFeatures = startEvents.get(boundary); 830 if (startFeatures?.length) { 831 active = [...active, ...startFeatures]; 832 } 833 if (boundary === next) continue; 834 const text = sliceByCharRange(plaintext, boundary, next); 835 segments.push({ text, features: active.slice() }); 836 } 837 return segments; 838} 839 840function buildBytePrefix(text: string): number[] { 841 const encoder = new TextEncoder(); 842 const prefix: number[] = [0]; 843 let byteCount = 0; 844 for (let i = 0; i < text.length; ) { 845 const codePoint = text.codePointAt(i)!; 846 const char = String.fromCodePoint(codePoint); 847 const encoded = encoder.encode(char); 848 byteCount += encoded.length; 849 prefix.push(byteCount); 850 i += codePoint > 0xffff ? 2 : 1; 851 } 852 return prefix; 853} 854 855function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number { 856 for (let i = 0; i < prefix.length; i++) { 857 if (prefix[i] === byteOffset) return i; 858 if (prefix[i] > byteOffset) return Math.max(0, i - 1); 859 } 860 return prefix.length - 1; 861} 862 863function sliceByCharRange(text: string, start: number, end: number): string { 864 if (start <= 0 && end >= text.length) return text; 865 let result = ""; 866 let charIndex = 0; 867 for (let i = 0; i < text.length && charIndex < end; ) { 868 const codePoint = text.codePointAt(i)!; 869 const char = String.fromCodePoint(codePoint); 870 if (charIndex >= start && charIndex < end) result += char; 871 i += codePoint > 0xffff ? 2 : 1; 872 charIndex++; 873 } 874 return result; 875} 876 877function renderSegment( 878 segment: Segment, 879): React.ReactNode { 880 const parts = segment.text.split("\n"); 881 return parts.flatMap((part, idx) => { 882 const key = `${segment.text}-${idx}-${part.length}`; 883 const wrapped = applyFeatures( 884 part.length ? part : "\u00a0", 885 segment.features, 886 key, 887 ); 888 if (idx === parts.length - 1) return wrapped; 889 return [wrapped, <br key={`${key}-br`} />]; 890 }); 891} 892 893function applyFeatures( 894 content: React.ReactNode, 895 features: LeafletRichTextFeature[], 896 key: string, 897): React.ReactNode { 898 if (!features?.length) 899 return <React.Fragment key={key}>{content}</React.Fragment>; 900 return ( 901 <React.Fragment key={key}> 902 {features.reduce<React.ReactNode>( 903 (child, feature, idx) => 904 wrapFeature( 905 child, 906 feature, 907 `${key}-feature-${idx}`, 908 ), 909 content, 910 )} 911 </React.Fragment> 912 ); 913} 914 915function wrapFeature( 916 child: React.ReactNode, 917 feature: LeafletRichTextFeature, 918 key: string, 919): React.ReactNode { 920 switch (feature.$type) { 921 case "pub.leaflet.richtext.facet#link": 922 return ( 923 <a 924 key={key} 925 href={feature.uri} 926 target="_blank" 927 rel="noopener noreferrer" 928 style={{ color: `var(--atproto-color-link)`, textDecoration: "underline" }} 929 > 930 {child} 931 </a> 932 ); 933 case "pub.leaflet.richtext.facet#code": 934 return ( 935 <code key={key} style={{ 936 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace', 937 background: `var(--atproto-color-bg-elevated)`, 938 padding: "0 4px", 939 borderRadius: 4, 940 }}> 941 {child} 942 </code> 943 ); 944 case "pub.leaflet.richtext.facet#highlight": 945 return ( 946 <mark key={key} style={{ background: `var(--atproto-color-highlight)` }}> 947 {child} 948 </mark> 949 ); 950 case "pub.leaflet.richtext.facet#underline": 951 return ( 952 <span key={key} style={{ textDecoration: "underline" }}> 953 {child} 954 </span> 955 ); 956 case "pub.leaflet.richtext.facet#strikethrough": 957 return ( 958 <span key={key} style={{ textDecoration: "line-through" }}> 959 {child} 960 </span> 961 ); 962 case "pub.leaflet.richtext.facet#bold": 963 return <strong key={key}>{child}</strong>; 964 case "pub.leaflet.richtext.facet#italic": 965 return <em key={key}>{child}</em>; 966 case "pub.leaflet.richtext.facet#id": 967 return ( 968 <span key={key} id={feature.id}> 969 {child} 970 </span> 971 ); 972 default: 973 return <span key={key}>{child}</span>; 974 } 975} 976 977const base: Record<string, React.CSSProperties> = { 978 container: { 979 display: "flex", 980 flexDirection: "column", 981 gap: 24, 982 padding: "24px 28px", 983 borderRadius: 20, 984 borderWidth: "1px", 985 borderStyle: "solid", 986 borderColor: "transparent", 987 maxWidth: 720, 988 width: "100%", 989 fontFamily: 990 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', 991 }, 992 header: { 993 display: "flex", 994 flexDirection: "column", 995 gap: 16, 996 }, 997 headerContent: { 998 display: "flex", 999 flexDirection: "column", 1000 gap: 8, 1001 }, 1002 title: { 1003 fontSize: 32, 1004 margin: 0, 1005 lineHeight: 1.15, 1006 }, 1007 subtitle: { 1008 margin: 0, 1009 fontSize: 16, 1010 lineHeight: 1.5, 1011 }, 1012 meta: { 1013 display: "flex", 1014 flexWrap: "wrap", 1015 gap: 8, 1016 alignItems: "center", 1017 fontSize: 14, 1018 }, 1019 body: { 1020 display: "flex", 1021 flexDirection: "column", 1022 gap: 18, 1023 }, 1024 page: { 1025 display: "flex", 1026 flexDirection: "column", 1027 gap: 18, 1028 }, 1029 paragraph: { 1030 margin: "1em 0 0", 1031 lineHeight: 1.65, 1032 fontSize: 16, 1033 }, 1034 heading: { 1035 margin: "0.5em 0 0", 1036 fontWeight: 700, 1037 }, 1038 blockquote: { 1039 margin: "1em 0 0", 1040 padding: "0.6em 1em", 1041 borderLeftWidth: "4px", 1042 borderLeftStyle: "solid", 1043 }, 1044 figure: { 1045 margin: "1.2em 0 0", 1046 display: "flex", 1047 flexDirection: "column", 1048 gap: 12, 1049 }, 1050 imageWrapper: { 1051 borderRadius: 16, 1052 overflow: "hidden", 1053 width: "100%", 1054 position: "relative", 1055 background: "#e2e8f0", 1056 }, 1057 image: { 1058 width: "100%", 1059 height: "100%", 1060 objectFit: "cover", 1061 display: "block", 1062 }, 1063 imagePlaceholder: { 1064 width: "100%", 1065 padding: "24px 16px", 1066 textAlign: "center", 1067 }, 1068 caption: { 1069 fontSize: 13, 1070 lineHeight: 1.4, 1071 }, 1072 list: { 1073 paddingLeft: 28, 1074 margin: "1em 0 0", 1075 listStyleType: "disc", 1076 listStylePosition: "outside", 1077 }, 1078 nestedList: { 1079 paddingLeft: 20, 1080 marginTop: 8, 1081 listStyleType: "circle", 1082 listStylePosition: "outside", 1083 }, 1084 listItem: { 1085 marginTop: 8, 1086 display: "list-item", 1087 }, 1088 linkCard: { 1089 borderRadius: 16, 1090 borderWidth: "1px", 1091 borderStyle: "solid", 1092 display: "flex", 1093 flexDirection: "column", 1094 overflow: "hidden", 1095 textDecoration: "none", 1096 }, 1097 linkPreview: { 1098 width: "100%", 1099 height: 180, 1100 objectFit: "cover", 1101 }, 1102 linkPreviewPlaceholder: { 1103 width: "100%", 1104 height: 180, 1105 display: "flex", 1106 alignItems: "center", 1107 justifyContent: "center", 1108 fontSize: 14, 1109 }, 1110 linkContent: { 1111 display: "flex", 1112 flexDirection: "column", 1113 gap: 6, 1114 padding: "16px 18px", 1115 }, 1116 iframe: { 1117 width: "100%", 1118 height: 360, 1119 border: "1px solid #cbd5f5", 1120 borderRadius: 16, 1121 }, 1122 math: { 1123 margin: "1em 0 0", 1124 padding: "14px 16px", 1125 borderRadius: 12, 1126 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace', 1127 overflowX: "auto", 1128 }, 1129 code: { 1130 margin: "1em 0 0", 1131 padding: "14px 16px", 1132 borderRadius: 12, 1133 overflowX: "auto", 1134 fontSize: 14, 1135 }, 1136 hr: { 1137 border: 0, 1138 borderTopWidth: "1px", 1139 borderTopStyle: "solid", 1140 margin: "24px 0 0", 1141 }, 1142 embedFallback: { 1143 padding: "12px 16px", 1144 borderRadius: 12, 1145 border: "1px solid #e2e8f0", 1146 fontSize: 14, 1147 }, 1148}; 1149 1150export default LeafletDocumentRenderer;