a tool for shared writing and social publishing

wip support custom google fonts

+350 -105
+116 -39
components/ThemeManager/Pickers/TextPickers.tsx
··· 9 9 import { ColorPicker } from "./ColorPicker"; 10 10 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 11 11 import { useIsMobile } from "src/hooks/isMobile"; 12 - import { fonts, defaultFontId, FontConfig } from "src/fonts"; 12 + import { 13 + fonts, 14 + defaultFontId, 15 + FontConfig, 16 + isCustomFontId, 17 + parseGoogleFontInput, 18 + createCustomFontId, 19 + getFontConfig, 20 + } from "src/fonts"; 13 21 14 22 export const TextColorPicker = (props: { 15 23 openPicker: pickers; ··· 39 47 }) => { 40 48 let isMobile = useIsMobile(); 41 49 let { rep } = useReplicache(); 42 - let [searchValue, setSearchValue] = useState(""); 50 + let [showCustomInput, setShowCustomInput] = useState(false); 51 + let [customFontValue, setCustomFontValue] = useState(""); 43 52 let currentFont = useEntity(props.entityID, props.attribute); 44 53 let fontId = currentFont?.data.value || defaultFontId; 45 - let font = fonts[fontId] || fonts[defaultFontId]; 54 + let font = getFontConfig(fontId); 55 + let isCustom = isCustomFontId(fontId); 46 56 47 - let fontList = Object.values(fonts); 48 - let filteredFonts = fontList 49 - .filter((f) => { 50 - const matchesSearch = f.displayName 51 - .toLocaleLowerCase() 52 - .includes(searchValue.toLocaleLowerCase()); 53 - return matchesSearch; 54 - }) 55 - .sort((a, b) => { 56 - return a.displayName.localeCompare(b.displayName); 57 - }); 57 + let fontList = Object.values(fonts).sort((a, b) => 58 + a.displayName.localeCompare(b.displayName), 59 + ); 60 + 61 + const handleCustomSubmit = () => { 62 + const parsed = parseGoogleFontInput(customFontValue); 63 + if (parsed) { 64 + const customId = createCustomFontId( 65 + parsed.fontName, 66 + parsed.googleFontsFamily, 67 + ); 68 + rep?.mutate.assertFact({ 69 + entity: props.entityID, 70 + attribute: props.attribute, 71 + data: { type: "string", value: customId }, 72 + }); 73 + setShowCustomInput(false); 74 + setCustomFontValue(""); 75 + } 76 + }; 58 77 59 78 return ( 60 79 <Menu ··· 76 95 align="start" 77 96 className="w-[250px] !gap-0 !outline-none max-h-72 " 78 97 > 79 - <Input 80 - value={searchValue} 81 - className="px-3 pb-1 appearance-none !outline-none bg-transparent" 82 - placeholder="search..." 83 - onChange={(e) => { 84 - setSearchValue(e.currentTarget.value); 85 - }} 86 - /> 87 - <hr className="mx-2 border-border" /> 88 - <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 89 - {filteredFonts.map((fontOption) => { 90 - return ( 98 + {showCustomInput ? ( 99 + <div className="p-2 flex flex-col gap-2"> 100 + <div className="text-sm text-secondary"> 101 + Paste a Google Fonts URL or font name 102 + </div> 103 + <Input 104 + value={customFontValue} 105 + className="w-full" 106 + placeholder="e.g. Roboto or fonts.google.com/..." 107 + autoFocus 108 + onChange={(e) => setCustomFontValue(e.currentTarget.value)} 109 + onKeyDown={(e) => { 110 + if (e.key === "Enter") { 111 + e.preventDefault(); 112 + handleCustomSubmit(); 113 + } else if (e.key === "Escape") { 114 + setShowCustomInput(false); 115 + setCustomFontValue(""); 116 + } 117 + }} 118 + /> 119 + <div className="flex gap-2"> 120 + <button 121 + className="flex-1 px-2 py-1 text-sm rounded-md bg-accent-1 text-accent-2 hover:opacity-80" 122 + onClick={handleCustomSubmit} 123 + > 124 + Add Font 125 + </button> 126 + <button 127 + className="px-2 py-1 text-sm rounded-md text-secondary hover:bg-border-light" 128 + onClick={() => { 129 + setShowCustomInput(false); 130 + setCustomFontValue(""); 131 + }} 132 + > 133 + Cancel 134 + </button> 135 + </div> 136 + </div> 137 + ) : ( 138 + <div className="flex flex-col h-full overflow-auto gap-0 py-1"> 139 + {fontList.map((fontOption) => { 140 + return ( 141 + <FontOption 142 + key={fontOption.id} 143 + onSelect={() => { 144 + rep?.mutate.assertFact({ 145 + entity: props.entityID, 146 + attribute: props.attribute, 147 + data: { type: "string", value: fontOption.id }, 148 + }); 149 + }} 150 + font={fontOption} 151 + selected={fontOption.id === fontId} 152 + /> 153 + ); 154 + })} 155 + {isCustom && ( 91 156 <FontOption 92 - key={fontOption.id} 93 - onSelect={() => { 94 - rep?.mutate.assertFact({ 95 - entity: props.entityID, 96 - attribute: props.attribute, 97 - data: { type: "string", value: fontOption.id }, 98 - }); 99 - }} 100 - font={fontOption} 101 - selected={fontOption.id === fontId} 157 + key={fontId} 158 + onSelect={() => {}} 159 + font={font} 160 + selected={true} 102 161 /> 103 - ); 104 - })} 105 - </div> 162 + )} 163 + <hr className="mx-2 my-1 border-border" /> 164 + <DropdownMenu.Item 165 + onSelect={(e) => { 166 + e.preventDefault(); 167 + setShowCustomInput(true); 168 + }} 169 + className={` 170 + fontOption 171 + z-10 px-1 py-0.5 172 + text-left text-secondary 173 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 174 + hover:bg-border-light hover:text-secondary 175 + outline-none 176 + cursor-pointer 177 + `} 178 + > 179 + <div className="px-2 py-0 rounded-md">Custom Google Font...</div> 180 + </DropdownMenu.Item> 181 + </div> 182 + )} 106 183 </Menu> 107 184 ); 108 185 };
+108 -35
components/ThemeManager/PubPickers/PubFontPicker.tsx
··· 5 5 import { Input } from "components/Input"; 6 6 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 7 7 import { useIsMobile } from "src/hooks/isMobile"; 8 - import { fonts, defaultFontId, FontConfig } from "src/fonts"; 8 + import { 9 + fonts, 10 + defaultFontId, 11 + FontConfig, 12 + isCustomFontId, 13 + parseGoogleFontInput, 14 + createCustomFontId, 15 + getFontConfig, 16 + } from "src/fonts"; 9 17 10 18 export const PubFontPicker = (props: { 11 19 label: string; ··· 13 21 onChange: (fontId: string) => void; 14 22 }) => { 15 23 let isMobile = useIsMobile(); 16 - let [searchValue, setSearchValue] = useState(""); 24 + let [showCustomInput, setShowCustomInput] = useState(false); 25 + let [customFontValue, setCustomFontValue] = useState(""); 17 26 let fontId = props.value || defaultFontId; 18 - let font = fonts[fontId] || fonts[defaultFontId]; 27 + let font = getFontConfig(fontId); 28 + let isCustom = isCustomFontId(fontId); 19 29 20 - let fontList = Object.values(fonts); 21 - let filteredFonts = fontList 22 - .filter((f) => { 23 - const matchesSearch = f.displayName 24 - .toLocaleLowerCase() 25 - .includes(searchValue.toLocaleLowerCase()); 26 - return matchesSearch; 27 - }) 28 - .sort((a, b) => { 29 - return a.displayName.localeCompare(b.displayName); 30 - }); 30 + let fontList = Object.values(fonts).sort((a, b) => 31 + a.displayName.localeCompare(b.displayName), 32 + ); 33 + 34 + const handleCustomSubmit = () => { 35 + const parsed = parseGoogleFontInput(customFontValue); 36 + if (parsed) { 37 + const customId = createCustomFontId( 38 + parsed.fontName, 39 + parsed.googleFontsFamily, 40 + ); 41 + props.onChange(customId); 42 + setShowCustomInput(false); 43 + setCustomFontValue(""); 44 + } 45 + }; 31 46 32 47 return ( 33 48 <Menu ··· 49 64 align="start" 50 65 className="w-[250px] !gap-0 !outline-none max-h-72 " 51 66 > 52 - <Input 53 - value={searchValue} 54 - className="px-3 pb-1 appearance-none !outline-none bg-transparent" 55 - placeholder="search..." 56 - onChange={(e) => { 57 - setSearchValue(e.currentTarget.value); 58 - }} 59 - /> 60 - <hr className="mx-2 border-border" /> 61 - <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 62 - {filteredFonts.map((fontOption) => { 63 - return ( 67 + {showCustomInput ? ( 68 + <div className="p-2 flex flex-col gap-2"> 69 + <div className="text-sm text-secondary"> 70 + Paste a Google Fonts URL or font name 71 + </div> 72 + <Input 73 + value={customFontValue} 74 + className="w-full" 75 + placeholder="e.g. Roboto or fonts.google.com/..." 76 + autoFocus 77 + onChange={(e) => setCustomFontValue(e.currentTarget.value)} 78 + onKeyDown={(e) => { 79 + if (e.key === "Enter") { 80 + e.preventDefault(); 81 + handleCustomSubmit(); 82 + } else if (e.key === "Escape") { 83 + setShowCustomInput(false); 84 + setCustomFontValue(""); 85 + } 86 + }} 87 + /> 88 + <div className="flex gap-2"> 89 + <button 90 + className="flex-1 px-2 py-1 text-sm rounded-md bg-accent-1 text-accent-2 hover:opacity-80" 91 + onClick={handleCustomSubmit} 92 + > 93 + Add Font 94 + </button> 95 + <button 96 + className="px-2 py-1 text-sm rounded-md text-secondary hover:bg-border-light" 97 + onClick={() => { 98 + setShowCustomInput(false); 99 + setCustomFontValue(""); 100 + }} 101 + > 102 + Cancel 103 + </button> 104 + </div> 105 + </div> 106 + ) : ( 107 + <div className="flex flex-col h-full overflow-auto gap-0 py-1"> 108 + {fontList.map((fontOption) => { 109 + return ( 110 + <FontOption 111 + key={fontOption.id} 112 + onSelect={() => { 113 + props.onChange(fontOption.id); 114 + }} 115 + font={fontOption} 116 + selected={fontOption.id === fontId} 117 + /> 118 + ); 119 + })} 120 + {isCustom && ( 64 121 <FontOption 65 - key={fontOption.id} 66 - onSelect={() => { 67 - props.onChange(fontOption.id); 68 - }} 69 - font={fontOption} 70 - selected={fontOption.id === fontId} 122 + key={fontId} 123 + onSelect={() => {}} 124 + font={font} 125 + selected={true} 71 126 /> 72 - ); 73 - })} 74 - </div> 127 + )} 128 + <hr className="mx-2 my-1 border-border" /> 129 + <DropdownMenu.Item 130 + onSelect={(e) => { 131 + e.preventDefault(); 132 + setShowCustomInput(true); 133 + }} 134 + className={` 135 + fontOption 136 + z-10 px-1 py-0.5 137 + text-left text-secondary 138 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 139 + hover:bg-border-light hover:text-secondary 140 + outline-none 141 + cursor-pointer 142 + `} 143 + > 144 + <div className="px-2 py-0 rounded-md">Custom Google Font...</div> 145 + </DropdownMenu.Item> 146 + </div> 147 + )} 75 148 </Menu> 76 149 ); 77 150 };
+126 -31
src/fonts.ts
··· 35 35 fontFamily: "iA Writer Quattro V", 36 36 type: "local", 37 37 files: [ 38 - { path: "/fonts/iaw-quattro-vf.woff2", style: "normal", weight: "400 700" }, 39 - { path: "/fonts/iaw-quattro-vf-Italic.woff2", style: "italic", weight: "400 700" }, 38 + { 39 + path: "/fonts/iaw-quattro-vf.woff2", 40 + style: "normal", 41 + weight: "400 700", 42 + }, 43 + { 44 + path: "/fonts/iaw-quattro-vf-Italic.woff2", 45 + style: "italic", 46 + weight: "400 700", 47 + }, 40 48 ], 41 49 fallback: ["system-ui", "sans-serif"], 42 50 }, ··· 46 54 fontFamily: "Lora", 47 55 type: "local", 48 56 files: [ 49 - { path: "/fonts/Lora-Variable.woff2", style: "normal", weight: "400 700" }, 50 - { path: "/fonts/Lora-Italic-Variable.woff2", style: "italic", weight: "400 700" }, 57 + { 58 + path: "/fonts/Lora-Variable.woff2", 59 + style: "normal", 60 + weight: "400 700", 61 + }, 62 + { 63 + path: "/fonts/Lora-Italic-Variable.woff2", 64 + style: "italic", 65 + weight: "400 700", 66 + }, 51 67 ], 52 68 fallback: ["Georgia", "serif"], 53 69 }, ··· 57 73 fontFamily: "Source Sans 3", 58 74 type: "local", 59 75 files: [ 60 - { path: "/fonts/SourceSans3-Variable.woff2", style: "normal", weight: "200 900" }, 61 - { path: "/fonts/SourceSans3-Italic-Variable.woff2", style: "italic", weight: "200 900" }, 76 + { 77 + path: "/fonts/SourceSans3-Variable.woff2", 78 + style: "normal", 79 + weight: "200 900", 80 + }, 81 + { 82 + path: "/fonts/SourceSans3-Italic-Variable.woff2", 83 + style: "italic", 84 + weight: "200 900", 85 + }, 62 86 ], 63 87 fallback: ["system-ui", "sans-serif"], 64 88 }, ··· 68 92 fontFamily: "Atkinson Hyperlegible Next", 69 93 type: "local", 70 94 files: [ 71 - { path: "/fonts/AtkinsonHyperlegibleNext-Variable.woff2", style: "normal", weight: "200 800" }, 72 - { path: "/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2", style: "italic", weight: "200 800" }, 95 + { 96 + path: "/fonts/AtkinsonHyperlegibleNext-Variable.woff2", 97 + style: "normal", 98 + weight: "200 800", 99 + }, 100 + { 101 + path: "/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2", 102 + style: "italic", 103 + weight: "200 800", 104 + }, 73 105 ], 74 106 fallback: ["system-ui", "sans-serif"], 75 107 }, 76 - "noto-sans": { 77 - id: "noto-sans", 78 - displayName: "Noto Sans", 79 - fontFamily: "Noto Sans", 80 - type: "local", 81 - files: [ 82 - { path: "/fonts/NotoSans-Variable.woff2", style: "normal", weight: "100 900" }, 83 - { path: "/fonts/NotoSans-Italic-Variable.woff2", style: "italic", weight: "100 900" }, 84 - ], 85 - fallback: ["Arial", "sans-serif"], 86 - }, 87 - 88 - // Google Fonts (no variable version available) 89 - "alegreya-sans": { 90 - id: "alegreya-sans", 91 - displayName: "Alegreya Sans", 92 - fontFamily: "Alegreya Sans", 93 - type: "google", 94 - googleFontsFamily: "Alegreya+Sans:ital,wght@0,400;0,700;1,400;1,700", 95 - fallback: ["system-ui", "sans-serif"], 96 - }, 97 108 "space-mono": { 98 109 id: "space-mono", 99 110 displayName: "Space Mono", ··· 106 117 107 118 export const defaultFontId = "quattro"; 108 119 120 + // Parse a Google Fonts URL or string to extract the font name and family parameter 121 + // Supports various formats: 122 + // - Full URL: https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap 123 + // - Family param: Open+Sans:ital,wght@0,400;0,700 124 + // - Just font name: Open Sans 125 + export function parseGoogleFontInput(input: string): { 126 + fontName: string; 127 + googleFontsFamily: string; 128 + } | null { 129 + const trimmed = input.trim(); 130 + if (!trimmed) return null; 131 + 132 + // Try to parse as full URL 133 + try { 134 + const url = new URL(trimmed); 135 + const family = url.searchParams.get("family"); 136 + if (family) { 137 + // Extract font name from family param (before the colon if present) 138 + const fontName = family.split(":")[0].replace(/\+/g, " "); 139 + return { fontName, googleFontsFamily: family }; 140 + } 141 + } catch { 142 + // Not a valid URL, continue with other parsing 143 + } 144 + 145 + // Check if it's a family parameter with weight/style specifiers (contains : or @) 146 + if (trimmed.includes(":") || trimmed.includes("@")) { 147 + const fontName = trimmed.split(":")[0].replace(/\+/g, " "); 148 + // Ensure plus signs are used for spaces in the family param 149 + const googleFontsFamily = trimmed.includes("+") 150 + ? trimmed 151 + : trimmed.replace(/ /g, "+"); 152 + return { fontName, googleFontsFamily }; 153 + } 154 + 155 + // Treat as just a font name - construct a basic family param with common weights 156 + const fontName = trimmed.replace(/\+/g, " "); 157 + const googleFontsFamily = `${trimmed.replace(/ /g, "+")}:wght@400;700`; 158 + return { fontName, googleFontsFamily }; 159 + } 160 + 161 + // Custom font ID format: "custom:FontName:googleFontsFamily" 162 + export function createCustomFontId( 163 + fontName: string, 164 + googleFontsFamily: string, 165 + ): string { 166 + return `custom:${fontName}:${googleFontsFamily}`; 167 + } 168 + 169 + export function isCustomFontId(fontId: string): boolean { 170 + return fontId.startsWith("custom:"); 171 + } 172 + 173 + export function parseCustomFontId(fontId: string): { 174 + fontName: string; 175 + googleFontsFamily: string; 176 + } | null { 177 + if (!isCustomFontId(fontId)) return null; 178 + const parts = fontId.slice("custom:".length).split(":"); 179 + if (parts.length < 2) return null; 180 + const fontName = parts[0]; 181 + const googleFontsFamily = parts.slice(1).join(":"); 182 + return { fontName, googleFontsFamily }; 183 + } 184 + 109 185 export function getFontConfig(fontId: string | undefined): FontConfig { 110 - return fonts[fontId || defaultFontId] || fonts[defaultFontId]; 186 + if (!fontId) return fonts[defaultFontId]; 187 + 188 + // Check for custom font 189 + if (isCustomFontId(fontId)) { 190 + const parsed = parseCustomFontId(fontId); 191 + if (parsed) { 192 + return { 193 + id: fontId, 194 + displayName: parsed.fontName, 195 + fontFamily: parsed.fontName, 196 + type: "google", 197 + googleFontsFamily: parsed.googleFontsFamily, 198 + fallback: ["system-ui", "sans-serif"], 199 + }; 200 + } 201 + } 202 + 203 + return fonts[fontId] || fonts[defaultFontId]; 111 204 } 112 205 113 206 // Generate @font-face CSS for a local font ··· 129 222 } 130 223 131 224 // Generate preload link attributes for a local font 132 - export function getFontPreloadLinks(font: FontConfig): { href: string; type: string }[] { 225 + export function getFontPreloadLinks( 226 + font: FontConfig, 227 + ): { href: string; type: string }[] { 133 228 if (font.type !== "local") return []; 134 229 return font.files.map((file) => ({ 135 230 href: file.path,