a tool for shared writing and social publishing
at update/reader 374 lines 12 kB view raw
1"use client"; 2 3import { createContext, CSSProperties, useContext, useEffect } from "react"; 4 5// Context for cardBorderHidden 6export const CardBorderHiddenContext = createContext<boolean>(false); 7 8export function useCardBorderHiddenContext() { 9 return useContext(CardBorderHiddenContext); 10} 11 12// Context for hasBackgroundImage 13export const HasBackgroundImageContext = createContext<boolean>(false); 14 15export function useHasBackgroundImageContext() { 16 return useContext(HasBackgroundImageContext); 17} 18import { 19 colorToString, 20 useColorAttribute, 21 useColorAttributeNullable, 22} from "./useColorAttribute"; 23import { Color as AriaColor, parseColor } from "react-aria-components"; 24 25import { useEntity } from "src/replicache"; 26import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 27import { 28 PublicationBackgroundProvider, 29 PublicationThemeProvider, 30} from "./PublicationThemeProvider"; 31import { getColorDifference } from "./themeUtils"; 32 33// define a function to set an Aria Color to a CSS Variable in RGB 34function setCSSVariableToColor( 35 el: HTMLElement, 36 name: string, 37 value: AriaColor, 38) { 39 el?.style.setProperty(name, colorToString(value, "rgb")); 40} 41 42//Create a wrapper that applies a theme to each page 43export function ThemeProvider(props: { 44 entityID: string | null; 45 local?: boolean; 46 children: React.ReactNode; 47 className?: string; 48}) { 49 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 50 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; 51 return ( 52 <PublicationThemeProvider 53 {...props} 54 theme={normalizedPublication?.theme} 55 pub_creator={pub.publications?.identity_did} 56 /> 57 ); 58} 59// for PUBLICATIONS: define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme 60 61// for LEAFLETS : define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme 62export function LeafletThemeProvider(props: { 63 entityID: string | null; 64 local?: boolean; 65 children: React.ReactNode; 66}) { 67 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 68 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 69 let cardBorderHiddenValue = useEntity( 70 props.entityID, 71 "theme/card-border-hidden", 72 )?.data.value; 73 let showPageBackground = !cardBorderHiddenValue; 74 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 75 let hasBackgroundImage = !!backgroundImage; 76 let primary = useColorAttribute(props.entityID, "theme/primary"); 77 78 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); 79 let highlight2 = useColorAttribute(props.entityID, "theme/highlight-2"); 80 let highlight3 = useColorAttribute(props.entityID, "theme/highlight-3"); 81 82 let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 83 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 84 85 let pageWidth = useEntity(props.entityID, "theme/page-width"); 86 87 return ( 88 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 89 <HasBackgroundImageContext.Provider value={hasBackgroundImage}> 90 <BaseThemeProvider 91 local={props.local} 92 bgLeaflet={bgLeaflet} 93 bgPage={bgPage} 94 primary={primary} 95 highlight2={highlight2} 96 highlight3={highlight3} 97 highlight1={highlight1?.data.value} 98 accent1={accent1} 99 accent2={accent2} 100 showPageBackground={showPageBackground} 101 pageWidth={pageWidth?.data.value} 102 hasBackgroundImage={hasBackgroundImage} 103 > 104 {props.children} 105 </BaseThemeProvider> 106 </HasBackgroundImageContext.Provider> 107 </CardBorderHiddenContext.Provider> 108 ); 109} 110 111// handles setting all the Aria Color values to CSS Variables and wrapping the page the theme providers 112export const BaseThemeProvider = ({ 113 local, 114 bgLeaflet, 115 bgPage: bgPageProp, 116 primary, 117 accent1, 118 accent2, 119 highlight1, 120 highlight2, 121 highlight3, 122 showPageBackground, 123 pageWidth, 124 hasBackgroundImage, 125 children, 126}: { 127 local?: boolean; 128 showPageBackground?: boolean; 129 hasBackgroundImage?: boolean; 130 bgLeaflet: AriaColor; 131 bgPage: AriaColor; 132 primary: AriaColor; 133 accent1: AriaColor; 134 accent2: AriaColor; 135 highlight1?: string; 136 highlight2: AriaColor; 137 highlight3: AriaColor; 138 pageWidth?: number; 139 children: React.ReactNode; 140}) => { 141 // When showPageBackground is false and there's no background image, 142 // pageBg should inherit from leafletBg 143 const bgPage = 144 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp; 145 146 let accentContrast; 147 let sortedAccents = [accent1, accent2].sort((a, b) => { 148 // sort accents by contrast against the background 149 return ( 150 getColorDifference( 151 colorToString(b, "rgb"), 152 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 153 ) - 154 getColorDifference( 155 colorToString(a, "rgb"), 156 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 157 ) 158 ); 159 }); 160 if ( 161 // if the contrast-y accent is too similar to text color 162 getColorDifference( 163 colorToString(sortedAccents[0], "rgb"), 164 colorToString(primary, "rgb"), 165 ) < 0.15 && 166 // and if the other accent is different enough from the background 167 getColorDifference( 168 colorToString(sortedAccents[1], "rgb"), 169 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 170 ) > 0.31 171 ) { 172 //then choose the less contrast-y accent 173 accentContrast = sortedAccents[1]; 174 } else { 175 // otherwise, choose the more contrast-y option 176 accentContrast = sortedAccents[0]; 177 } 178 179 useEffect(() => { 180 if (local) return; 181 let el = document.querySelector(":root") as HTMLElement; 182 if (!el) return; 183 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet); 184 setCSSVariableToColor(el, "--bg-page", bgPage); 185 document.body.style.backgroundColor = `rgb(${colorToString(bgLeaflet, "rgb")})`; 186 document 187 .querySelector('meta[name="theme-color"]') 188 ?.setAttribute("content", `rgb(${colorToString(bgLeaflet, "rgb")})`); 189 el?.style.setProperty( 190 "--bg-page-alpha", 191 bgPage.getChannelValue("alpha").toString(), 192 ); 193 setCSSVariableToColor(el, "--primary", primary); 194 195 setCSSVariableToColor(el, "--highlight-2", highlight2); 196 setCSSVariableToColor(el, "--highlight-3", highlight3); 197 198 //highlight 1 is special because its default value is a calculated value 199 if (highlight1) { 200 let color = parseColor(`hsba(${highlight1})`); 201 el?.style.setProperty( 202 "--highlight-1", 203 `rgb(${colorToString(color, "rgb")})`, 204 ); 205 } else { 206 el?.style.setProperty( 207 "--highlight-1", 208 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 209 ); 210 } 211 setCSSVariableToColor(el, "--accent-1", accent1); 212 setCSSVariableToColor(el, "--accent-2", accent2); 213 el?.style.setProperty( 214 "--accent-contrast", 215 colorToString(accentContrast, "rgb"), 216 ); 217 el?.style.setProperty( 218 "--accent-1-is-contrast", 219 accentContrast === accent1 ? "1" : "0", 220 ); 221 222 // Set page width CSS variable 223 el?.style.setProperty( 224 "--page-width-setting", 225 (pageWidth || 624).toString(), 226 ); 227 }, [ 228 local, 229 bgLeaflet, 230 bgPage, 231 primary, 232 highlight1, 233 highlight2, 234 highlight3, 235 accent1, 236 accent2, 237 accentContrast, 238 pageWidth, 239 ]); 240 return ( 241 <div 242 className="leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch " 243 style={ 244 { 245 "--bg-leaflet": colorToString(bgLeaflet, "rgb"), 246 "--bg-page": colorToString(bgPage, "rgb"), 247 "--bg-page-alpha": bgPage.getChannelValue("alpha"), 248 "--primary": colorToString(primary, "rgb"), 249 "--accent-1": colorToString(accent1, "rgb"), 250 "--accent-2": colorToString(accent2, "rgb"), 251 "--accent-contrast": colorToString(accentContrast, "rgb"), 252 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0, 253 "--highlight-1": highlight1 254 ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})` 255 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 256 "--highlight-2": colorToString(highlight2, "rgb"), 257 "--highlight-3": colorToString(highlight3, "rgb"), 258 "--page-width-setting": pageWidth || 624, 259 "--page-width-unitless": pageWidth || 624, 260 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 261 } as CSSProperties 262 } 263 > 264 {" "} 265 {children}{" "} 266 </div> 267 ); 268}; 269 270let CardThemeProviderContext = createContext<null | string>(null); 271export function NestedCardThemeProvider(props: { children: React.ReactNode }) { 272 let card = useContext(CardThemeProviderContext); 273 if (!card) return props.children; 274 return ( 275 <CardThemeProvider entityID={card}>{props.children}</CardThemeProvider> 276 ); 277} 278 279export function CardThemeProvider(props: { 280 entityID: string; 281 children: React.ReactNode; 282}) { 283 let bgPage = useColorAttributeNullable( 284 props.entityID, 285 "theme/card-background", 286 ); 287 let primary = useColorAttributeNullable(props.entityID, "theme/primary"); 288 let accent1 = useColorAttributeNullable( 289 props.entityID, 290 "theme/accent-background", 291 ); 292 let accent2 = useColorAttributeNullable(props.entityID, "theme/accent-text"); 293 let accentContrast = 294 bgPage && accent1 && accent2 295 ? [accent1, accent2].sort((a, b) => { 296 return ( 297 getColorDifference( 298 colorToString(b, "rgb"), 299 colorToString(bgPage, "rgb"), 300 ) - 301 getColorDifference( 302 colorToString(a, "rgb"), 303 colorToString(bgPage, "rgb"), 304 ) 305 ); 306 })[0] 307 : null; 308 309 return ( 310 <CardThemeProviderContext.Provider value={props.entityID}> 311 <div 312 className="contents text-primary" 313 style={ 314 { 315 "--accent-1": accent1 ? colorToString(accent1, "rgb") : undefined, 316 "--accent-2": accent2 ? colorToString(accent2, "rgb") : undefined, 317 "--accent-contrast": accentContrast 318 ? colorToString(accentContrast, "rgb") 319 : undefined, 320 "--bg-page": bgPage ? colorToString(bgPage, "rgb") : undefined, 321 "--bg-page-alpha": bgPage 322 ? bgPage.getChannelValue("alpha") 323 : undefined, 324 "--primary": primary ? colorToString(primary, "rgb") : undefined, 325 } as CSSProperties 326 } 327 > 328 {props.children} 329 </div> 330 </CardThemeProviderContext.Provider> 331 ); 332} 333 334// Wrapper within the Theme Wrapper that provides background image data 335export const ThemeBackgroundProvider = (props: { 336 entityID: string; 337 children: React.ReactNode; 338}) => { 339 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 340 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 341 let backgroundImageRepeat = useEntity( 342 props.entityID, 343 "theme/background-image-repeat", 344 ); 345 if (pub?.publications) { 346 return ( 347 <PublicationBackgroundProvider 348 pub_creator={pub?.publications.identity_did || ""} 349 theme={normalizedPublication?.theme} 350 > 351 {props.children} 352 </PublicationBackgroundProvider> 353 ); 354 } 355 return ( 356 <div 357 className="LeafletBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 358 style={ 359 { 360 backgroundImage: backgroundImage 361 ? `url(${backgroundImage?.data.src}), url(${backgroundImage?.data.fallback})` 362 : undefined, 363 backgroundPosition: "center", 364 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 365 backgroundSize: !backgroundImageRepeat 366 ? "cover" 367 : backgroundImageRepeat?.data.value, 368 } as CSSProperties 369 } 370 > 371 {props.children} 372 </div> 373 ); 374};