11import {
2+ Loading ,
23 Menu ,
34 MenuItem ,
5+ MenuSeparator ,
46 MenuTrigger ,
57 RadioGroup ,
68 type RadioItem ,
9+ Scrollable ,
710 Switch ,
811} from '@affine/component' ;
912import {
1013 SettingRow ,
1114 SettingWrapper ,
1215} from '@affine/component/setting-components' ;
1316import { 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' ;
1421import { useI18n } from '@affine/i18n' ;
1522import {
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
2444const 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+ } ;
62248const 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'
0 commit comments