a tool for shared writing and social publishing
at feature/reader 508 lines 16 kB view raw
1"use client"; 2 3import React, { JSX, useState } from "react"; 4import { useUIState } from "src/useUIState"; 5import { useEntitySetContext } from "../EntitySetProvider"; 6import { useSearchParams } from "next/navigation"; 7 8import { focusBlock } from "src/utils/focusBlock"; 9import { elementId } from "src/utils/elementId"; 10 11import { Replicache } from "replicache"; 12import { 13 Fact, 14 ReplicacheMutators, 15 useEntity, 16 useReferenceToEntity, 17 useReplicache, 18} from "src/replicache"; 19 20import { Media } from "../Media"; 21import { DesktopPageFooter } from "../DesktopFooter"; 22import { ThemePopover } from "../ThemeManager/ThemeSetter"; 23import { Canvas } from "../Canvas"; 24import { DraftPostOptions } from "../Blocks/MailboxBlock"; 25import { Blocks } from "components/Blocks"; 26import { MenuItem, Menu } from "../Layout"; 27import { scanIndex } from "src/replicache/utils"; 28import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 29import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 30import { PageShareMenu } from "./PageShareMenu"; 31import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 32import { useUndoState } from "src/undoManager"; 33import { CloseTiny } from "components/Icons/CloseTiny"; 34import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 35import { PaintSmall } from "components/Icons/PaintSmall"; 36import { ShareSmall } from "components/Icons/ShareSmall"; 37import { PublicationMetadata } from "./PublicationMetadata"; 38import { useCardBorderHidden } from "./useCardBorderHidden"; 39import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 40 41export function Pages(props: { rootPage: string }) { 42 let rootPage = useEntity(props.rootPage, "root/page")[0]; 43 let pages = useUIState((s) => s.openPages); 44 let params = useSearchParams(); 45 let queryRoot = params.get("page"); 46 let firstPage = queryRoot || rootPage?.data.value || props.rootPage; 47 48 return ( 49 <> 50 <div className="flex items-stretch"> 51 <CardThemeProvider entityID={firstPage}> 52 <Page entityID={firstPage} first /> 53 </CardThemeProvider> 54 </div> 55 {pages.map((page) => ( 56 <div className="flex items-stretch" key={page}> 57 <CardThemeProvider entityID={page}> 58 <Page entityID={page} /> 59 </CardThemeProvider> 60 </div> 61 ))} 62 <div 63 className="spacer" 64 style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 65 onClick={(e) => { 66 e.currentTarget === e.target && blurPage(); 67 }} 68 /> 69 </> 70 ); 71} 72 73export const LeafletOptions = (props: { entityID: string }) => { 74 return ( 75 <> 76 <ThemePopover entityID={props.entityID} /> 77 </> 78 ); 79}; 80 81function Page(props: { entityID: string; first?: boolean }) { 82 let { rep, rootEntity } = useReplicache(); 83 let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); 84 85 let isFocused = useUIState((s) => { 86 let focusedElement = s.focusedEntity; 87 let focusedPageID = 88 focusedElement?.entityType === "page" 89 ? focusedElement.entityID 90 : focusedElement?.parent; 91 return focusedPageID === props.entityID; 92 }); 93 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 94 let cardBorderHidden = useCardBorderHidden(props.entityID); 95 return ( 96 <> 97 {!props.first && ( 98 <div 99 className="w-6 lg:snap-center" 100 onClick={(e) => { 101 e.currentTarget === e.target && blurPage(); 102 }} 103 /> 104 )} 105 <div className="pageWrapper w-fit flex relative snap-center"> 106 <div 107 onClick={(e) => { 108 if (e.defaultPrevented) return; 109 if (rep) { 110 if (isFocused) return; 111 focusPage(props.entityID, rep); 112 } 113 }} 114 id={elementId.page(props.entityID).container} 115 style={{ 116 width: pageType === "doc" ? "var(--page-width-units)" : undefined, 117 backgroundColor: cardBorderHidden 118 ? "" 119 : "rgba(var(--bg-page), var(--bg-page-alpha))", 120 }} 121 className={` 122 ${pageType === "canvas" ? "!lg:max-w-[1152px]" : "max-w-(--page-width-units)"} 123 page 124 grow flex flex-col 125 overscroll-y-none 126 overflow-y-auto 127 ${cardBorderHidden ? "border-0 shadow-none! sm:-mt-6 sm:-mb-12 -mt-2 -mb-1 pt-3 " : "border rounded-lg"} 128 ${isFocused ? "shadow-md border-border" : "border-border-light"} 129 `} 130 > 131 <Media mobile={true}> 132 <PageOptions entityID={props.entityID} first={props.first} /> 133 </Media> 134 <DesktopPageFooter pageID={props.entityID} /> 135 {isDraft.length > 0 && ( 136 <div 137 className={`pageStatus pt-[6px] pb-1 ${!props.first ? "pr-10 pl-3 sm:px-4" : "px-3 sm:px-4"} border-b border-border text-tertiary`} 138 style={{ 139 backgroundColor: 140 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 141 }} 142 > 143 <DraftPostOptions mailboxEntity={isDraft[0].entity} /> 144 </div> 145 )} 146 147 <PageContent entityID={props.entityID} /> 148 </div> 149 <Media mobile={false}> 150 {isFocused && ( 151 <PageOptions entityID={props.entityID} first={props.first} /> 152 )} 153 </Media> 154 </div> 155 </> 156 ); 157} 158 159const PageContent = (props: { entityID: string }) => { 160 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 161 if (pageType === "doc") return <DocContent entityID={props.entityID} />; 162 return <Canvas entityID={props.entityID} />; 163}; 164 165const DocContent = (props: { entityID: string }) => { 166 let { rootEntity } = useReplicache(); 167 let isFocused = useUIState((s) => { 168 let focusedElement = s.focusedEntity; 169 let focusedPageID = 170 focusedElement?.entityType === "page" 171 ? focusedElement.entityID 172 : focusedElement?.parent; 173 return focusedPageID === props.entityID; 174 }); 175 176 let cardBorderHidden = useCardBorderHidden(props.entityID); 177 let rootBackgroundImage = useEntity( 178 rootEntity, 179 "theme/card-background-image", 180 ); 181 let rootBackgroundRepeat = useEntity( 182 rootEntity, 183 "theme/card-background-image-repeat", 184 ); 185 let rootBackgroundOpacity = useEntity( 186 rootEntity, 187 "theme/card-background-image-opacity", 188 ); 189 190 let cardBackgroundImage = useEntity( 191 props.entityID, 192 "theme/card-background-image", 193 ); 194 195 let cardBackgroundImageRepeat = useEntity( 196 props.entityID, 197 "theme/card-background-image-repeat", 198 ); 199 200 let cardBackgroundImageOpacity = useEntity( 201 props.entityID, 202 "theme/card-background-image-opacity", 203 ); 204 205 let backgroundImage = cardBackgroundImage || rootBackgroundImage; 206 let backgroundImageRepeat = cardBackgroundImage 207 ? cardBackgroundImageRepeat?.data?.value 208 : rootBackgroundRepeat?.data.value; 209 let backgroundImageOpacity = cardBackgroundImage 210 ? cardBackgroundImageOpacity?.data.value 211 : rootBackgroundOpacity?.data.value || 1; 212 213 return ( 214 <> 215 {!cardBorderHidden ? ( 216 <div 217 className={`pageBackground 218 absolute top-0 left-0 right-0 bottom-0 219 pointer-events-none 220 rounded-lg border 221 ${isFocused ? " border-border" : "border-border-light"} 222 `} 223 style={{ 224 backgroundImage: backgroundImage 225 ? `url(${backgroundImage.data.src}), url(${backgroundImage.data.fallback})` 226 : undefined, 227 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 228 backgroundPosition: "center", 229 backgroundSize: !backgroundImageRepeat 230 ? "cover" 231 : backgroundImageRepeat, 232 opacity: backgroundImage?.data.src ? backgroundImageOpacity : 1, 233 }} 234 /> 235 ) : null} 236 <PublicationMetadata cardBorderHidden={!!cardBorderHidden} /> 237 <Blocks entityID={props.entityID} /> 238 {/* we handle page bg in this sepate div so that 239 we can apply an opacity the background image 240 without affecting the opacity of the rest of the page */} 241 </> 242 ); 243}; 244 245const PageOptionButton = ({ 246 children, 247 secondary, 248 cardBorderHidden, 249 className, 250 disabled, 251 ...props 252}: { 253 children: React.ReactNode; 254 secondary?: boolean; 255 cardBorderHidden: boolean | undefined; 256 className?: string; 257 disabled?: boolean; 258} & Omit<JSX.IntrinsicElements["button"], "content">) => { 259 return ( 260 <button 261 className={` 262 pageOptionsTrigger 263 shrink-0 264 pt-[2px] h-5 w-5 p-0.5 mx-auto 265 border border-border 266 ${secondary ? "bg-border text-bg-page" : "bg-bg-page text-border"} 267 ${disabled && "opacity-50"} 268 ${cardBorderHidden ? "rounded-md" : `rounded-b-md sm:rounded-l-none sm:rounded-r-md`} 269 flex items-center justify-center 270 ${className} 271 272 `} 273 {...props} 274 > 275 {children} 276 </button> 277 ); 278}; 279 280const PageOptions = (props: { 281 entityID: string; 282 first: boolean | undefined; 283}) => { 284 let { rootEntity } = useReplicache(); 285 let cardBorderHidden = useCardBorderHidden(props.entityID); 286 287 return ( 288 <div 289 className={`z-10 w-fit absolute ${cardBorderHidden ? "top-1" : "sm:top-3"} sm:-right-[19px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start`} 290 > 291 {!props.first && ( 292 <PageOptionButton 293 cardBorderHidden={cardBorderHidden} 294 secondary 295 onClick={() => { 296 useUIState.getState().closePage(props.entityID); 297 }} 298 > 299 <CloseTiny /> 300 </PageOptionButton> 301 )} 302 <OptionsMenu 303 entityID={props.entityID} 304 first={!!props.first} 305 cardBorderHidden={cardBorderHidden} 306 /> 307 <UndoButtons cardBorderHidden={cardBorderHidden} /> 308 </div> 309 ); 310}; 311 312const UndoButtons = (props: { cardBorderHidden: boolean | undefined }) => { 313 let undoState = useUndoState(); 314 let { undoManager } = useReplicache(); 315 return ( 316 <Media mobile> 317 {undoState.canUndo && ( 318 <div className="gap-1 flex sm:flex-col"> 319 <PageOptionButton 320 secondary 321 cardBorderHidden={props.cardBorderHidden} 322 onClick={() => undoManager.undo()} 323 > 324 <UndoTiny /> 325 </PageOptionButton> 326 327 <PageOptionButton 328 secondary 329 cardBorderHidden={props.cardBorderHidden} 330 onClick={() => undoManager.undo()} 331 disabled={!undoState.canRedo} 332 > 333 <RedoTiny /> 334 </PageOptionButton> 335 </div> 336 )} 337 </Media> 338 ); 339}; 340 341const OptionsMenu = (props: { 342 entityID: string; 343 first: boolean; 344 cardBorderHidden: boolean | undefined; 345}) => { 346 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 347 let { permissions } = useEntitySetContext(); 348 if (!permissions.write) return null; 349 350 let { data: pub, mutate } = useLeafletPublicationData(); 351 if (pub && props.first) return; 352 return ( 353 <Menu 354 align="end" 355 asChild 356 onOpenChange={(open) => { 357 if (!open) setState("normal"); 358 }} 359 trigger={ 360 <PageOptionButton 361 cardBorderHidden={props.cardBorderHidden} 362 className="w-8! h-5! sm:w-5! sm:h-8!" 363 > 364 <MoreOptionsTiny className="sm:rotate-90" /> 365 </PageOptionButton> 366 } 367 > 368 {state === "normal" ? ( 369 <> 370 {!props.first && ( 371 <MenuItem 372 onSelect={(e) => { 373 e.preventDefault(); 374 setState("share"); 375 }} 376 > 377 <ShareSmall /> Share Page 378 </MenuItem> 379 )} 380 {!pub && ( 381 <MenuItem 382 onSelect={(e) => { 383 e.preventDefault(); 384 setState("theme"); 385 }} 386 > 387 <PaintSmall /> Theme Page 388 </MenuItem> 389 )} 390 </> 391 ) : state === "theme" ? ( 392 <PageThemeSetter entityID={props.entityID} /> 393 ) : state === "share" ? ( 394 <PageShareMenu entityID={props.entityID} /> 395 ) : null} 396 </Menu> 397 ); 398}; 399 400export async function focusPage( 401 pageID: string, 402 rep: Replicache<ReplicacheMutators>, 403 focusFirstBlock?: "focusFirstBlock", 404) { 405 // if this page is already focused, 406 let focusedBlock = useUIState.getState().focusedEntity; 407 // else set this page as focused 408 useUIState.setState(() => ({ 409 focusedEntity: { 410 entityType: "page", 411 entityID: pageID, 412 }, 413 })); 414 415 setTimeout(async () => { 416 //scroll to page 417 418 scrollIntoViewIfNeeded( 419 document.getElementById(elementId.page(pageID).container), 420 false, 421 "smooth", 422 ); 423 424 // if we asked that the function focus the first block, focus the first block 425 if (focusFirstBlock === "focusFirstBlock") { 426 let firstBlock = await rep.query(async (tx) => { 427 let type = await scanIndex(tx).eav(pageID, "page/type"); 428 let blocks = await scanIndex(tx).eav( 429 pageID, 430 type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 431 ); 432 433 let firstBlock = blocks[0]; 434 435 if (!firstBlock) { 436 return null; 437 } 438 439 let blockType = ( 440 await tx 441 .scan< 442 Fact<"block/type"> 443 >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 444 .toArray() 445 )[0]; 446 447 if (!blockType) return null; 448 449 return { 450 value: firstBlock.data.value, 451 type: blockType.data.value, 452 parent: firstBlock.entity, 453 position: firstBlock.data.position, 454 }; 455 }); 456 457 if (firstBlock) { 458 setTimeout(() => { 459 focusBlock(firstBlock, { type: "start" }); 460 }, 500); 461 } 462 } 463 }, 50); 464} 465 466const blurPage = () => { 467 useUIState.setState(() => ({ 468 focusedEntity: null, 469 selectedBlocks: [], 470 })); 471}; 472const UndoTiny = () => { 473 return ( 474 <svg 475 width="16" 476 height="16" 477 viewBox="0 0 16 16" 478 fill="none" 479 xmlns="http://www.w3.org/2000/svg" 480 > 481 <path 482 fillRule="evenodd" 483 clipRule="evenodd" 484 d="M5.98775 3.14543C6.37828 2.75491 6.37828 2.12174 5.98775 1.73122C5.59723 1.34069 4.96407 1.34069 4.57354 1.73122L1.20732 5.09744C0.816798 5.48796 0.816798 6.12113 1.20732 6.51165L4.57354 9.87787C4.96407 10.2684 5.59723 10.2684 5.98775 9.87787C6.37828 9.48735 6.37828 8.85418 5.98775 8.46366L4.32865 6.80456H9.6299C12.1732 6.80456 13.0856 8.27148 13.0856 9.21676C13.0856 9.84525 12.8932 10.5028 12.5318 10.9786C12.1942 11.4232 11.6948 11.7367 10.9386 11.7367H9.43173C8.87944 11.7367 8.43173 12.1844 8.43173 12.7367C8.43173 13.2889 8.87944 13.7367 9.43173 13.7367H10.9386C12.3587 13.7367 13.4328 13.0991 14.1246 12.1883C14.7926 11.3086 15.0856 10.2062 15.0856 9.21676C15.0856 6.92612 13.0205 4.80456 9.6299 4.80456L4.32863 4.80456L5.98775 3.14543Z" 485 fill="currentColor" 486 /> 487 </svg> 488 ); 489}; 490 491const RedoTiny = () => { 492 return ( 493 <svg 494 width="16" 495 height="16" 496 viewBox="0 0 16 16" 497 fill="none" 498 xmlns="http://www.w3.org/2000/svg" 499 > 500 <path 501 fillRule="evenodd" 502 clipRule="evenodd" 503 d="M10.0122 3.14543C9.62172 2.75491 9.62172 2.12174 10.0122 1.73122C10.4028 1.34069 11.0359 1.34069 11.4265 1.73122L14.7927 5.09744C15.1832 5.48796 15.1832 6.12113 14.7927 6.51165L11.4265 9.87787C11.0359 10.2684 10.4028 10.2684 10.0122 9.87787C9.62172 9.48735 9.62172 8.85418 10.0122 8.46366L11.6713 6.80456H6.3701C3.82678 6.80456 2.91443 8.27148 2.91443 9.21676C2.91443 9.84525 3.10681 10.5028 3.46817 10.9786C3.8058 11.4232 4.30523 11.7367 5.06143 11.7367H6.56827C7.12056 11.7367 7.56827 12.1844 7.56827 12.7367C7.56827 13.2889 7.12056 13.7367 6.56827 13.7367H5.06143C3.6413 13.7367 2.56723 13.0991 1.87544 12.1883C1.20738 11.3086 0.914429 10.2062 0.914429 9.21676C0.914429 6.92612 2.97946 4.80456 6.3701 4.80456L11.6714 4.80456L10.0122 3.14543Z" 504 fill="currentColor" 505 /> 506 </svg> 507 ); 508};