From 83049703cc02efb4c5a4f94ad3dbfb43c50c07a3 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 30 Jan 2026 18:09:40 -0500 Subject: [PATCH] wip support custom google fonts --- .../ThemeManager/Pickers/TextPickers.tsx | 155 ++++++++++++----- .../ThemeManager/PubPickers/PubFontPicker.tsx | 143 ++++++++++++---- src/fonts.ts | 157 ++++++++++++++---- 3 files changed, 350 insertions(+), 105 deletions(-) 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 ( - { - setSearchValue(e.currentTarget.value); - }} - /> -
-
- {filteredFonts.map((fontOption) => { - return ( - { - rep?.mutate.assertFact({ - entity: props.entityID, - attribute: props.attribute, - data: { type: "string", value: fontOption.id }, - }); + {showCustomInput ? ( +
+
+ Paste a Google Fonts URL or font name +
+ setCustomFontValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCustomSubmit(); + } else if (e.key === "Escape") { + setShowCustomInput(false); + setCustomFontValue(""); + } + }} + /> +
+ + +
+
+ ) : ( +
+ {fontList.map((fontOption) => { + return ( + { + rep?.mutate.assertFact({ + entity: props.entityID, + attribute: props.attribute, + data: { type: "string", value: fontOption.id }, + }); + }} + font={fontOption} + selected={fontOption.id === fontId} + /> + ); + })} + {isCustom && ( + {}} + font={font} + selected={true} /> - ); - })} -
+ )} +
+ { + e.preventDefault(); + setShowCustomInput(true); + }} + className={` + fontOption + z-10 px-1 py-0.5 + text-left text-secondary + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary + hover:bg-border-light hover:text-secondary + outline-none + cursor-pointer + `} + > +
Custom Google Font...
+
+
+ )}
); }; 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 ( - { - setSearchValue(e.currentTarget.value); - }} - /> -
-
- {filteredFonts.map((fontOption) => { - return ( - { - props.onChange(fontOption.id); + {showCustomInput ? ( +
+
+ Paste a Google Fonts URL or font name +
+ setCustomFontValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCustomSubmit(); + } else if (e.key === "Escape") { + setShowCustomInput(false); + setCustomFontValue(""); + } + }} + /> +
+ + +
+
+ ) : ( +
+ {fontList.map((fontOption) => { + return ( + { + props.onChange(fontOption.id); + }} + font={fontOption} + selected={fontOption.id === fontId} + /> + ); + })} + {isCustom && ( + {}} + font={font} + selected={true} /> - ); - })} -
+ )} +
+ { + e.preventDefault(); + setShowCustomInput(true); + }} + className={` + fontOption + z-10 px-1 py-0.5 + text-left text-secondary + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary + hover:bg-border-light hover:text-secondary + outline-none + cursor-pointer + `} + > +
Custom Google Font...
+
+
+ )}
); }; 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, -- 2.51.2