a tool for shared writing and social publishing

add canvas pages and previews

+419 -38
+84 -12
actions/publishToPublication.ts
··· 12 12 PubLeafletBlocksUnorderedList, 13 13 PubLeafletDocument, 14 14 PubLeafletPagesLinearDocument, 15 + PubLeafletPagesCanvas, 15 16 PubLeafletRichtextFacet, 16 17 PubLeafletBlocksWebsite, 17 18 PubLeafletBlocksCode, ··· 95 96 $type: "pub.leaflet.pages.linearDocument", 96 97 blocks: firstPageBlocks, 97 98 }, 98 - ...pages.map((p) => ({ 99 - $type: "pub.leaflet.pages.linearDocument", 100 - id: p.id, 101 - blocks: p.blocks, 102 - })), 99 + ...pages.map((p) => { 100 + if (p.type === "canvas") { 101 + return { 102 + $type: "pub.leaflet.pages.canvas" as const, 103 + id: p.id, 104 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 105 + }; 106 + } else { 107 + return { 108 + $type: "pub.leaflet.pages.linearDocument" as const, 109 + id: p.id, 110 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 111 + }; 112 + } 113 + }), 103 114 ], 104 115 }; 105 116 let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); ··· 139 150 root_entity: string, 140 151 ) { 141 152 let scan = scanIndexLocal(facts); 142 - let pages: { id: string; blocks: PubLeafletPagesLinearDocument.Block[] }[] = 143 - []; 153 + let pages: { 154 + id: string; 155 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 156 + type: "doc" | "canvas"; 157 + }[] = []; 144 158 145 159 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 146 160 if (!firstEntity) throw new Error("No root page"); ··· 229 243 let [page] = scan.eav(b.value, "block/card"); 230 244 if (!page) return; 231 245 let [pageType] = scan.eav(page.data.value, "page/type"); 232 - let blocks = getBlocksWithTypeLocal(facts, page.data.value); 233 - pages.push({ 234 - id: page.data.value, 235 - blocks: await blocksToRecord(blocks), 236 - }); 246 + 247 + if (pageType?.data.value === "canvas") { 248 + let canvasBlocks = await canvasBlocksToRecord(page.data.value); 249 + pages.push({ 250 + id: page.data.value, 251 + blocks: canvasBlocks, 252 + type: "canvas", 253 + }); 254 + } else { 255 + let blocks = getBlocksWithTypeLocal(facts, page.data.value); 256 + pages.push({ 257 + id: page.data.value, 258 + blocks: await blocksToRecord(blocks), 259 + type: "doc", 260 + }); 261 + } 262 + 237 263 let block: $Typed<PubLeafletBlocksPage.Main> = { 238 264 $type: "pub.leaflet.blocks.page", 239 265 id: page.data.value, ··· 359 385 return block; 360 386 } 361 387 return; 388 + } 389 + 390 + async function canvasBlocksToRecord( 391 + pageID: string, 392 + ): Promise<PubLeafletPagesCanvas.Block[]> { 393 + let canvasBlocks = scan.eav(pageID, "canvas/block"); 394 + return ( 395 + await Promise.all( 396 + canvasBlocks.map(async (canvasBlock) => { 397 + let blockEntity = canvasBlock.data.value; 398 + let position = canvasBlock.data.position; 399 + 400 + // Get the block content 401 + let blockType = scan.eav(blockEntity, "block/type")?.[0]; 402 + if (!blockType) return null; 403 + 404 + let block: Block = { 405 + type: blockType.data.value, 406 + value: blockEntity, 407 + parent: pageID, 408 + position: "", 409 + factID: canvasBlock.id, 410 + }; 411 + 412 + let content = await blockToRecord(block); 413 + if (!content) return null; 414 + 415 + // Get canvas-specific properties 416 + let width = 417 + scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 418 + let rotation = 419 + scan.eav(blockEntity, "canvas/block/rotation")?.[0]?.data.value; 420 + 421 + let canvasBlockRecord: PubLeafletPagesCanvas.Block = { 422 + $type: "pub.leaflet.pages.canvas#block", 423 + block: content, 424 + x: position.x, 425 + y: position.y, 426 + width, 427 + ...(rotation !== undefined && { rotation }), 428 + }; 429 + 430 + return canvasBlockRecord; 431 + }), 432 + ) 433 + ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 362 434 } 363 435 } 364 436
+177
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 1 + "use client"; 2 + import { 3 + PubLeafletPagesCanvas, 4 + PubLeafletPagesLinearDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 + import { PostPageData } from "./getPostPageData"; 8 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 + import { AppBskyFeedDefs } from "@atproto/api"; 10 + import { PageWrapper } from "components/Pages/Page"; 11 + import { Block } from "./PostContent"; 12 + import { CanvasBackgroundPattern } from "components/Canvas"; 13 + 14 + export function CanvasPage({ 15 + document, 16 + blocks, 17 + did, 18 + profile, 19 + preferences, 20 + pubRecord, 21 + prerenderedCodeBlocks, 22 + bskyPostData, 23 + document_uri, 24 + pageId, 25 + pageOptions, 26 + fullPageScroll, 27 + pages, 28 + }: { 29 + document_uri: string; 30 + document: PostPageData; 31 + blocks: PubLeafletPagesCanvas.Block[]; 32 + profile?: ProfileViewDetailed; 33 + pubRecord: PubLeafletPublication.Record; 34 + did: string; 35 + prerenderedCodeBlocks?: Map<string, string>; 36 + bskyPostData: AppBskyFeedDefs.PostView[]; 37 + preferences: { showComments?: boolean }; 38 + pageId?: string; 39 + pageOptions?: React.ReactNode; 40 + fullPageScroll: boolean; 41 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 42 + }) { 43 + let hasPageBackground = !!pubRecord.theme?.showPageBackground; 44 + 45 + return ( 46 + <PageWrapper 47 + pageType="canvas" 48 + fullPageScroll={fullPageScroll} 49 + cardBorderHidden={!hasPageBackground} 50 + id={pageId ? `post-page-${pageId}` : "post-page"} 51 + drawerOpen={false} 52 + pageOptions={pageOptions} 53 + > 54 + <CanvasContent 55 + blocks={blocks} 56 + did={did} 57 + prerenderedCodeBlocks={prerenderedCodeBlocks} 58 + bskyPostData={bskyPostData} 59 + pageId={pageId} 60 + pages={pages} 61 + /> 62 + </PageWrapper> 63 + ); 64 + } 65 + 66 + function CanvasContent({ 67 + blocks, 68 + did, 69 + prerenderedCodeBlocks, 70 + bskyPostData, 71 + pageId, 72 + pages, 73 + }: { 74 + blocks: PubLeafletPagesCanvas.Block[]; 75 + did: string; 76 + prerenderedCodeBlocks?: Map<string, string>; 77 + bskyPostData: AppBskyFeedDefs.PostView[]; 78 + pageId?: string; 79 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 80 + }) { 81 + let height = blocks.length > 0 ? Math.max(...blocks.map((b) => b.y), 0) : 0; 82 + 83 + return ( 84 + <div className="canvasWrapper h-full w-fit overflow-y-scroll"> 85 + <div 86 + style={{ 87 + minHeight: height + 512, 88 + contain: "size layout paint", 89 + }} 90 + className="relative h-full w-[1272px]" 91 + > 92 + <CanvasBackground /> 93 + {blocks 94 + .sort((a, b) => { 95 + if (a.y === b.y) { 96 + return a.x - b.x; 97 + } 98 + return a.y - b.y; 99 + }) 100 + .map((canvasBlock, index) => { 101 + return ( 102 + <CanvasBlock 103 + key={index} 104 + canvasBlock={canvasBlock} 105 + did={did} 106 + prerenderedCodeBlocks={prerenderedCodeBlocks} 107 + bskyPostData={bskyPostData} 108 + pageId={pageId} 109 + pages={pages} 110 + index={index} 111 + /> 112 + ); 113 + })} 114 + </div> 115 + </div> 116 + ); 117 + } 118 + 119 + function CanvasBlock({ 120 + canvasBlock, 121 + did, 122 + prerenderedCodeBlocks, 123 + bskyPostData, 124 + pageId, 125 + pages, 126 + index, 127 + }: { 128 + canvasBlock: PubLeafletPagesCanvas.Block; 129 + did: string; 130 + prerenderedCodeBlocks?: Map<string, string>; 131 + bskyPostData: AppBskyFeedDefs.PostView[]; 132 + pageId?: string; 133 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 134 + index: number; 135 + }) { 136 + let { x, y, width, rotation } = canvasBlock; 137 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 138 + 139 + // Wrap the block in a LinearDocument.Block structure for compatibility 140 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 141 + $type: "pub.leaflet.pages.linearDocument#block", 142 + block: canvasBlock.block, 143 + }; 144 + 145 + return ( 146 + <div 147 + className="absolute rounded-lg flex items-stretch origin-center p-3" 148 + style={{ 149 + top: 0, 150 + left: 0, 151 + width, 152 + transform, 153 + }} 154 + > 155 + <div className="contents"> 156 + <Block 157 + pageId={pageId} 158 + pages={pages} 159 + bskyPostData={bskyPostData} 160 + block={linearBlock} 161 + did={did} 162 + index={[index]} 163 + preview={false} 164 + prerenderedCodeBlocks={prerenderedCodeBlocks} 165 + /> 166 + </div> 167 + </div> 168 + ); 169 + } 170 + 171 + const CanvasBackground = () => { 172 + return ( 173 + <div className="w-full h-full pointer-events-none"> 174 + <CanvasBackgroundPattern pattern="grid" /> 175 + </div> 176 + ); 177 + };
+9 -3
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 9 9 PubLeafletBlocksWebsite, 10 10 PubLeafletDocument, 11 11 PubLeafletPagesLinearDocument, 12 + PubLeafletPagesCanvas, 12 13 PubLeafletBlocksHorizontalRule, 13 14 PubLeafletBlocksBlockquote, 14 15 PubLeafletBlocksBskyPost, ··· 46 47 className?: string; 47 48 prerenderedCodeBlocks?: Map<string, string>; 48 49 bskyPostData: AppBskyFeedDefs.PostView[]; 49 - pages: PubLeafletPagesLinearDocument.Main[]; 50 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 50 51 }) { 51 52 return ( 52 53 <div ··· 91 92 block: PubLeafletPagesLinearDocument.Block; 92 93 did: string; 93 94 isList?: boolean; 94 - pages: PubLeafletPagesLinearDocument.Main[]; 95 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 95 96 previousBlock?: PubLeafletPagesLinearDocument.Block; 96 97 prerenderedCodeBlocks?: Map<string, string>; 97 98 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 137 138 let id = b.block.id; 138 139 let page = pages.find((p) => p.id === id); 139 140 if (!page) return; 141 + 142 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 143 + 140 144 return ( 141 145 <PublishedPageLinkBlock 142 146 blocks={page.blocks} ··· 144 148 parentPageId={pageId} 145 149 did={did} 146 150 bskyPostData={bskyPostData} 151 + isCanvas={isCanvas} 152 + pages={pages} 147 153 /> 148 154 ); 149 155 } ··· 354 360 355 361 function ListItem(props: { 356 362 index: number[]; 357 - pages: PubLeafletPagesLinearDocument.Main[]; 363 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 358 364 item: PubLeafletBlocksUnorderedList.ListItem; 359 365 did: string; 360 366 className?: string;
+48 -20
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 2 2 import { 3 3 PubLeafletDocument, 4 4 PubLeafletPagesLinearDocument, 5 + PubLeafletPagesCanvas, 5 6 PubLeafletPublication, 6 7 } from "lexicons/api"; 7 8 import { PostPageData } from "./getPostPageData"; ··· 21 22 import { useParams } from "next/navigation"; 22 23 import { decodeQuotePosition } from "./quotePosition"; 23 24 import { LinearDocumentPage } from "./LinearDocumentPage"; 25 + import { CanvasPage } from "./CanvasPage"; 24 26 25 27 const usePostPageUIState = create(() => ({ 26 28 pages: [] as string[], ··· 157 159 158 160 {pages.map((p) => { 159 161 let page = record.pages.find( 160 - (page) => (page as PubLeafletPagesLinearDocument.Main).id === p, 161 - ) as PubLeafletPagesLinearDocument.Main | undefined; 162 + (page) => 163 + (page as PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main).id === p, 164 + ) as PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main | undefined; 162 165 if (!page) return null; 166 + 167 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 168 + 163 169 return ( 164 170 <Fragment key={p}> 165 171 <SandwichSpacer /> 166 - <LinearDocumentPage 167 - fullPageScroll={false} 168 - document={document} 169 - blocks={page.blocks} 170 - did={did} 171 - preferences={preferences} 172 - pubRecord={pubRecord} 173 - prerenderedCodeBlocks={prerenderedCodeBlocks} 174 - bskyPostData={bskyPostData} 175 - document_uri={document_uri} 176 - pageId={page.id} 177 - pageOptions={ 178 - <PageOptions 179 - onClick={() => closePage(page?.id!)} 180 - hasPageBackground={hasPageBackground} 181 - /> 182 - } 183 - /> 172 + {isCanvas ? ( 173 + <CanvasPage 174 + fullPageScroll={false} 175 + document={document} 176 + blocks={(page as PubLeafletPagesCanvas.Main).blocks} 177 + did={did} 178 + preferences={preferences} 179 + pubRecord={pubRecord} 180 + prerenderedCodeBlocks={prerenderedCodeBlocks} 181 + bskyPostData={bskyPostData} 182 + document_uri={document_uri} 183 + pageId={page.id} 184 + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 185 + pageOptions={ 186 + <PageOptions 187 + onClick={() => closePage(page?.id!)} 188 + hasPageBackground={hasPageBackground} 189 + /> 190 + } 191 + /> 192 + ) : ( 193 + <LinearDocumentPage 194 + fullPageScroll={false} 195 + document={document} 196 + blocks={(page as PubLeafletPagesLinearDocument.Main).blocks} 197 + did={did} 198 + preferences={preferences} 199 + pubRecord={pubRecord} 200 + prerenderedCodeBlocks={prerenderedCodeBlocks} 201 + bskyPostData={bskyPostData} 202 + document_uri={document_uri} 203 + pageId={page.id} 204 + pageOptions={ 205 + <PageOptions 206 + onClick={() => closePage(page?.id!)} 207 + hasPageBackground={hasPageBackground} 208 + /> 209 + } 210 + /> 211 + )} 184 212 {drawer && drawer.pageId === page.id && ( 185 213 <InteractionDrawer 186 214 pageId={page.id}
+101 -3
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { CSSProperties, useContext, useRef } from "react"; 6 6 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 - import { PostContent } from "./PostContent"; 7 + import { PostContent, Block } from "./PostContent"; 8 8 import { 9 9 PubLeafletBlocksHeader, 10 10 PubLeafletBlocksText, 11 11 PubLeafletComment, 12 12 PubLeafletPagesLinearDocument, 13 + PubLeafletPagesCanvas, 13 14 PubLeafletPublication, 14 15 } from "lexicons/api"; 15 16 import { AppBskyFeedDefs } from "@atproto/api"; ··· 23 24 } from "./Interactions/Interactions"; 24 25 import { CommentTiny } from "components/Icons/CommentTiny"; 25 26 import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { CanvasBackgroundPattern } from "components/Canvas"; 26 28 27 29 export function PublishedPageLinkBlock(props: { 28 - blocks: PubLeafletPagesLinearDocument.Block[]; 30 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 29 31 parentPageId: string | undefined; 30 32 pageId: string; 31 33 did: string; ··· 33 35 className?: string; 34 36 prerenderedCodeBlocks?: Map<string, string>; 35 37 bskyPostData: AppBskyFeedDefs.PostView[]; 38 + isCanvas?: boolean; 39 + pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 36 40 }) { 37 41 //switch to use actually state 38 42 let openPages = useOpenPages(); ··· 55 59 openPage(props.parentPageId, props.pageId); 56 60 }} 57 61 > 58 - <DocLinkBlock {...props} /> 62 + {props.isCanvas ? ( 63 + <CanvasLinkBlock 64 + blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 65 + did={props.did} 66 + pageId={props.pageId} 67 + bskyPostData={props.bskyPostData} 68 + pages={props.pages || []} 69 + /> 70 + ) : ( 71 + <DocLinkBlock 72 + {...props} 73 + blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 74 + /> 75 + )} 59 76 </div> 60 77 ); 61 78 } ··· 228 245 </div> 229 246 ); 230 247 }; 248 + 249 + const CanvasLinkBlock = (props: { 250 + blocks: PubLeafletPagesCanvas.Block[]; 251 + did: string; 252 + pageId: string; 253 + bskyPostData: AppBskyFeedDefs.PostView[]; 254 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 255 + }) => { 256 + let pageWidth = `var(--page-width-unitless)`; 257 + let height = props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 258 + 259 + return ( 260 + <div 261 + style={{ contain: "size layout paint" }} 262 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 263 + > 264 + <div 265 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 266 + style={{ 267 + width: `calc(1px * ${pageWidth})`, 268 + height: "calc(1150px * 2)", 269 + transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 270 + }} 271 + > 272 + <div 273 + style={{ 274 + minHeight: height + 512, 275 + contain: "size layout paint", 276 + }} 277 + className="relative h-full w-[1272px]" 278 + > 279 + <div className="w-full h-full pointer-events-none"> 280 + <CanvasBackgroundPattern pattern="grid" /> 281 + </div> 282 + {props.blocks 283 + .sort((a, b) => { 284 + if (a.y === b.y) { 285 + return a.x - b.x; 286 + } 287 + return a.y - b.y; 288 + }) 289 + .map((canvasBlock, index) => { 290 + let { x, y, width, rotation } = canvasBlock; 291 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 292 + 293 + // Wrap the block in a LinearDocument.Block structure for compatibility 294 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 295 + $type: "pub.leaflet.pages.linearDocument#block", 296 + block: canvasBlock.block, 297 + }; 298 + 299 + return ( 300 + <div 301 + key={index} 302 + className="absolute rounded-lg flex items-stretch origin-center p-3" 303 + style={{ 304 + top: 0, 305 + left: 0, 306 + width, 307 + transform, 308 + }} 309 + > 310 + <div className="contents"> 311 + <Block 312 + pageId={props.pageId} 313 + pages={props.pages} 314 + bskyPostData={props.bskyPostData} 315 + block={linearBlock} 316 + did={props.did} 317 + index={[index]} 318 + preview={true} 319 + /> 320 + </div> 321 + </div> 322 + ); 323 + })} 324 + </div> 325 + </div> 326 + </div> 327 + ); 328 + };