Initial commit
This commit is contained in:
163
editor/theme/ThemeProvider.tsx
Normal file
163
editor/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
export type ThemeProps = {
|
||||
headerFont?: string;
|
||||
bodyFont?: string;
|
||||
primaryColor?: string;
|
||||
primaryForegroundColor?: string;
|
||||
accentColor?: string;
|
||||
bgColor?: string;
|
||||
fgColor?: string;
|
||||
mutedColor?: string;
|
||||
mutedForegroundColor?: string;
|
||||
borderColor?: string;
|
||||
roundedness?: "none" | "sm" | "md" | "lg" | "xl" | "full";
|
||||
shadowLevel?: "none" | "sm" | "md" | "lg" | "xl";
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
};
|
||||
|
||||
const radiusMap: Record<NonNullable<ThemeProps["roundedness"]>, string> = {
|
||||
none: "0px",
|
||||
sm: "0.25rem",
|
||||
md: "0.5rem",
|
||||
lg: "0.75rem",
|
||||
xl: "1rem",
|
||||
full: "9999px",
|
||||
};
|
||||
|
||||
const shadowMap: Record<NonNullable<ThemeProps["shadowLevel"]>, string> = {
|
||||
none: "0 0 #0000",
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)",
|
||||
xl: "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)",
|
||||
};
|
||||
|
||||
function googleFontsHref(headerFont?: string, bodyFont?: string): string | null {
|
||||
const fonts = [headerFont, bodyFont].filter(
|
||||
(f): f is string => !!f && f !== "system-ui"
|
||||
);
|
||||
if (fonts.length === 0) return null;
|
||||
const families = Array.from(new Set(fonts))
|
||||
.map((f) => `family=${encodeURIComponent(f)}:wght@400;500;600;700`)
|
||||
.join("&");
|
||||
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
headerFont,
|
||||
bodyFont,
|
||||
primaryColor,
|
||||
primaryForegroundColor,
|
||||
accentColor,
|
||||
bgColor,
|
||||
fgColor,
|
||||
mutedColor,
|
||||
mutedForegroundColor,
|
||||
borderColor,
|
||||
roundedness,
|
||||
shadowLevel,
|
||||
children,
|
||||
}: ThemeProps & { children?: React.ReactNode }) {
|
||||
// Recompute CSS-variable map only when a relevant prop changes.
|
||||
const cssVars = useMemo<Record<string, string>>(() => {
|
||||
const vars: Record<string, string> = {};
|
||||
if (primaryColor) vars["--primary"] = primaryColor;
|
||||
if (primaryForegroundColor) vars["--primary-foreground"] = primaryForegroundColor;
|
||||
if (accentColor) vars["--accent"] = accentColor;
|
||||
if (bgColor) vars["--background"] = bgColor;
|
||||
if (fgColor) vars["--foreground"] = fgColor;
|
||||
if (mutedColor) vars["--muted"] = mutedColor;
|
||||
if (mutedForegroundColor) vars["--muted-foreground"] = mutedForegroundColor;
|
||||
if (borderColor) vars["--border"] = borderColor;
|
||||
if (roundedness) vars["--radius"] = radiusMap[roundedness];
|
||||
if (shadowLevel) vars["--shadow"] = shadowMap[shadowLevel];
|
||||
if (headerFont) vars["--font-header"] = `"${headerFont}", system-ui, sans-serif`;
|
||||
if (bodyFont) vars["--font-body"] = `"${bodyFont}", system-ui, sans-serif`;
|
||||
return vars;
|
||||
}, [
|
||||
headerFont,
|
||||
bodyFont,
|
||||
primaryColor,
|
||||
primaryForegroundColor,
|
||||
accentColor,
|
||||
bgColor,
|
||||
fgColor,
|
||||
mutedColor,
|
||||
mutedForegroundColor,
|
||||
borderColor,
|
||||
roundedness,
|
||||
shadowLevel,
|
||||
]);
|
||||
|
||||
// Imperatively push every CSS var onto :root inside the host document
|
||||
// (which is the iframe's document for the editor preview, and the page
|
||||
// <html> for the published render). This guarantees descendants pick up
|
||||
// updates even if React's style-prop diffing missed something or the
|
||||
// base-layer rules need access via :root inheritance.
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const doc =
|
||||
rootRef.current?.ownerDocument ??
|
||||
(typeof document !== "undefined" ? document : null);
|
||||
if (!doc) return;
|
||||
const target = doc.documentElement;
|
||||
const previous: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(cssVars)) {
|
||||
previous[key] = target.style.getPropertyValue(key);
|
||||
target.style.setProperty(key, value);
|
||||
}
|
||||
return () => {
|
||||
// Restore prior values so unmount doesn't leak our overrides.
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value) target.style.setProperty(key, value);
|
||||
else target.style.removeProperty(key);
|
||||
}
|
||||
};
|
||||
}, [cssVars]);
|
||||
|
||||
const fontsHref = useMemo(
|
||||
() => googleFontsHref(headerFont, bodyFont),
|
||||
[headerFont, bodyFont],
|
||||
);
|
||||
|
||||
// Plain CSS rules — applied directly, no Tailwind CDN runtime needed.
|
||||
// Tailwind preflight resets h1..h6 to font-family: inherit, which would
|
||||
// make headings pick up the body font. We override that here using the
|
||||
// `--font-header` CSS var ThemeProvider sets per-page.
|
||||
const css = `
|
||||
body, p, span, a, li, button, input, textarea, select {
|
||||
font-family: var(--font-body), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-header), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
`;
|
||||
|
||||
// Tailwind theme directives — only useful if/when the CDN compiles
|
||||
// font-heading / font-body utilities. The plain CSS above handles the
|
||||
// common case so headers always render with the right font.
|
||||
const tailwindCss = `
|
||||
@theme {
|
||||
--font-family-heading: var(--font-header), system-ui, -apple-system, sans-serif;
|
||||
--font-family-body: var(--font-body), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{fontsHref ? <link rel="stylesheet" href={fontsHref} /> : null}
|
||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||
<style type="text/tailwindcss" dangerouslySetInnerHTML={{ __html: tailwindCss }} />
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-theme-root
|
||||
style={cssVars as React.CSSProperties}
|
||||
className="flex min-h-screen flex-col bg-background text-foreground"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
editor/theme/Typography.tsx
Normal file
94
editor/theme/Typography.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
export type TypographyVariant =
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "subtitle1"
|
||||
| "subtitle2"
|
||||
| "body1"
|
||||
| "body2"
|
||||
| "caption";
|
||||
|
||||
const sizeClasses: Record<TypographyVariant, string> = {
|
||||
h1: "text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tight leading-[1.05]",
|
||||
h2: "text-4xl md:text-5xl font-semibold tracking-tight leading-[1.1]",
|
||||
h3: "text-3xl md:text-4xl font-semibold tracking-tight leading-tight",
|
||||
h4: "text-2xl md:text-3xl font-semibold tracking-tight leading-snug",
|
||||
h5: "text-xl md:text-2xl font-semibold leading-snug",
|
||||
h6: "text-lg md:text-xl font-semibold leading-snug",
|
||||
subtitle1: "text-lg md:text-xl leading-relaxed text-muted-foreground",
|
||||
subtitle2: "text-base md:text-lg leading-relaxed text-muted-foreground",
|
||||
body1: "text-lg leading-relaxed",
|
||||
body2: "text-base leading-relaxed",
|
||||
caption: "text-sm leading-relaxed text-muted-foreground",
|
||||
};
|
||||
|
||||
// Inline-style fallbacks so headings size correctly even when Tailwind
|
||||
// preflight resets <h1>..<h6> to font-size: inherit and the iframe CDN
|
||||
// hasn't compiled utility classes yet. The Tailwind classes above still
|
||||
// apply on top once available (responsive breakpoints, leading, etc.).
|
||||
const sizeStyles: Record<TypographyVariant, React.CSSProperties> = {
|
||||
h1: { fontSize: "clamp(2.5rem, 6vw, 4.5rem)", lineHeight: 1.05, fontWeight: 600, letterSpacing: "-0.02em" },
|
||||
h2: { fontSize: "clamp(2rem, 4vw, 3rem)", lineHeight: 1.1, fontWeight: 600, letterSpacing: "-0.02em" },
|
||||
h3: { fontSize: "clamp(1.75rem, 3.5vw, 2.25rem)", lineHeight: 1.15, fontWeight: 600, letterSpacing: "-0.015em" },
|
||||
h4: { fontSize: "clamp(1.5rem, 3vw, 1.875rem)", lineHeight: 1.2, fontWeight: 600, letterSpacing: "-0.01em" },
|
||||
h5: { fontSize: "1.5rem", lineHeight: 1.25, fontWeight: 600 },
|
||||
h6: { fontSize: "1.25rem", lineHeight: 1.3, fontWeight: 600 },
|
||||
subtitle1: { fontSize: "1.125rem", lineHeight: 1.6 },
|
||||
subtitle2: { fontSize: "1rem", lineHeight: 1.6 },
|
||||
body1: { fontSize: "1.125rem", lineHeight: 1.6 },
|
||||
body2: { fontSize: "1rem", lineHeight: 1.6 },
|
||||
caption: { fontSize: "0.875rem", lineHeight: 1.5 },
|
||||
};
|
||||
|
||||
const defaultTag: Record<TypographyVariant, keyof JSX.IntrinsicElements> = {
|
||||
h1: "h1",
|
||||
h2: "h2",
|
||||
h3: "h3",
|
||||
h4: "h4",
|
||||
h5: "h5",
|
||||
h6: "h6",
|
||||
subtitle1: "p",
|
||||
subtitle2: "p",
|
||||
body1: "p",
|
||||
body2: "p",
|
||||
caption: "p",
|
||||
};
|
||||
|
||||
type Props<C extends keyof JSX.IntrinsicElements = "p"> = {
|
||||
variant: TypographyVariant;
|
||||
as?: C;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
} & Omit<React.ComponentPropsWithoutRef<C>, "className" | "children" | "style">;
|
||||
|
||||
export function Typography<C extends keyof JSX.IntrinsicElements = "p">({
|
||||
variant,
|
||||
as,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: Props<C>) {
|
||||
const Tag = (as ?? defaultTag[variant]) as keyof JSX.IntrinsicElements;
|
||||
const isHeading = variant.startsWith("h");
|
||||
const fontClass = isHeading ? "font-heading" : "font-body";
|
||||
|
||||
return React.createElement(
|
||||
Tag,
|
||||
{
|
||||
className: cn(fontClass, sizeClasses[variant], className),
|
||||
style: { ...sizeStyles[variant], ...style },
|
||||
...rest,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
export default Typography;
|
||||
Reference in New Issue
Block a user