diff --git a/components/ThemeManager/Pickers/TextPickers.tsx b/components/ThemeManager/Pickers/TextPickers.tsx
index 625fc6b7..2ef60298 100644
--- a/components/ThemeManager/Pickers/TextPickers.tsx
+++ b/components/ThemeManager/Pickers/TextPickers.tsx
@@ -9,7 +9,15 @@ import { pickers } from "../ThemeSetter";
import { ColorPicker } from "./ColorPicker";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useIsMobile } from "src/hooks/isMobile";
-import { fonts, defaultFontId, FontConfig } from "src/fonts";
+import {
+ fonts,
+ defaultFontId,
+ FontConfig,
+ isCustomFontId,
+ parseGoogleFontInput,
+ createCustomFontId,
+ getFontConfig,
+} from "src/fonts";
export const TextColorPicker = (props: {
openPicker: pickers;
@@ -39,22 +47,33 @@ export const FontPicker = (props: {
}) => {
let isMobile = useIsMobile();
let { rep } = useReplicache();
- let [searchValue, setSearchValue] = useState("");
+ let [showCustomInput, setShowCustomInput] = useState(false);
+ let [customFontValue, setCustomFontValue] = useState("");
let currentFont = useEntity(props.entityID, props.attribute);
let fontId = currentFont?.data.value || defaultFontId;
- let font = fonts[fontId] || fonts[defaultFontId];
+ let font = getFontConfig(fontId);
+ let isCustom = isCustomFontId(fontId);
- let fontList = Object.values(fonts);
- let filteredFonts = fontList
- .filter((f) => {
- const matchesSearch = f.displayName
- .toLocaleLowerCase()
- .includes(searchValue.toLocaleLowerCase());
- return matchesSearch;
- })
- .sort((a, b) => {
- return a.displayName.localeCompare(b.displayName);
- });
+ let fontList = Object.values(fonts).sort((a, b) =>
+ a.displayName.localeCompare(b.displayName),
+ );
+
+ const handleCustomSubmit = () => {
+ const parsed = parseGoogleFontInput(customFontValue);
+ if (parsed) {
+ const customId = createCustomFontId(
+ parsed.fontName,
+ parsed.googleFontsFamily,
+ );
+ rep?.mutate.assertFact({
+ entity: props.entityID,
+ attribute: props.attribute,
+ data: { type: "string", value: customId },
+ });
+ setShowCustomInput(false);
+ setCustomFontValue("");
+ }
+ };
return (
);
};
diff --git a/components/ThemeManager/PubPickers/PubFontPicker.tsx b/components/ThemeManager/PubPickers/PubFontPicker.tsx
index 5235e264..ce94853b 100644
--- a/components/ThemeManager/PubPickers/PubFontPicker.tsx
+++ b/components/ThemeManager/PubPickers/PubFontPicker.tsx
@@ -5,7 +5,15 @@ import { Menu } from "components/Menu";
import { Input } from "components/Input";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useIsMobile } from "src/hooks/isMobile";
-import { fonts, defaultFontId, FontConfig } from "src/fonts";
+import {
+ fonts,
+ defaultFontId,
+ FontConfig,
+ isCustomFontId,
+ parseGoogleFontInput,
+ createCustomFontId,
+ getFontConfig,
+} from "src/fonts";
export const PubFontPicker = (props: {
label: string;
@@ -13,21 +21,28 @@ export const PubFontPicker = (props: {
onChange: (fontId: string) => void;
}) => {
let isMobile = useIsMobile();
- let [searchValue, setSearchValue] = useState("");
+ let [showCustomInput, setShowCustomInput] = useState(false);
+ let [customFontValue, setCustomFontValue] = useState("");
let fontId = props.value || defaultFontId;
- let font = fonts[fontId] || fonts[defaultFontId];
+ let font = getFontConfig(fontId);
+ let isCustom = isCustomFontId(fontId);
- let fontList = Object.values(fonts);
- let filteredFonts = fontList
- .filter((f) => {
- const matchesSearch = f.displayName
- .toLocaleLowerCase()
- .includes(searchValue.toLocaleLowerCase());
- return matchesSearch;
- })
- .sort((a, b) => {
- return a.displayName.localeCompare(b.displayName);
- });
+ let fontList = Object.values(fonts).sort((a, b) =>
+ a.displayName.localeCompare(b.displayName),
+ );
+
+ const handleCustomSubmit = () => {
+ const parsed = parseGoogleFontInput(customFontValue);
+ if (parsed) {
+ const customId = createCustomFontId(
+ parsed.fontName,
+ parsed.googleFontsFamily,
+ );
+ props.onChange(customId);
+ setShowCustomInput(false);
+ setCustomFontValue("");
+ }
+ };
return (
);
};
diff --git a/src/fonts.ts b/src/fonts.ts
index 9842e2e5..787705f8 100644
--- a/src/fonts.ts
+++ b/src/fonts.ts
@@ -35,8 +35,16 @@ export const fonts: Record = {
fontFamily: "iA Writer Quattro V",
type: "local",
files: [
- { path: "/fonts/iaw-quattro-vf.woff2", style: "normal", weight: "400 700" },
- { path: "/fonts/iaw-quattro-vf-Italic.woff2", style: "italic", weight: "400 700" },
+ {
+ path: "/fonts/iaw-quattro-vf.woff2",
+ style: "normal",
+ weight: "400 700",
+ },
+ {
+ path: "/fonts/iaw-quattro-vf-Italic.woff2",
+ style: "italic",
+ weight: "400 700",
+ },
],
fallback: ["system-ui", "sans-serif"],
},
@@ -46,8 +54,16 @@ export const fonts: Record = {
fontFamily: "Lora",
type: "local",
files: [
- { path: "/fonts/Lora-Variable.woff2", style: "normal", weight: "400 700" },
- { path: "/fonts/Lora-Italic-Variable.woff2", style: "italic", weight: "400 700" },
+ {
+ path: "/fonts/Lora-Variable.woff2",
+ style: "normal",
+ weight: "400 700",
+ },
+ {
+ path: "/fonts/Lora-Italic-Variable.woff2",
+ style: "italic",
+ weight: "400 700",
+ },
],
fallback: ["Georgia", "serif"],
},
@@ -57,8 +73,16 @@ export const fonts: Record = {
fontFamily: "Source Sans 3",
type: "local",
files: [
- { path: "/fonts/SourceSans3-Variable.woff2", style: "normal", weight: "200 900" },
- { path: "/fonts/SourceSans3-Italic-Variable.woff2", style: "italic", weight: "200 900" },
+ {
+ path: "/fonts/SourceSans3-Variable.woff2",
+ style: "normal",
+ weight: "200 900",
+ },
+ {
+ path: "/fonts/SourceSans3-Italic-Variable.woff2",
+ style: "italic",
+ weight: "200 900",
+ },
],
fallback: ["system-ui", "sans-serif"],
},
@@ -68,32 +92,19 @@ export const fonts: Record = {
fontFamily: "Atkinson Hyperlegible Next",
type: "local",
files: [
- { path: "/fonts/AtkinsonHyperlegibleNext-Variable.woff2", style: "normal", weight: "200 800" },
- { path: "/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2", style: "italic", weight: "200 800" },
+ {
+ path: "/fonts/AtkinsonHyperlegibleNext-Variable.woff2",
+ style: "normal",
+ weight: "200 800",
+ },
+ {
+ path: "/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2",
+ style: "italic",
+ weight: "200 800",
+ },
],
fallback: ["system-ui", "sans-serif"],
},
- "noto-sans": {
- id: "noto-sans",
- displayName: "Noto Sans",
- fontFamily: "Noto Sans",
- type: "local",
- files: [
- { path: "/fonts/NotoSans-Variable.woff2", style: "normal", weight: "100 900" },
- { path: "/fonts/NotoSans-Italic-Variable.woff2", style: "italic", weight: "100 900" },
- ],
- fallback: ["Arial", "sans-serif"],
- },
-
- // Google Fonts (no variable version available)
- "alegreya-sans": {
- id: "alegreya-sans",
- displayName: "Alegreya Sans",
- fontFamily: "Alegreya Sans",
- type: "google",
- googleFontsFamily: "Alegreya+Sans:ital,wght@0,400;0,700;1,400;1,700",
- fallback: ["system-ui", "sans-serif"],
- },
"space-mono": {
id: "space-mono",
displayName: "Space Mono",
@@ -106,8 +117,90 @@ export const fonts: Record = {
export const defaultFontId = "quattro";
+// Parse a Google Fonts URL or string to extract the font name and family parameter
+// Supports various formats:
+// - Full URL: https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap
+// - Family param: Open+Sans:ital,wght@0,400;0,700
+// - Just font name: Open Sans
+export function parseGoogleFontInput(input: string): {
+ fontName: string;
+ googleFontsFamily: string;
+} | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ // Try to parse as full URL
+ try {
+ const url = new URL(trimmed);
+ const family = url.searchParams.get("family");
+ if (family) {
+ // Extract font name from family param (before the colon if present)
+ const fontName = family.split(":")[0].replace(/\+/g, " ");
+ return { fontName, googleFontsFamily: family };
+ }
+ } catch {
+ // Not a valid URL, continue with other parsing
+ }
+
+ // Check if it's a family parameter with weight/style specifiers (contains : or @)
+ if (trimmed.includes(":") || trimmed.includes("@")) {
+ const fontName = trimmed.split(":")[0].replace(/\+/g, " ");
+ // Ensure plus signs are used for spaces in the family param
+ const googleFontsFamily = trimmed.includes("+")
+ ? trimmed
+ : trimmed.replace(/ /g, "+");
+ return { fontName, googleFontsFamily };
+ }
+
+ // Treat as just a font name - construct a basic family param with common weights
+ const fontName = trimmed.replace(/\+/g, " ");
+ const googleFontsFamily = `${trimmed.replace(/ /g, "+")}:wght@400;700`;
+ return { fontName, googleFontsFamily };
+}
+
+// Custom font ID format: "custom:FontName:googleFontsFamily"
+export function createCustomFontId(
+ fontName: string,
+ googleFontsFamily: string,
+): string {
+ return `custom:${fontName}:${googleFontsFamily}`;
+}
+
+export function isCustomFontId(fontId: string): boolean {
+ return fontId.startsWith("custom:");
+}
+
+export function parseCustomFontId(fontId: string): {
+ fontName: string;
+ googleFontsFamily: string;
+} | null {
+ if (!isCustomFontId(fontId)) return null;
+ const parts = fontId.slice("custom:".length).split(":");
+ if (parts.length < 2) return null;
+ const fontName = parts[0];
+ const googleFontsFamily = parts.slice(1).join(":");
+ return { fontName, googleFontsFamily };
+}
+
export function getFontConfig(fontId: string | undefined): FontConfig {
- return fonts[fontId || defaultFontId] || fonts[defaultFontId];
+ if (!fontId) return fonts[defaultFontId];
+
+ // Check for custom font
+ if (isCustomFontId(fontId)) {
+ const parsed = parseCustomFontId(fontId);
+ if (parsed) {
+ return {
+ id: fontId,
+ displayName: parsed.fontName,
+ fontFamily: parsed.fontName,
+ type: "google",
+ googleFontsFamily: parsed.googleFontsFamily,
+ fallback: ["system-ui", "sans-serif"],
+ };
+ }
+ }
+
+ return fonts[fontId] || fonts[defaultFontId];
}
// Generate @font-face CSS for a local font
@@ -129,7 +222,9 @@ export function generateFontFaceCSS(font: FontConfig): string {
}
// Generate preload link attributes for a local font
-export function getFontPreloadLinks(font: FontConfig): { href: string; type: string }[] {
+export function getFontPreloadLinks(
+ font: FontConfig,
+): { href: string; type: string }[] {
if (font.type !== "local") return [];
return font.files.map((file) => ({
href: file.path,