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