a tool for shared writing and social publishing
at main 453 lines 16 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, getFontBaseSize, defaultFontId } 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 // When using the default font (Quattro), use var(--font-quattro) which is 197 // always available via next/font/local in layout.tsx, rather than the raw 198 // font-family name 'iA Writer Quattro V' which depends on a dynamic @font-face. 199 // When both heading and body are default, omit the variables entirely so the 200 // CSS fallback var(--theme-font, var(--font-quattro)) resolves naturally. 201 const isDefaultBody = !bodyFontId || bodyFontId === defaultFontId; 202 const isDefaultHeading = !headingFontId || headingFontId === defaultFontId; 203 const headingFontConfig = getFontConfig(headingFontId); 204 const bodyFontConfig = getFontConfig(bodyFontId); 205 const headingFontValue = isDefaultHeading 206 ? (isDefaultBody ? undefined : "var(--font-quattro)") 207 : getFontFamilyValue(headingFontConfig); 208 const bodyFontValue = isDefaultBody 209 ? (isDefaultHeading ? undefined : "var(--font-quattro)") 210 : getFontFamilyValue(bodyFontConfig); 211 const bodyFontBaseSize = isDefaultBody ? undefined : getFontBaseSize(bodyFontConfig); 212 const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 213 const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 214 215 // Dynamically load Google Fonts when fonts change 216 useEffect(() => { 217 const loadGoogleFont = (url: string | null, fontFamily: string) => { 218 if (!url) return; 219 220 // Check if this font stylesheet is already in the document 221 const existingLink = document.querySelector(`link[href="${url}"]`); 222 if (existingLink) return; 223 224 // Add preconnect hints if not present 225 if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 226 const preconnect1 = document.createElement("link"); 227 preconnect1.rel = "preconnect"; 228 preconnect1.href = "https://fonts.googleapis.com"; 229 document.head.appendChild(preconnect1); 230 231 const preconnect2 = document.createElement("link"); 232 preconnect2.rel = "preconnect"; 233 preconnect2.href = "https://fonts.gstatic.com"; 234 preconnect2.crossOrigin = "anonymous"; 235 document.head.appendChild(preconnect2); 236 } 237 238 // Load the Google Font stylesheet 239 const link = document.createElement("link"); 240 link.rel = "stylesheet"; 241 link.href = url; 242 document.head.appendChild(link); 243 244 // Wait for the font to actually load before it gets applied 245 if (document.fonts?.load) { 246 document.fonts.load(`1em "${fontFamily}"`); 247 } 248 }; 249 250 loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 251 loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 252 }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 253 254 useEffect(() => { 255 if (local) return; 256 let el = document.querySelector(":root") as HTMLElement; 257 if (!el) return; 258 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet); 259 setCSSVariableToColor(el, "--bg-page", bgPage); 260 document.body.style.backgroundColor = `rgb(${colorToString(bgLeaflet, "rgb")})`; 261 document 262 .querySelector('meta[name="theme-color"]') 263 ?.setAttribute("content", `rgb(${colorToString(bgLeaflet, "rgb")})`); 264 el?.style.setProperty( 265 "--bg-page-alpha", 266 bgPage.getChannelValue("alpha").toString(), 267 ); 268 setCSSVariableToColor(el, "--primary", primary); 269 270 setCSSVariableToColor(el, "--highlight-2", highlight2); 271 setCSSVariableToColor(el, "--highlight-3", highlight3); 272 273 //highlight 1 is special because its default value is a calculated value 274 if (highlight1) { 275 let color = parseColor(`hsba(${highlight1})`); 276 el?.style.setProperty( 277 "--highlight-1", 278 `rgb(${colorToString(color, "rgb")})`, 279 ); 280 } else { 281 el?.style.setProperty( 282 "--highlight-1", 283 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 284 ); 285 } 286 setCSSVariableToColor(el, "--accent-1", accent1); 287 setCSSVariableToColor(el, "--accent-2", accent2); 288 el?.style.setProperty( 289 "--accent-contrast", 290 colorToString(accentContrast, "rgb"), 291 ); 292 el?.style.setProperty( 293 "--accent-1-is-contrast", 294 accentContrast === accent1 ? "1" : "0", 295 ); 296 297 // Set page width CSS variable 298 el?.style.setProperty( 299 "--page-width-setting", 300 (pageWidth || 624).toString(), 301 ); 302 303 }, [ 304 local, 305 bgLeaflet, 306 bgPage, 307 primary, 308 highlight1, 309 highlight2, 310 highlight3, 311 accent1, 312 accent2, 313 accentContrast, 314 pageWidth, 315 ]); 316 return ( 317 <div 318 className={`leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch ${className || ""}`} 319 style={ 320 { 321 "--bg-leaflet": colorToString(bgLeaflet, "rgb"), 322 "--bg-page": colorToString(bgPage, "rgb"), 323 "--bg-page-alpha": bgPage.getChannelValue("alpha"), 324 "--primary": colorToString(primary, "rgb"), 325 "--accent-1": colorToString(accent1, "rgb"), 326 "--accent-2": colorToString(accent2, "rgb"), 327 "--accent-contrast": colorToString(accentContrast, "rgb"), 328 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0, 329 "--highlight-1": highlight1 330 ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})` 331 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 332 "--highlight-2": colorToString(highlight2, "rgb"), 333 "--highlight-3": colorToString(highlight3, "rgb"), 334 "--page-width-setting": pageWidth || 624, 335 "--page-width-unitless": pageWidth || 624, 336 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 337 "--theme-heading-font": headingFontValue, 338 "--theme-font": bodyFontValue, 339 "--theme-font-base-size": bodyFontBaseSize ? `${bodyFontBaseSize}px` : undefined, 340 } as CSSProperties 341 } 342 > 343 {" "} 344 {children}{" "} 345 </div> 346 ); 347}; 348 349let CardThemeProviderContext = createContext<null | string>(null); 350export function NestedCardThemeProvider(props: { children: React.ReactNode }) { 351 let card = useContext(CardThemeProviderContext); 352 if (!card) return props.children; 353 return ( 354 <CardThemeProvider entityID={card}>{props.children}</CardThemeProvider> 355 ); 356} 357 358export function CardThemeProvider(props: { 359 entityID: string; 360 children: React.ReactNode; 361}) { 362 let bgPage = useColorAttributeNullable( 363 props.entityID, 364 "theme/card-background", 365 ); 366 let primary = useColorAttributeNullable(props.entityID, "theme/primary"); 367 let accent1 = useColorAttributeNullable( 368 props.entityID, 369 "theme/accent-background", 370 ); 371 let accent2 = useColorAttributeNullable(props.entityID, "theme/accent-text"); 372 let accentContrast = 373 bgPage && accent1 && accent2 374 ? [accent1, accent2].sort((a, b) => { 375 return ( 376 getColorDifference( 377 colorToString(b, "rgb"), 378 colorToString(bgPage, "rgb"), 379 ) - 380 getColorDifference( 381 colorToString(a, "rgb"), 382 colorToString(bgPage, "rgb"), 383 ) 384 ); 385 })[0] 386 : null; 387 388 return ( 389 <CardThemeProviderContext.Provider value={props.entityID}> 390 <div 391 className="contents text-primary" 392 style={ 393 { 394 "--accent-1": accent1 ? colorToString(accent1, "rgb") : undefined, 395 "--accent-2": accent2 ? colorToString(accent2, "rgb") : undefined, 396 "--accent-contrast": accentContrast 397 ? colorToString(accentContrast, "rgb") 398 : undefined, 399 "--bg-page": bgPage ? colorToString(bgPage, "rgb") : undefined, 400 "--bg-page-alpha": bgPage 401 ? bgPage.getChannelValue("alpha") 402 : undefined, 403 "--primary": primary ? colorToString(primary, "rgb") : undefined, 404 } as CSSProperties 405 } 406 > 407 {props.children} 408 </div> 409 </CardThemeProviderContext.Provider> 410 ); 411} 412 413// Wrapper within the Theme Wrapper that provides background image data 414export const ThemeBackgroundProvider = (props: { 415 entityID: string; 416 children: React.ReactNode; 417}) => { 418 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 419 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 420 let backgroundImageRepeat = useEntity( 421 props.entityID, 422 "theme/background-image-repeat", 423 ); 424 if (pub?.publications) { 425 return ( 426 <PublicationBackgroundProvider 427 pub_creator={pub?.publications.identity_did || ""} 428 theme={normalizedPublication?.theme} 429 > 430 {props.children} 431 </PublicationBackgroundProvider> 432 ); 433 } 434 return ( 435 <div 436 className="LeafletBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 437 style={ 438 { 439 backgroundImage: backgroundImage 440 ? `url(${backgroundImage?.data.src}), url(${backgroundImage?.data.fallback})` 441 : undefined, 442 backgroundPosition: "center", 443 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 444 backgroundSize: !backgroundImageRepeat 445 ? "cover" 446 : backgroundImageRepeat?.data.value, 447 } as CSSProperties 448 } 449 > 450 {props.children} 451 </div> 452 ); 453};