a tool for shared writing and social publishing

make quotes have a page component

+96 -28
+37 -6
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 50 50 }) { 51 51 return ( 52 52 <div 53 - id="post-content" 53 + //The postContent class is important for QuoteHandler 54 54 className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-2 ${className}`} 55 55 > 56 56 {blocks.map((b, index) => { ··· 103 103 scrollMarginBottom: "4rem", 104 104 wordBreak: "break-word" as React.CSSProperties["wordBreak"], 105 105 }, 106 - id: preview ? undefined : index.join("."), 106 + id: preview 107 + ? undefined 108 + : pageId 109 + ? `${pageId}~${index.join(".")}` 110 + : index.join("."), 107 111 "data-index": index.join("."), 112 + "data-page-id": pageId, 108 113 }; 109 114 let alignment = 110 115 b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" ··· 175 180 did={did} 176 181 key={i} 177 182 className={className} 183 + pageId={pageId} 178 184 /> 179 185 ))} 180 186 </ul> ··· 277 283 plaintext={b.block.plaintext} 278 284 index={index} 279 285 preview={preview} 286 + pageId={pageId} 280 287 /> 281 288 </blockquote> 282 289 ); ··· 289 296 plaintext={b.block.plaintext} 290 297 index={index} 291 298 preview={preview} 299 + pageId={pageId} 292 300 /> 293 301 </p> 294 302 ); ··· 296 304 if (b.block.level === 1) 297 305 return ( 298 306 <h2 className={`${className}`} {...blockProps}> 299 - <TextBlock {...b.block} index={index} preview={preview} /> 307 + <TextBlock 308 + {...b.block} 309 + index={index} 310 + preview={preview} 311 + pageId={pageId} 312 + /> 300 313 </h2> 301 314 ); 302 315 if (b.block.level === 2) 303 316 return ( 304 317 <h3 className={`${className}`} {...blockProps}> 305 - <TextBlock {...b.block} index={index} preview={preview} /> 318 + <TextBlock 319 + {...b.block} 320 + index={index} 321 + preview={preview} 322 + pageId={pageId} 323 + /> 306 324 </h3> 307 325 ); 308 326 if (b.block.level === 3) 309 327 return ( 310 328 <h4 className={`${className}`} {...blockProps}> 311 - <TextBlock {...b.block} index={index} preview={preview} /> 329 + <TextBlock 330 + {...b.block} 331 + index={index} 332 + preview={preview} 333 + pageId={pageId} 334 + /> 312 335 </h4> 313 336 ); 314 337 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 315 338 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 316 339 return ( 317 340 <h6 className={`${className}`} {...blockProps}> 318 - <TextBlock {...b.block} index={index} preview={preview} /> 341 + <TextBlock 342 + {...b.block} 343 + index={index} 344 + preview={preview} 345 + pageId={pageId} 346 + /> 319 347 </h6> 320 348 ); 321 349 } ··· 331 359 did: string; 332 360 className?: string; 333 361 bskyPostData: AppBskyFeedDefs.PostView[]; 362 + pageId?: string; 334 363 }) { 335 364 let children = props.item.children?.length ? ( 336 365 <ul className="-ml-[7px] sm:ml-[7px]"> ··· 343 372 did={props.did} 344 373 key={index} 345 374 className={props.className} 375 + pageId={props.pageId} 346 376 /> 347 377 ))} 348 378 </ul> ··· 361 391 did={props.did} 362 392 isList 363 393 index={props.index} 394 + pageId={props.pageId} 364 395 /> 365 396 {children}{" "} 366 397 </div>
+1 -4
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 58 58 export function PostPages({ 59 59 document, 60 60 blocks, 61 - name, 62 61 did, 63 62 profile, 64 63 preferences, ··· 70 69 document_uri: string; 71 70 document: PostPageData; 72 71 blocks: PubLeafletPagesLinearDocument.Block[]; 73 - name: string; 74 72 profile: ProfileViewDetailed; 75 73 pubRecord: PubLeafletPublication.Record; 76 74 did: string; ··· 99 97 <PostHeader 100 98 data={document} 101 99 profile={profile} 102 - name={name} 103 100 preferences={preferences} 104 101 /> 105 102 <PostContent ··· 137 134 document.documents_in_publications[0].publications 138 135 .publication_subscriptions 139 136 } 140 - pubName={name} 137 + pubName={document.documents_in_publications[0].publications.name} 141 138 /> 142 139 )} 143 140 </div>
+19 -8
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 24 24 useEffect(() => { 25 25 const handleSelectionChange = (e: Event) => { 26 26 const selection = document.getSelection(); 27 - const postContent = document.getElementById("post-content"); 27 + 28 + // Check if selection is within any element with postContent class 28 29 const isWithinPostContent = 29 - postContent && selection?.rangeCount && selection.rangeCount > 0 30 - ? postContent.contains( 31 - selection.getRangeAt(0).commonAncestorContainer, 32 - ) 30 + selection?.rangeCount && selection.rangeCount > 0 31 + ? (() => { 32 + const range = selection.getRangeAt(0); 33 + const ancestor = range.commonAncestorContainer; 34 + const element = ancestor.nodeType === Node.ELEMENT_NODE 35 + ? ancestor as Element 36 + : ancestor.parentElement; 37 + return element?.closest('.postContent') !== null; 38 + })() 33 39 : false; 34 40 35 41 if (!selection || !isWithinPostContent || !selection?.toString()) ··· 88 94 endIndex?.element, 89 95 ); 90 96 let position: QuotePosition = { 97 + ...(startIndex.pageId && { pageId: startIndex.pageId }), 91 98 start: { 92 99 block: startIndex?.index.split(".").map((i) => parseInt(i)), 93 100 offset: startOffset, ··· 145 152 // Clear existing query parameters 146 153 currentUrl.search = ""; 147 154 148 - currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`; 155 + const fragmentId = pos?.pageId 156 + ? `${pos.pageId}~${pos.start.block.join(".")}_${pos.start.offset}` 157 + : `${pos?.start.block.join(".")}_${pos?.start.offset}`; 158 + currentUrl.hash = `#${fragmentId}`; 149 159 return [currentUrl.toString(), pos]; 150 160 }, [props.position]); 151 161 let pubRecord = data.documents_in_publications[0]?.publications?.record as ··· 210 220 ); 211 221 }; 212 222 213 - function findDataIndex(node: Node): { index: string; element: Element } | null { 223 + function findDataIndex(node: Node): { index: string; element: Element; pageId?: string } | null { 214 224 if (node.nodeType === Node.ELEMENT_NODE) { 215 225 const element = node as Element; 216 226 if (element.hasAttribute("data-index")) { 217 227 const index = element.getAttribute("data-index"); 218 228 if (index) { 219 - return { index, element }; 229 + const pageId = element.getAttribute("data-page-id") || undefined; 230 + return { index, element, pageId }; 220 231 } 221 232 } 222 233 }
+7 -3
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
··· 11 11 facets?: Facet[]; 12 12 index: number[]; 13 13 preview?: boolean; 14 + pageId?: string; 14 15 }) { 15 16 let children = []; 16 - let highlights = useHighlight(props.index); 17 + let highlights = useHighlight(props.index, props.pageId); 17 18 let facets = useMemo(() => { 18 19 if (props.preview) return props.facets; 19 20 let facets = [...(props.facets || [])]; 20 21 for (let highlight of highlights) { 22 + const fragmentId = props.pageId 23 + ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` 24 + : `${props.index.join(".")}_${highlight.startOffset || 0}`; 21 25 facets = addFacet( 22 26 facets, 23 27 { ··· 35 39 { $type: "pub.leaflet.richtext.facet#highlight" }, 36 40 { 37 41 $type: "pub.leaflet.richtext.facet#id", 38 - id: `${props.index.join(".")}_${highlight.startOffset || 0}`, 42 + id: fragmentId, 39 43 }, 40 44 ], 41 45 }, ··· 43 47 ); 44 48 } 45 49 return facets; 46 - }, [props.plaintext, props.facets, highlights, props.preview]); 50 + }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); 47 51 return <BaseTextBlock {...props} facets={facets} />; 48 52 } 49 53
+20 -5
app/lish/[did]/[publication]/[rkey]/quotePosition.ts
··· 1 1 export interface QuotePosition { 2 + pageId?: string; 2 3 start: { 3 4 block: number[]; 4 5 offset: number; ··· 14 15 /** 15 16 * Encodes quote position into a URL-friendly string 16 17 * Format: startBlock_startOffset-endBlock_endOffset 18 + * Format with page: pageId~startBlock_startOffset-endBlock_endOffset 17 19 * Block paths are joined with dots: 1.2.0_45-1.2.3_67 18 - * Simple blocks: 0:12-2:45 20 + * Simple blocks: 0_12-2_45 21 + * With page: page1~0_12-2_45 19 22 */ 20 23 export function encodeQuotePosition(position: QuotePosition): string { 21 - const { start, end } = position; 22 - return `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; 24 + const { pageId, start, end } = position; 25 + const positionStr = `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; 26 + return pageId ? `${pageId}~${positionStr}` : positionStr; 23 27 } 24 28 25 29 /** ··· 28 32 */ 29 33 export function decodeQuotePosition(encoded: string): QuotePosition | null { 30 34 try { 31 - // Match format: blockPath:number-blockPath:number 35 + // Check for pageId prefix (format: pageId~blockPath_number-blockPath_number) 36 + let pageId: string | undefined; 37 + let positionStr = encoded; 38 + 39 + const tildeIndex = encoded.indexOf("~"); 40 + if (tildeIndex !== -1) { 41 + pageId = encoded.substring(0, tildeIndex); 42 + positionStr = encoded.substring(tildeIndex + 1); 43 + } 44 + 45 + // Match format: blockPath_number-blockPath_number 32 46 // Block paths can be: 5, 1.2, 0.1.3, etc. 33 - const match = encoded.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); 47 + const match = positionStr.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); 34 48 35 49 if (!match) { 36 50 return null; ··· 39 53 const [, startBlockPath, startOffset, endBlockPath, endOffset] = match; 40 54 41 55 const position: QuotePosition = { 56 + ...(pageId && { pageId }), 42 57 start: { 43 58 block: startBlockPath.split(".").map((i) => parseInt(i)), 44 59 offset: parseInt(startOffset, 10),
+9 -1
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 11 11 activeHighlight: null as null | QuotePosition, 12 12 })); 13 13 14 - export const useHighlight = (pos: number[]) => { 14 + export const useHighlight = (pos: number[], pageId?: string) => { 15 15 let doc = useContext(PostPageContext); 16 16 let { quote } = useParams(); 17 17 let activeHighlight = useActiveHighlightState( ··· 23 23 return highlights 24 24 .map((quotePosition) => { 25 25 if (!quotePosition) return null; 26 + // Filter by pageId if provided 27 + if (pageId && quotePosition.pageId !== pageId) { 28 + return null; 29 + } 30 + // If highlight has pageId but block doesn't, skip 31 + if (quotePosition.pageId && !pageId) { 32 + return null; 33 + } 26 34 let maxLength = Math.max( 27 35 quotePosition.start.block.length, 28 36 quotePosition.end.block.length,
+3 -1
components/Blocks/ImageBlock.tsx
··· 140 140 ) : ( 141 141 <Image 142 142 alt={altText || ""} 143 - src={new URL(image.data.src).pathname.split("/").slice(5).join("/")} 143 + src={ 144 + "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") 145 + } 144 146 height={image?.data.height} 145 147 width={image?.data.width} 146 148 className={className}