Skip to content

Commit 20174b9

Browse files
committed
feat(core): add custom font family setting (#7924)
close AF-1255 https://github.com/user-attachments/assets/d44359b6-b75c-4883-a57b-1f226586feec
1 parent b333cde commit 20174b9

File tree

9 files changed

+327
-80
lines changed

9 files changed

+327
-80
lines changed

packages/common/infra/src/atom/settings.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type AppSetting = {
2222
fullWidthLayout: boolean;
2323
windowFrameStyle: 'frameless' | 'NativeTitleBar';
2424
fontStyle: FontFamily;
25+
customFontFamily: string;
2526
dateFormat: DateFormats;
2627
startWeekOnMonday: boolean;
2728
enableBlurBackground: boolean;
@@ -45,12 +46,13 @@ export const dateFormatOptions: DateFormats[] = [
4546
'dd MMMM YYYY',
4647
];
4748

48-
export type FontFamily = 'Sans' | 'Serif' | 'Mono';
49+
export type FontFamily = 'Sans' | 'Serif' | 'Mono' | 'Custom';
4950

5051
export const fontStyleOptions = [
5152
{ key: 'Sans', value: 'var(--affine-font-sans-family)' },
5253
{ key: 'Serif', value: 'var(--affine-font-serif-family)' },
5354
{ key: 'Mono', value: 'var(--affine-font-mono-family)' },
55+
{ key: 'Custom', value: 'var(--affine-font-sans-family)' },
5456
] satisfies {
5557
key: FontFamily;
5658
value: string;
@@ -61,6 +63,7 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
6163
fullWidthLayout: false,
6264
windowFrameStyle: 'frameless',
6365
fontStyle: 'Sans',
66+
customFontFamily: '',
6467
dateFormat: dateFormatOptions[0],
6568
startWeekOnMonday: false,
6669
enableBlurBackground: true,

packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '@affine/component/setting-components';
88
import { useI18n } from '@affine/i18n';
99
import type { AppSetting } from '@toeverything/infra';
10-
import { fontStyleOptions, windowFrameStyleOptions } from '@toeverything/infra';
10+
import { windowFrameStyleOptions } from '@toeverything/infra';
1111
import { useTheme } from 'next-themes';
1212
import { useCallback, useMemo } from 'react';
1313

@@ -58,45 +58,6 @@ export const ThemeSettings = () => {
5858
);
5959
};
6060

61-
const FontFamilySettings = () => {
62-
const t = useI18n();
63-
const { appSettings, updateSettings } = useAppSettingHelper();
64-
65-
const radioItems = useMemo(() => {
66-
return fontStyleOptions.map(({ key, value }) => {
67-
const label =
68-
key === 'Mono'
69-
? t[`com.affine.appearanceSettings.fontStyle.mono`]()
70-
: key === 'Sans'
71-
? t['com.affine.appearanceSettings.fontStyle.sans']()
72-
: key === 'Serif'
73-
? t['com.affine.appearanceSettings.fontStyle.serif']()
74-
: '';
75-
return {
76-
value: key,
77-
label,
78-
testId: 'system-font-style-trigger',
79-
style: { fontFamily: value },
80-
} satisfies RadioItem;
81-
});
82-
}, [t]);
83-
84-
return (
85-
<RadioGroup
86-
items={radioItems}
87-
value={appSettings.fontStyle}
88-
width={250}
89-
className={settingWrapper}
90-
onChange={useCallback(
91-
(value: AppSetting['fontStyle']) => {
92-
updateSettings('fontStyle', value);
93-
},
94-
[updateSettings]
95-
)}
96-
/>
97-
);
98-
};
99-
10061
export const AppearanceSettings = () => {
10162
const t = useI18n();
10263

@@ -116,12 +77,6 @@ export const AppearanceSettings = () => {
11677
>
11778
<ThemeSettings />
11879
</SettingRow>
119-
<SettingRow
120-
name={t['com.affine.appearanceSettings.font.title']()}
121-
desc={t['com.affine.appearanceSettings.font.description']()}
122-
>
123-
<FontFamilySettings />
124-
</SettingRow>
12580
<SettingRow
12681
name={t['com.affine.appearanceSettings.language.title']()}
12782
desc={t['com.affine.appearanceSettings.language.description']()}

packages/frontend/core/src/components/affine/setting-modal/general-setting/editor/general.tsx

Lines changed: 206 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,89 @@
11
import {
2+
Loading,
23
Menu,
34
MenuItem,
5+
MenuSeparator,
46
MenuTrigger,
57
RadioGroup,
68
type RadioItem,
9+
Scrollable,
710
Switch,
811
} from '@affine/component';
912
import {
1013
SettingRow,
1114
SettingWrapper,
1215
} from '@affine/component/setting-components';
1316
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
17+
import {
18+
type FontData,
19+
SystemFontFamilyService,
20+
} from '@affine/core/modules/system-font-family';
1421
import { useI18n } from '@affine/i18n';
1522
import {
1623
type AppSetting,
1724
type DocMode,
25+
type FontFamily,
1826
fontStyleOptions,
27+
useLiveData,
28+
useService,
1929
} from '@toeverything/infra';
20-
import { useCallback, useMemo, useState } from 'react';
30+
import {
31+
type ChangeEvent,
32+
forwardRef,
33+
type HTMLAttributes,
34+
type PropsWithChildren,
35+
useCallback,
36+
useEffect,
37+
useMemo,
38+
useState,
39+
} from 'react';
40+
import { Virtuoso } from 'react-virtuoso';
2141

22-
import { menu, menuTrigger, settingWrapper } from './style.css';
42+
import { menu, menuTrigger, searchInput, settingWrapper } from './style.css';
2343

2444
const FontFamilySettings = () => {
2545
const t = useI18n();
2646
const { appSettings, updateSettings } = useAppSettingHelper();
47+
const getLabel = useCallback(
48+
(fontKey: FontFamily) => {
49+
switch (fontKey) {
50+
case 'Sans':
51+
return t['com.affine.appearanceSettings.fontStyle.sans']();
52+
case 'Serif':
53+
return t['com.affine.appearanceSettings.fontStyle.serif']();
54+
case 'Mono':
55+
return t[`com.affine.appearanceSettings.fontStyle.mono`]();
56+
case 'Custom':
57+
return t['com.affine.settings.editorSettings.edgeless.custom']();
58+
default:
59+
return '';
60+
}
61+
},
62+
[t]
63+
);
2764

2865
const radioItems = useMemo(() => {
29-
return fontStyleOptions.map(({ key, value }) => {
30-
const label =
31-
key === 'Mono'
32-
? t[`com.affine.appearanceSettings.fontStyle.mono`]()
33-
: key === 'Sans'
34-
? t['com.affine.appearanceSettings.fontStyle.sans']()
35-
: key === 'Serif'
36-
? t['com.affine.appearanceSettings.fontStyle.serif']()
37-
: '';
38-
return {
39-
value: key,
40-
label,
41-
testId: 'system-font-style-trigger',
42-
style: { fontFamily: value },
43-
} satisfies RadioItem;
44-
});
45-
}, [t]);
66+
return fontStyleOptions
67+
.map(({ key, value }) => {
68+
if (key === 'Custom' && !environment.isDesktop) {
69+
return null;
70+
}
71+
const label = getLabel(key);
72+
let fontFamily = value;
73+
if (key === 'Custom' && appSettings.customFontFamily) {
74+
fontFamily = `${appSettings.customFontFamily}, ${value}`;
75+
}
76+
return {
77+
value: key,
78+
label,
79+
testId: 'system-font-style-trigger',
80+
style: {
81+
fontFamily,
82+
},
83+
} satisfies RadioItem;
84+
})
85+
.filter(item => item !== null);
86+
}, [appSettings.customFontFamily, getLabel]);
4687

4788
return (
4889
<RadioGroup
@@ -59,6 +100,151 @@ const FontFamilySettings = () => {
59100
/>
60101
);
61102
};
103+
104+
const getFontFamily = (font: string) => `${font}, ${fontStyleOptions[0].value}`;
105+
106+
const Scroller = forwardRef<
107+
HTMLDivElement,
108+
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
109+
>(({ children, ...props }, ref) => {
110+
return (
111+
<Scrollable.Root>
112+
<Scrollable.Viewport {...props} ref={ref}>
113+
{children}
114+
</Scrollable.Viewport>
115+
<Scrollable.Scrollbar />
116+
</Scrollable.Root>
117+
);
118+
});
119+
120+
Scroller.displayName = 'Scroller';
121+
122+
const FontMenuItems = ({ onSelect }: { onSelect: (font: string) => void }) => {
123+
const systemFontFamily = useService(SystemFontFamilyService).systemFontFamily;
124+
useEffect(() => {
125+
if (systemFontFamily.fontList$.value.length === 0) {
126+
systemFontFamily.loadFontList();
127+
}
128+
systemFontFamily.clearSearch();
129+
}, [systemFontFamily]);
130+
131+
const isLoading = useLiveData(systemFontFamily.isLoading$);
132+
const result = useLiveData(systemFontFamily.result$);
133+
const searchText = useLiveData(systemFontFamily.searchText$);
134+
135+
const onInputChange = useCallback(
136+
(e: ChangeEvent<HTMLInputElement>) => {
137+
systemFontFamily.search(e.target.value);
138+
},
139+
[systemFontFamily]
140+
);
141+
const onInputKeyDown = useCallback(
142+
(e: React.KeyboardEvent<HTMLInputElement>) => {
143+
e.stopPropagation(); // avoid typeahead search built-in in the menu
144+
},
145+
[]
146+
);
147+
148+
return (
149+
<div>
150+
<input
151+
value={searchText ?? ''}
152+
onChange={onInputChange}
153+
onKeyDown={onInputKeyDown}
154+
autoFocus
155+
className={searchInput}
156+
placeholder="Type here ..."
157+
/>
158+
<MenuSeparator />
159+
{isLoading ? (
160+
<Loading />
161+
) : (
162+
<Scrollable.Root style={{ height: '200px' }}>
163+
<Scrollable.Viewport>
164+
{result.length > 0 ? (
165+
<Virtuoso
166+
totalCount={result.length}
167+
components={{
168+
Scroller: Scroller,
169+
}}
170+
itemContent={index => (
171+
<FontMenuItem
172+
key={result[index].fullName}
173+
font={result[index]}
174+
onSelect={onSelect}
175+
/>
176+
)}
177+
/>
178+
) : (
179+
<div>No font found</div>
180+
)}
181+
</Scrollable.Viewport>
182+
<Scrollable.Scrollbar />
183+
</Scrollable.Root>
184+
)}
185+
</div>
186+
);
187+
};
188+
189+
const FontMenuItem = ({
190+
font,
191+
onSelect,
192+
}: {
193+
font: FontData;
194+
onSelect: (font: string) => void;
195+
}) => {
196+
const handleFontSelect = useCallback(
197+
() => onSelect(font.fullName),
198+
[font, onSelect]
199+
);
200+
const fontFamily = getFontFamily(font.family);
201+
return (
202+
<MenuItem
203+
key={font.fullName}
204+
onSelect={handleFontSelect}
205+
style={{ fontFamily }}
206+
>
207+
{font.fullName}
208+
</MenuItem>
209+
);
210+
};
211+
212+
const CustomFontFamilySettings = () => {
213+
const t = useI18n();
214+
const { appSettings, updateSettings } = useAppSettingHelper();
215+
const fontFamily = getFontFamily(appSettings.customFontFamily);
216+
const onCustomFontFamilyChange = useCallback(
217+
(fontFamily: string) => {
218+
updateSettings('customFontFamily', fontFamily);
219+
},
220+
[updateSettings]
221+
);
222+
if (appSettings.fontStyle !== 'Custom' || !environment.isDesktop) {
223+
return null;
224+
}
225+
return (
226+
<SettingRow
227+
name={t[
228+
'com.affine.settings.editorSettings.general.font-family.custom.title'
229+
]()}
230+
desc={t[
231+
'com.affine.settings.editorSettings.general.font-family.custom.description'
232+
]()}
233+
>
234+
<Menu
235+
items={<FontMenuItems onSelect={onCustomFontFamilyChange} />}
236+
contentOptions={{
237+
align: 'end',
238+
style: { width: '250px' },
239+
}}
240+
>
241+
<MenuTrigger className={menuTrigger} style={{ fontFamily }}>
242+
{appSettings.customFontFamily || 'Select a font'}
243+
</MenuTrigger>
244+
</Menu>
245+
</SettingRow>
246+
);
247+
};
62248
const NewDocDefaultModeSettings = () => {
63249
const t = useI18n();
64250
const [value, setValue] = useState<DocMode>('page');
@@ -104,16 +290,7 @@ export const General = () => {
104290
>
105291
<FontFamilySettings />
106292
</SettingRow>
107-
<SettingRow
108-
name={t[
109-
'com.affine.settings.editorSettings.general.font-family.custom.title'
110-
]()}
111-
desc={t[
112-
'com.affine.settings.editorSettings.general.font-family.custom.description'
113-
]()}
114-
>
115-
<Switch />
116-
</SettingRow>
293+
<CustomFontFamilySettings />
117294
<SettingRow
118295
name={t[
119296
'com.affine.settings.editorSettings.general.font-family.title'

packages/frontend/core/src/components/affine/setting-modal/general-setting/editor/style.css.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,18 @@ export const shapeIndicator = style({
4949
boxShadow: 'none',
5050
backgroundColor: cssVarV2('layer/background/tertiary'),
5151
});
52+
53+
export const searchInput = style({
54+
flexGrow: 1,
55+
padding: '10px 0',
56+
margin: '-10px 0',
57+
border: 'none',
58+
outline: 'none',
59+
fontSize: cssVar('fontSm'),
60+
fontFamily: 'inherit',
61+
color: 'inherit',
62+
backgroundColor: 'transparent',
63+
'::placeholder': {
64+
color: cssVarV2('text/placeholder'),
65+
},
66+
});

0 commit comments

Comments
 (0)