a tool for shared writing and social publishing
at feature/fonts 442 lines 15 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"; 32import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue, generateFontFaceCSS, getFontBaseSize } from "src/fonts"; 33 34// define a function to set an Aria Color to a CSS Variable in RGB 35function setCSSVariableToColor( 36 el: HTMLElement, 37 name: string, 38 value: AriaColor, 39) { 40 el?.style.setProperty(name, colorToString(value, "rgb")); 41} 42 43//Create a wrapper that applies a theme to each page 44export function ThemeProvider(props: { 45 entityID: string | null; 46 local?: boolean; 47 children: React.ReactNode; 48 className?: string; 49 initialHeadingFontId?: string; 50 initialBodyFontId?: string; 51}) { 52 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 53 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; 54 return ( 55 <PublicationThemeProvider 56 {...props} 57 theme={normalizedPublication?.theme} 58 pub_creator={pub.publications?.identity_did} 59 /> 60 ); 61} 62// for PUBLICATIONS: define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme 63 64// for LEAFLETS : define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme 65export function LeafletThemeProvider(props: { 66 entityID: string | null; 67 local?: boolean; 68 children: React.ReactNode; 69 initialHeadingFontId?: string; 70 initialBodyFontId?: string; 71}) { 72 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 73 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 74 let cardBorderHiddenValue = useEntity( 75 props.entityID, 76 "theme/card-border-hidden", 77 )?.data.value; 78 let showPageBackground = !cardBorderHiddenValue; 79 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 80 let hasBackgroundImage = !!backgroundImage; 81 let primary = useColorAttribute(props.entityID, "theme/primary"); 82 83 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); 84 let highlight2 = useColorAttribute(props.entityID, "theme/highlight-2"); 85 let highlight3 = useColorAttribute(props.entityID, "theme/highlight-3"); 86 87 let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 88 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 89 90 let pageWidth = useEntity(props.entityID, "theme/page-width"); 91 // Use initial font IDs as fallback until Replicache syncs 92 let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId; 93 let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId; 94 95 return ( 96 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 97 <HasBackgroundImageContext.Provider value={hasBackgroundImage}> 98 <BaseThemeProvider 99 local={props.local} 100 bgLeaflet={bgLeaflet} 101 bgPage={bgPage} 102 primary={primary} 103 highlight2={highlight2} 104 highlight3={highlight3} 105 highlight1={highlight1?.data.value} 106 accent1={accent1} 107 accent2={accent2} 108 showPageBackground={showPageBackground} 109 pageWidth={pageWidth?.data.value} 110 hasBackgroundImage={hasBackgroundImage} 111 headingFontId={headingFontId} 112 bodyFontId={bodyFontId} 113 > 114 {props.children} 115 </BaseThemeProvider> 116 </HasBackgroundImageContext.Provider> 117 </CardBorderHiddenContext.Provider> 118 ); 119} 120 121// handles setting all the Aria Color values to CSS Variables and wrapping the page the theme providers 122export const BaseThemeProvider = ({ 123 local, 124 bgLeaflet, 125 bgPage: bgPageProp, 126 primary, 127 accent1, 128 accent2, 129 highlight1, 130 highlight2, 131 highlight3, 132 showPageBackground, 133 pageWidth, 134 hasBackgroundImage, 135 headingFontId, 136 bodyFontId, 137 className, 138 children, 139}: { 140 local?: boolean; 141 showPageBackground?: boolean; 142 hasBackgroundImage?: boolean; 143 bgLeaflet: AriaColor; 144 bgPage: AriaColor; 145 primary: AriaColor; 146 accent1: AriaColor; 147 accent2: AriaColor; 148 highlight1?: string; 149 highlight2: AriaColor; 150 highlight3: AriaColor; 151 pageWidth?: number; 152 headingFontId?: string; 153 bodyFontId?: string; 154 className?: string; 155 children: React.ReactNode; 156}) => { 157 // When showPageBackground is false and there's no background image, 158 // pageBg should inherit from leafletBg 159 const bgPage = 160 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp; 161 162 let accentContrast; 163 let sortedAccents = [accent1, accent2].sort((a, b) => { 164 // sort accents by contrast against the background 165 return ( 166 getColorDifference( 167 colorToString(b, "rgb"), 168 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 169 ) - 170 getColorDifference( 171 colorToString(a, "rgb"), 172 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 173 ) 174 ); 175 }); 176 if ( 177 // if the contrast-y accent is too similar to text color 178 getColorDifference( 179 colorToString(sortedAccents[0], "rgb"), 180 colorToString(primary, "rgb"), 181 ) < 0.15 && 182 // and if the other accent is different enough from the background 183 getColorDifference( 184 colorToString(sortedAccents[1], "rgb"), 185 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 186 ) > 0.31 187 ) { 188 //then choose the less contrast-y accent 189 accentContrast = sortedAccents[1]; 190 } else { 191 // otherwise, choose the more contrast-y option 192 accentContrast = sortedAccents[0]; 193 } 194 195 // Get font configs for CSS variables 196 const headingFontConfig = getFontConfig(headingFontId); 197 const bodyFontConfig = getFontConfig(bodyFontId); 198 const headingFontValue = getFontFamilyValue(headingFontConfig); 199 const bodyFontValue = getFontFamilyValue(bodyFontConfig); 200 const bodyFontBaseSize = getFontBaseSize(bodyFontConfig); 201 const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 202 const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 203 204 // Dynamically load Google Fonts when fonts change 205 useEffect(() => { 206 const loadGoogleFont = (url: string | null, fontFamily: string) => { 207 if (!url) return; 208 209 // Check if this font stylesheet is already in the document 210 const existingLink = document.querySelector(`link[href="${url}"]`); 211 if (existingLink) return; 212 213 // Add preconnect hints if not present 214 if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 215 const preconnect1 = document.createElement("link"); 216 preconnect1.rel = "preconnect"; 217 preconnect1.href = "https://fonts.googleapis.com"; 218 document.head.appendChild(preconnect1); 219 220 const preconnect2 = document.createElement("link"); 221 preconnect2.rel = "preconnect"; 222 preconnect2.href = "https://fonts.gstatic.com"; 223 preconnect2.crossOrigin = "anonymous"; 224 document.head.appendChild(preconnect2); 225 } 226 227 // Load the Google Font stylesheet 228 const link = document.createElement("link"); 229 link.rel = "stylesheet"; 230 link.href = url; 231 document.head.appendChild(link); 232 233 // Wait for the font to actually load before it gets applied 234 if (document.fonts?.load) { 235 document.fonts.load(`1em "${fontFamily}"`); 236 } 237 }; 238 239 loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 240 loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 241 }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 242 243 useEffect(() => { 244 if (local) return; 245 let el = document.querySelector(":root") as HTMLElement; 246 if (!el) return; 247 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet); 248 setCSSVariableToColor(el, "--bg-page", bgPage); 249 document.body.style.backgroundColor = `rgb(${colorToString(bgLeaflet, "rgb")})`; 250 document 251 .querySelector('meta[name="theme-color"]') 252 ?.setAttribute("content", `rgb(${colorToString(bgLeaflet, "rgb")})`); 253 el?.style.setProperty( 254 "--bg-page-alpha", 255 bgPage.getChannelValue("alpha").toString(), 256 ); 257 setCSSVariableToColor(el, "--primary", primary); 258 259 setCSSVariableToColor(el, "--highlight-2", highlight2); 260 setCSSVariableToColor(el, "--highlight-3", highlight3); 261 262 //highlight 1 is special because its default value is a calculated value 263 if (highlight1) { 264 let color = parseColor(`hsba(${highlight1})`); 265 el?.style.setProperty( 266 "--highlight-1", 267 `rgb(${colorToString(color, "rgb")})`, 268 ); 269 } else { 270 el?.style.setProperty( 271 "--highlight-1", 272 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 273 ); 274 } 275 setCSSVariableToColor(el, "--accent-1", accent1); 276 setCSSVariableToColor(el, "--accent-2", accent2); 277 el?.style.setProperty( 278 "--accent-contrast", 279 colorToString(accentContrast, "rgb"), 280 ); 281 el?.style.setProperty( 282 "--accent-1-is-contrast", 283 accentContrast === accent1 ? "1" : "0", 284 ); 285 286 // Set page width CSS variable 287 el?.style.setProperty( 288 "--page-width-setting", 289 (pageWidth || 624).toString(), 290 ); 291 292 }, [ 293 local, 294 bgLeaflet, 295 bgPage, 296 primary, 297 highlight1, 298 highlight2, 299 highlight3, 300 accent1, 301 accent2, 302 accentContrast, 303 pageWidth, 304 ]); 305 return ( 306 <div 307 className={`leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch ${className || ""}`} 308 style={ 309 { 310 "--bg-leaflet": colorToString(bgLeaflet, "rgb"), 311 "--bg-page": colorToString(bgPage, "rgb"), 312 "--bg-page-alpha": bgPage.getChannelValue("alpha"), 313 "--primary": colorToString(primary, "rgb"), 314 "--accent-1": colorToString(accent1, "rgb"), 315 "--accent-2": colorToString(accent2, "rgb"), 316 "--accent-contrast": colorToString(accentContrast, "rgb"), 317 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0, 318 "--highlight-1": highlight1 319 ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})` 320 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 321 "--highlight-2": colorToString(highlight2, "rgb"), 322 "--highlight-3": colorToString(highlight3, "rgb"), 323 "--page-width-setting": pageWidth || 624, 324 "--page-width-unitless": pageWidth || 624, 325 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 326 "--theme-heading-font": headingFontValue, 327 "--theme-font": bodyFontValue, 328 "--theme-font-base-size": `${bodyFontBaseSize}px`, 329 } as CSSProperties 330 } 331 > 332 {" "} 333 {children}{" "} 334 </div> 335 ); 336}; 337 338let CardThemeProviderContext = createContext<null | string>(null); 339export function NestedCardThemeProvider(props: { children: React.ReactNode }) { 340 let card = useContext(CardThemeProviderContext); 341 if (!card) return props.children; 342 return ( 343 <CardThemeProvider entityID={card}>{props.children}</CardThemeProvider> 344 ); 345} 346 347export function CardThemeProvider(props: { 348 entityID: string; 349 children: React.ReactNode; 350}) { 351 let bgPage = useColorAttributeNullable( 352 props.entityID, 353 "theme/card-background", 354 ); 355 let primary = useColorAttributeNullable(props.entityID, "theme/primary"); 356 let accent1 = useColorAttributeNullable( 357 props.entityID, 358 "theme/accent-background", 359 ); 360 let accent2 = useColorAttributeNullable(props.entityID, "theme/accent-text"); 361 let accentContrast = 362 bgPage && accent1 && accent2 363 ? [accent1, accent2].sort((a, b) => { 364 return ( 365 getColorDifference( 366 colorToString(b, "rgb"), 367 colorToString(bgPage, "rgb"), 368 ) - 369 getColorDifference( 370 colorToString(a, "rgb"), 371 colorToString(bgPage, "rgb"), 372 ) 373 ); 374 })[0] 375 : null; 376 377 return ( 378 <CardThemeProviderContext.Provider value={props.entityID}> 379 <div 380 className="contents text-primary" 381 style={ 382 { 383 "--accent-1": accent1 ? colorToString(accent1, "rgb") : undefined, 384 "--accent-2": accent2 ? colorToString(accent2, "rgb") : undefined, 385 "--accent-contrast": accentContrast 386 ? colorToString(accentContrast, "rgb") 387 : undefined, 388 "--bg-page": bgPage ? colorToString(bgPage, "rgb") : undefined, 389 "--bg-page-alpha": bgPage 390 ? bgPage.getChannelValue("alpha") 391 : undefined, 392 "--primary": primary ? colorToString(primary, "rgb") : undefined, 393 } as CSSProperties 394 } 395 > 396 {props.children} 397 </div> 398 </CardThemeProviderContext.Provider> 399 ); 400} 401 402// Wrapper within the Theme Wrapper that provides background image data 403export const ThemeBackgroundProvider = (props: { 404 entityID: string; 405 children: React.ReactNode; 406}) => { 407 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 408 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 409 let backgroundImageRepeat = useEntity( 410 props.entityID, 411 "theme/background-image-repeat", 412 ); 413 if (pub?.publications) { 414 return ( 415 <PublicationBackgroundProvider 416 pub_creator={pub?.publications.identity_did || ""} 417 theme={normalizedPublication?.theme} 418 > 419 {props.children} 420 </PublicationBackgroundProvider> 421 ); 422 } 423 return ( 424 <div 425 className="LeafletBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 426 style={ 427 { 428 backgroundImage: backgroundImage 429 ? `url(${backgroundImage?.data.src}), url(${backgroundImage?.data.fallback})` 430 : undefined, 431 backgroundPosition: "center", 432 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 433 backgroundSize: !backgroundImageRepeat 434 ? "cover" 435 : backgroundImageRepeat?.data.value, 436 } as CSSProperties 437 } 438 > 439 {props.children} 440 </div> 441 ); 442};