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