a tool for shared writing and social publishing
at update/reader 730 lines 24 kB view raw
1"use client"; 2 3import { 4 ColorPicker as SpectrumColorPicker, 5 parseColor, 6 Color, 7 ColorThumb, 8 ColorSlider, 9 Input, 10 ColorField, 11 SliderTrack, 12 ColorSwatch, 13} from "react-aria-components"; 14import { Checkbox } from "components/Checkbox"; 15import { useMemo, useState } from "react"; 16import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 17import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 18import { Separator } from "components/Layout"; 19import { onMouseDown } from "src/utils/iosInputMouseDown"; 20import { pickers, setColorAttribute } from "../ThemeSetter"; 21import { ImageInput, ImageSettings } from "./ImagePicker"; 22 23import { ColorPicker, thumbStyle } from "./ColorPicker"; 24import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 25import { Replicache } from "replicache"; 26import { CanvasBackgroundPattern } from "components/Canvas"; 27import { Toggle } from "components/Toggle"; 28import { DeleteSmall } from "components/Icons/DeleteSmall"; 29 30export const PageThemePickers = (props: { 31 entityID: string; 32 openPicker: pickers; 33 setOpenPicker: (thisPicker: pickers) => void; 34}) => { 35 let { rep } = useReplicache(); 36 let set = useMemo(() => { 37 return setColorAttribute(rep, props.entityID); 38 }, [rep, props.entityID]); 39 40 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 41 let primaryValue = useColorAttribute(props.entityID, "theme/primary"); 42 43 return ( 44 <div 45 className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 46 style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 47 > 48 {pageType === "canvas" && ( 49 <> 50 <CanvasBGPatternPicker entityID={props.entityID} rep={rep} />{" "} 51 <hr className="border-border-light w-full" /> 52 </> 53 )} 54 <TextPickers 55 value={primaryValue} 56 setValue={set("theme/primary")} 57 openPicker={props.openPicker} 58 setOpenPicker={props.setOpenPicker} 59 /> 60 </div> 61 ); 62}; 63 64// Page background picker for subpages - shows Page/Containers color with optional background image 65export const SubpageBackgroundPicker = (props: { 66 entityID: string; 67 openPicker: pickers; 68 setOpenPicker: (p: pickers) => void; 69}) => { 70 let { rep, rootEntity } = useReplicache(); 71 let set = useMemo(() => { 72 return setColorAttribute(rep, props.entityID); 73 }, [rep, props.entityID]); 74 75 let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 76 let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 77 let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 78 let entityPageBorderHidden = useEntity( 79 props.entityID, 80 "theme/card-border-hidden", 81 ); 82 let pageBorderHidden = 83 (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 84 let hasPageBackground = !pageBorderHidden; 85 86 // Label is "Page" when page background is visible, "Containers" when hidden 87 let label = hasPageBackground ? "Page" : "Containers"; 88 89 // If root page border is hidden, only show color picker (no image support) 90 if (!hasPageBackground) { 91 return ( 92 <ColorPicker 93 label={label} 94 helpText={"Affects menus, tooltips and some block backgrounds"} 95 value={pageValue} 96 setValue={set("theme/card-background")} 97 thisPicker="page" 98 openPicker={props.openPicker} 99 setOpenPicker={props.setOpenPicker} 100 closePicker={() => props.setOpenPicker("null")} 101 alpha 102 /> 103 ); 104 } 105 106 return ( 107 <> 108 {pageBGImage && ( 109 <SubpageBackgroundImagePicker 110 entityID={props.entityID} 111 openPicker={props.openPicker} 112 setOpenPicker={props.setOpenPicker} 113 setValue={set("theme/card-background")} 114 /> 115 )} 116 <div className="relative"> 117 <ColorPicker 118 label={label} 119 value={pageValue} 120 setValue={set("theme/card-background")} 121 thisPicker="page" 122 openPicker={props.openPicker} 123 setOpenPicker={props.setOpenPicker} 124 closePicker={() => props.setOpenPicker("null")} 125 alpha 126 /> 127 {!pageBGImage && ( 128 <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 129 <BlockImageSmall /> 130 <div className="hidden"> 131 <ImageInput 132 entityID={props.entityID} 133 onChange={() => props.setOpenPicker("page-background-image")} 134 card 135 /> 136 </div> 137 </label> 138 )} 139 </div> 140 </> 141 ); 142}; 143 144const SubpageBackgroundImagePicker = (props: { 145 entityID: string; 146 openPicker: pickers; 147 setOpenPicker: (p: pickers) => void; 148 setValue: (c: Color) => void; 149}) => { 150 let { rep } = useReplicache(); 151 let bgImage = useEntity(props.entityID, "theme/card-background-image"); 152 let bgRepeat = useEntity( 153 props.entityID, 154 "theme/card-background-image-repeat", 155 ); 156 let bgColor = useColorAttribute(props.entityID, "theme/card-background"); 157 let bgAlpha = 158 useEntity(props.entityID, "theme/card-background-image-opacity")?.data 159 .value || 1; 160 let alphaColor = useMemo(() => { 161 return parseColor(`rgba(0,0,0,${bgAlpha})`); 162 }, [bgAlpha]); 163 let open = props.openPicker === "page-background-image"; 164 165 return ( 166 <> 167 <div className="bgPickerColorLabel flex gap-2 items-center"> 168 <button 169 onClick={() => { 170 props.setOpenPicker(open ? "null" : "page-background-image"); 171 }} 172 className="flex gap-2 items-center grow" 173 > 174 <ColorSwatch 175 color={bgColor} 176 className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 177 style={{ 178 backgroundImage: bgImage?.data.src 179 ? `url(${bgImage.data.src})` 180 : undefined, 181 backgroundPosition: "center", 182 backgroundSize: "cover", 183 }} 184 /> 185 <strong className="text-[#595959]">Page</strong> 186 <div className="italic text-[#8C8C8C]">image</div> 187 </button> 188 189 <SpectrumColorPicker 190 value={alphaColor} 191 onChange={(c) => { 192 let alpha = c.getChannelValue("alpha"); 193 rep?.mutate.assertFact({ 194 entity: props.entityID, 195 attribute: "theme/card-background-image-opacity", 196 data: { type: "number", value: alpha }, 197 }); 198 }} 199 > 200 <Separator classname="h-4! my-1 border-[#C3C3C3]!" /> 201 <ColorField className="w-fit pl-[6px]" channel="alpha"> 202 <Input 203 onMouseDown={onMouseDown} 204 onFocus={(e) => { 205 e.currentTarget.setSelectionRange( 206 0, 207 e.currentTarget.value.length - 1, 208 ); 209 }} 210 onKeyDown={(e) => { 211 if (e.key === "Enter") { 212 e.currentTarget.blur(); 213 } else return; 214 }} 215 className="w-[48px] bg-transparent outline-hidden" 216 /> 217 </ColorField> 218 </SpectrumColorPicker> 219 220 <div className="flex gap-1 text-[#8C8C8C]"> 221 <button 222 onClick={() => { 223 if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 224 if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 225 }} 226 > 227 <DeleteSmall /> 228 </button> 229 <label className="hover:cursor-pointer"> 230 <BlockImageSmall /> 231 <div className="hidden"> 232 <ImageInput 233 entityID={props.entityID} 234 onChange={() => props.setOpenPicker("page-background-image")} 235 card 236 /> 237 </div> 238 </label> 239 </div> 240 </div> 241 {open && ( 242 <div className="pageImagePicker flex flex-col gap-2"> 243 <ImageSettings 244 entityID={props.entityID} 245 card 246 setValue={props.setValue} 247 /> 248 <div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2"> 249 <hr className="border-[#DBDBDB]" /> 250 <SpectrumColorPicker 251 value={alphaColor} 252 onChange={(c) => { 253 let alpha = c.getChannelValue("alpha"); 254 rep?.mutate.assertFact({ 255 entity: props.entityID, 256 attribute: "theme/card-background-image-opacity", 257 data: { type: "number", value: alpha }, 258 }); 259 }} 260 > 261 <ColorSlider 262 colorSpace="hsb" 263 className="w-full mt-1 rounded-full" 264 style={{ 265 backgroundImage: `url(/transparent-bg.png)`, 266 backgroundRepeat: "repeat", 267 backgroundSize: "8px", 268 }} 269 channel="alpha" 270 > 271 <SliderTrack className="h-2 w-full rounded-md"> 272 <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 273 </SliderTrack> 274 </ColorSlider> 275 </SpectrumColorPicker> 276 </div> 277 </div> 278 )} 279 </> 280 ); 281}; 282 283// Unified background picker for leaflets - matches structure of BackgroundPicker for publications 284export const LeafletBackgroundPicker = (props: { 285 entityID: string; 286 openPicker: pickers; 287 setOpenPicker: (p: pickers) => void; 288}) => { 289 let { rep } = useReplicache(); 290 let set = useMemo(() => { 291 return setColorAttribute(rep, props.entityID); 292 }, [rep, props.entityID]); 293 294 let leafletBgValue = useColorAttribute( 295 props.entityID, 296 "theme/page-background", 297 ); 298 let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 299 let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 300 let leafletBGRepeat = useEntity( 301 props.entityID, 302 "theme/background-image-repeat", 303 ); 304 let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 305 let hasPageBackground = !pageBorderHidden?.data.value; 306 307 // When page background is hidden and no background image, only show the Background picker 308 let showPagePicker = hasPageBackground || !!leafletBGImage; 309 310 return ( 311 <> 312 {/* Background color/image picker */} 313 {leafletBGImage ? ( 314 <LeafletBackgroundImagePicker 315 entityID={props.entityID} 316 openPicker={props.openPicker} 317 setOpenPicker={props.setOpenPicker} 318 /> 319 ) : ( 320 <div className="relative"> 321 <ColorPicker 322 label="Background" 323 value={leafletBgValue} 324 setValue={set("theme/page-background")} 325 thisPicker="leaflet" 326 openPicker={props.openPicker} 327 setOpenPicker={props.setOpenPicker} 328 closePicker={() => props.setOpenPicker("null")} 329 /> 330 <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 331 <BlockImageSmall /> 332 <div className="hidden"> 333 <ImageInput 334 entityID={props.entityID} 335 onChange={() => props.setOpenPicker("leaflet")} 336 /> 337 </div> 338 </label> 339 </div> 340 )} 341 342 {/* Page/Containers color picker - only shown when page background is visible OR there's a bg image */} 343 {showPagePicker && ( 344 <ColorPicker 345 label={hasPageBackground ? "Page" : "Containers"} 346 helpText={ 347 hasPageBackground 348 ? undefined 349 : "Affects menus, tooltips and some block backgrounds" 350 } 351 value={pageValue} 352 setValue={set("theme/card-background")} 353 thisPicker="page" 354 openPicker={props.openPicker} 355 setOpenPicker={props.setOpenPicker} 356 closePicker={() => props.setOpenPicker("null")} 357 alpha 358 /> 359 )} 360 361 <hr className="border-[#CCCCCC]" /> 362 363 {/* Page Background toggle */} 364 <PageBorderHider 365 entityID={props.entityID} 366 openPicker={props.openPicker} 367 setOpenPicker={props.setOpenPicker} 368 /> 369 </> 370 ); 371}; 372 373const LeafletBackgroundImagePicker = (props: { 374 entityID: string; 375 openPicker: pickers; 376 setOpenPicker: (p: pickers) => void; 377}) => { 378 let { rep } = useReplicache(); 379 let bgImage = useEntity(props.entityID, "theme/background-image"); 380 let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat"); 381 let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 382 let open = props.openPicker === "leaflet"; 383 384 return ( 385 <> 386 <div className="bgPickerColorLabel flex gap-2 items-center"> 387 <button 388 onClick={() => { 389 props.setOpenPicker(open ? "null" : "leaflet"); 390 }} 391 className="flex gap-2 items-center grow" 392 > 393 <ColorSwatch 394 color={bgColor} 395 className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 396 style={{ 397 backgroundImage: bgImage?.data.src 398 ? `url(${bgImage.data.src})` 399 : undefined, 400 backgroundPosition: "center", 401 backgroundSize: "cover", 402 }} 403 /> 404 <strong className="text-[#595959]">Background</strong> 405 <div className="italic text-[#8C8C8C]">image</div> 406 </button> 407 <div className="flex gap-1 text-[#8C8C8C]"> 408 <button 409 onClick={() => { 410 if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 411 if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 412 }} 413 > 414 <DeleteSmall /> 415 </button> 416 <label className="hover:cursor-pointer"> 417 <BlockImageSmall /> 418 <div className="hidden"> 419 <ImageInput 420 entityID={props.entityID} 421 onChange={() => props.setOpenPicker("leaflet")} 422 /> 423 </div> 424 </label> 425 </div> 426 </div> 427 {open && ( 428 <div className="pageImagePicker flex flex-col gap-2"> 429 <ImageSettings entityID={props.entityID} setValue={() => {}} /> 430 </div> 431 )} 432 </> 433 ); 434}; 435 436export const PageBackgroundColorPicker = (props: { 437 disabled?: boolean; 438 label: string; 439 openPicker: pickers; 440 thisPicker: pickers; 441 setOpenPicker: (thisPicker: pickers) => void; 442 setValue: (c: Color) => void; 443 value: Color; 444 alpha?: boolean; 445 helpText?: string; 446}) => { 447 return ( 448 <ColorPicker 449 disabled={props.disabled} 450 label={props.label} 451 helpText={props.helpText} 452 value={props.value} 453 setValue={props.setValue} 454 thisPicker={"page"} 455 openPicker={props.openPicker} 456 setOpenPicker={props.setOpenPicker} 457 closePicker={() => props.setOpenPicker("null")} 458 alpha={props.alpha} 459 /> 460 ); 461}; 462 463export const PageBackgroundImagePicker = (props: { 464 disabled?: boolean; 465 entityID: string; 466 openPicker: pickers; 467 thisPicker: pickers; 468 setOpenPicker: (thisPicker: pickers) => void; 469 closePicker: () => void; 470 setValue: (c: Color) => void; 471 home?: boolean; 472}) => { 473 let bgImage = useEntity(props.entityID, "theme/card-background-image"); 474 let bgRepeat = useEntity( 475 props.entityID, 476 "theme/card-background-image-repeat", 477 ); 478 let bgColor = useColorAttribute(props.entityID, "theme/card-background"); 479 let bgAlpha = 480 useEntity(props.entityID, "theme/card-background-image-opacity")?.data 481 .value || 1; 482 let alphaColor = useMemo(() => { 483 return parseColor(`rgba(0,0,0,${bgAlpha})`); 484 }, [bgAlpha]); 485 let open = props.openPicker == props.thisPicker; 486 let { rep } = useReplicache(); 487 488 return ( 489 <> 490 <div className="bgPickerColorLabel flex gap-2 items-center"> 491 <button 492 disabled={props.disabled} 493 onClick={() => { 494 if (props.openPicker === props.thisPicker) { 495 props.setOpenPicker("null"); 496 } else { 497 props.setOpenPicker(props.thisPicker); 498 } 499 }} 500 className="flex gap-2 items-center disabled:text-[#969696]" 501 > 502 <ColorSwatch 503 color={bgColor} 504 className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C] ${props.disabled ? "opacity-50" : ""}`} 505 style={{ 506 backgroundImage: bgImage?.data.src 507 ? `url(${bgImage.data.src})` 508 : undefined, 509 backgroundPosition: "center", 510 backgroundSize: "cover", 511 }} 512 /> 513 <strong 514 className={`${props.disabled ? "text-[#969696]" : " text-[#272727] "}`} 515 > 516 Page 517 </strong> 518 <div className="">Image</div> 519 </button> 520 521 <SpectrumColorPicker 522 value={alphaColor} 523 onChange={(c) => { 524 let alpha = c.getChannelValue("alpha"); 525 rep?.mutate.assertFact({ 526 entity: props.entityID, 527 attribute: "theme/card-background-image-opacity", 528 data: { type: "number", value: alpha }, 529 }); 530 }} 531 > 532 <Separator classname="h-4! my-1 border-[#C3C3C3]!" /> 533 <ColorField className="w-fit pl-[6px]" channel="alpha"> 534 <Input 535 disabled={props.disabled} 536 onMouseDown={onMouseDown} 537 onFocus={(e) => { 538 e.currentTarget.setSelectionRange( 539 0, 540 e.currentTarget.value.length - 1, 541 ); 542 }} 543 onKeyDown={(e) => { 544 if (e.key === "Enter") { 545 e.currentTarget.blur(); 546 } else return; 547 }} 548 className={`w-[48px] bg-transparent outline-hidden disabled:text-[#969696]`} 549 /> 550 </ColorField> 551 </SpectrumColorPicker> 552 <div className="flex gap-1 justify-end grow text-[#969696]"> 553 <button 554 onClick={() => { 555 if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 556 if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 557 }} 558 > 559 <DeleteSmall /> 560 </button> 561 <label> 562 <BlockImageSmall /> 563 <div className="hidden"> 564 <ImageInput 565 entityID={props.entityID} 566 onChange={() => props.setOpenPicker("page-background-image")} 567 card 568 /> 569 </div> 570 </label> 571 </div> 572 </div> 573 {open && ( 574 <div className="pageImagePicker flex flex-col gap-2"> 575 <ImageSettings 576 entityID={props.entityID} 577 card 578 setValue={props.setValue} 579 /> 580 <div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2"> 581 <hr className="border-[#DBDBDB]" /> 582 <SpectrumColorPicker 583 value={alphaColor} 584 onChange={(c) => { 585 let alpha = c.getChannelValue("alpha"); 586 rep?.mutate.assertFact({ 587 entity: props.entityID, 588 attribute: "theme/card-background-image-opacity", 589 data: { type: "number", value: alpha }, 590 }); 591 }} 592 > 593 <ColorSlider 594 colorSpace="hsb" 595 className="w-full mt-1 rounded-full" 596 style={{ 597 backgroundImage: `url(/transparent-bg.png)`, 598 backgroundRepeat: "repeat", 599 backgroundSize: "8px", 600 }} 601 channel="alpha" 602 > 603 <SliderTrack className="h-2 w-full rounded-md"> 604 <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 605 </SliderTrack> 606 </ColorSlider> 607 </SpectrumColorPicker> 608 </div> 609 </div> 610 )} 611 </> 612 ); 613}; 614 615const CanvasBGPatternPicker = (props: { 616 entityID: string; 617 rep: Replicache<ReplicacheMutators> | null; 618}) => { 619 let selectedPattern = useEntity(props.entityID, "canvas/background-pattern") 620 ?.data.value; 621 return ( 622 <div className="flex gap-2 h-8 "> 623 <button 624 className={`w-full rounded-md bg-bg-page border ${selectedPattern === "grid" ? "outline-solid outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 625 onMouseDown={() => { 626 props.rep && 627 props.rep.mutate.assertFact({ 628 entity: props.entityID, 629 attribute: "canvas/background-pattern", 630 data: { type: "canvas-pattern-union", value: "grid" }, 631 }); 632 }} 633 > 634 <CanvasBackgroundPattern pattern="grid" scale={0.5} /> 635 </button> 636 <button 637 className={`w-full rounded-md bg-bg-page border ${selectedPattern === "dot" ? "outline-solid outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 638 onMouseDown={() => { 639 props.rep && 640 props.rep.mutate.assertFact({ 641 entity: props.entityID, 642 attribute: "canvas/background-pattern", 643 data: { type: "canvas-pattern-union", value: "dot" }, 644 }); 645 }} 646 > 647 <CanvasBackgroundPattern pattern="dot" scale={0.5} /> 648 </button> 649 <button 650 className={`w-full rounded-md bg-bg-page border ${selectedPattern === "plain" ? "outline-solid outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 651 onMouseDown={() => { 652 props.rep && 653 props.rep.mutate.assertFact({ 654 entity: props.entityID, 655 attribute: "canvas/background-pattern", 656 data: { type: "canvas-pattern-union", value: "plain" }, 657 }); 658 }} 659 > 660 <CanvasBackgroundPattern pattern="plain" /> 661 </button> 662 </div> 663 ); 664}; 665 666export const TextPickers = (props: { 667 openPicker: pickers; 668 setOpenPicker: (thisPicker: pickers) => void; 669 value: Color; 670 setValue: (c: Color) => void; 671}) => { 672 return ( 673 <ColorPicker 674 label="Text" 675 value={props.value} 676 setValue={props.setValue} 677 thisPicker={"text"} 678 openPicker={props.openPicker} 679 setOpenPicker={props.setOpenPicker} 680 closePicker={() => props.setOpenPicker("null")} 681 /> 682 ); 683}; 684 685export const PageBorderHider = (props: { 686 entityID: string; 687 setOpenPicker: (p: pickers) => void; 688 openPicker: pickers; 689}) => { 690 let { rep, rootEntity } = useReplicache(); 691 let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 692 let entityPageBorderHidden = useEntity( 693 props.entityID, 694 "theme/card-border-hidden", 695 ); 696 let pageBorderHidden = 697 (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 698 699 function handleToggle() { 700 rep?.mutate.assertFact({ 701 entity: props.entityID, 702 attribute: "theme/card-border-hidden", 703 data: { type: "boolean", value: !pageBorderHidden }, 704 }); 705 706 (pageBorderHidden && props.openPicker === "page") || 707 (props.openPicker === "page-background-image" && 708 props.setOpenPicker("null")); 709 } 710 711 return ( 712 <> 713 <Toggle 714 toggle={!pageBorderHidden} 715 onToggle={() => { 716 handleToggle(); 717 }} 718 disabledColor1="#8C8C8C" 719 disabledColor2="#DBDBDB" 720 > 721 <div className="flex gap-2"> 722 <div className="font-bold">Page Background</div> 723 <div className="italic text-[#8C8C8C]"> 724 {pageBorderHidden ? "none" : ""} 725 </div> 726 </div> 727 </Toggle> 728 </> 729 ); 730};