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