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