feat(core): add custom font family setting (#7924)

close AF-1255

https://github.com/user-attachments/assets/d44359b6-b75c-4883-a57b-1f226586feec
This commit is contained in:
JimmFly 2024-08-22 04:24:44 +00:00
parent b333cde336
commit 20174b9cbe
No known key found for this signature in database
GPG Key ID: 126E0320FEB0D05C
9 changed files with 327 additions and 80 deletions

View File

@ -22,6 +22,7 @@ export type AppSetting = {
fullWidthLayout: boolean;
windowFrameStyle: 'frameless' | 'NativeTitleBar';
fontStyle: FontFamily;
customFontFamily: string;
dateFormat: DateFormats;
startWeekOnMonday: boolean;
enableBlurBackground: boolean;
@ -45,12 +46,13 @@ export const dateFormatOptions: DateFormats[] = [
'dd MMMM YYYY',
];
export type FontFamily = 'Sans' | 'Serif' | 'Mono';
export type FontFamily = 'Sans' | 'Serif' | 'Mono' | 'Custom';
export const fontStyleOptions = [
{ key: 'Sans', value: 'var(--affine-font-sans-family)' },
{ key: 'Serif', value: 'var(--affine-font-serif-family)' },
{ key: 'Mono', value: 'var(--affine-font-mono-family)' },
{ key: 'Custom', value: 'var(--affine-font-sans-family)' },
] satisfies {
key: FontFamily;
value: string;
@ -61,6 +63,7 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
fullWidthLayout: false,
windowFrameStyle: 'frameless',
fontStyle: 'Sans',
customFontFamily: '',
dateFormat: dateFormatOptions[0],
startWeekOnMonday: false,
enableBlurBackground: true,

View File

@ -7,7 +7,7 @@ import {
} from '@affine/component/setting-components';
import { useI18n } from '@affine/i18n';
import type { AppSetting } from '@toeverything/infra';
import { fontStyleOptions, windowFrameStyleOptions } from '@toeverything/infra';
import { windowFrameStyleOptions } from '@toeverything/infra';
import { useTheme } from 'next-themes';
import { useCallback, useMemo } from 'react';
@ -58,45 +58,6 @@ export const ThemeSettings = () => {
);
};
const FontFamilySettings = () => {
const t = useI18n();
const { appSettings, updateSettings } = useAppSettingHelper();
const radioItems = useMemo(() => {
return fontStyleOptions.map(({ key, value }) => {
const label =
key === 'Mono'
? t[`com.affine.appearanceSettings.fontStyle.mono`]()
: key === 'Sans'
? t['com.affine.appearanceSettings.fontStyle.sans']()
: key === 'Serif'
? t['com.affine.appearanceSettings.fontStyle.serif']()
: '';
return {
value: key,
label,
testId: 'system-font-style-trigger',
style: { fontFamily: value },
} satisfies RadioItem;
});
}, [t]);
return (
<RadioGroup
items={radioItems}
value={appSettings.fontStyle}
width={250}
className={settingWrapper}
onChange={useCallback(
(value: AppSetting['fontStyle']) => {
updateSettings('fontStyle', value);
},
[updateSettings]
)}
/>
);
};
export const AppearanceSettings = () => {
const t = useI18n();
@ -116,12 +77,6 @@ export const AppearanceSettings = () => {
>
<ThemeSettings />
</SettingRow>
<SettingRow
name={t['com.affine.appearanceSettings.font.title']()}
desc={t['com.affine.appearanceSettings.font.description']()}
>
<FontFamilySettings />
</SettingRow>
<SettingRow
name={t['com.affine.appearanceSettings.language.title']()}
desc={t['com.affine.appearanceSettings.language.description']()}

View File

@ -1,9 +1,12 @@
import {
Loading,
Menu,
MenuItem,
MenuSeparator,
MenuTrigger,
RadioGroup,
type RadioItem,
Scrollable,
Switch,
} from '@affine/component';
import {
@ -11,38 +14,76 @@ import {
SettingWrapper,
} from '@affine/component/setting-components';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import {
type FontData,
SystemFontFamilyService,
} from '@affine/core/modules/system-font-family';
import { useI18n } from '@affine/i18n';
import {
type AppSetting,
type DocMode,
type FontFamily,
fontStyleOptions,
useLiveData,
useService,
} from '@toeverything/infra';
import { useCallback, useMemo, useState } from 'react';
import {
type ChangeEvent,
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { menu, menuTrigger, settingWrapper } from './style.css';
import { menu, menuTrigger, searchInput, settingWrapper } from './style.css';
const FontFamilySettings = () => {
const t = useI18n();
const { appSettings, updateSettings } = useAppSettingHelper();
const getLabel = useCallback(
(fontKey: FontFamily) => {
switch (fontKey) {
case 'Sans':
return t['com.affine.appearanceSettings.fontStyle.sans']();
case 'Serif':
return t['com.affine.appearanceSettings.fontStyle.serif']();
case 'Mono':
return t[`com.affine.appearanceSettings.fontStyle.mono`]();
case 'Custom':
return t['com.affine.settings.editorSettings.edgeless.custom']();
default:
return '';
}
},
[t]
);
const radioItems = useMemo(() => {
return fontStyleOptions.map(({ key, value }) => {
const label =
key === 'Mono'
? t[`com.affine.appearanceSettings.fontStyle.mono`]()
: key === 'Sans'
? t['com.affine.appearanceSettings.fontStyle.sans']()
: key === 'Serif'
? t['com.affine.appearanceSettings.fontStyle.serif']()
: '';
return {
value: key,
label,
testId: 'system-font-style-trigger',
style: { fontFamily: value },
} satisfies RadioItem;
});
}, [t]);
return fontStyleOptions
.map(({ key, value }) => {
if (key === 'Custom' && !environment.isDesktop) {
return null;
}
const label = getLabel(key);
let fontFamily = value;
if (key === 'Custom' && appSettings.customFontFamily) {
fontFamily = `${appSettings.customFontFamily}, ${value}`;
}
return {
value: key,
label,
testId: 'system-font-style-trigger',
style: {
fontFamily,
},
} satisfies RadioItem;
})
.filter(item => item !== null);
}, [appSettings.customFontFamily, getLabel]);
return (
<RadioGroup
@ -59,6 +100,151 @@ const FontFamilySettings = () => {
/>
);
};
const getFontFamily = (font: string) => `${font}, ${fontStyleOptions[0].value}`;
const Scroller = forwardRef<
HTMLDivElement,
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
>(({ children, ...props }, ref) => {
return (
<Scrollable.Root>
<Scrollable.Viewport {...props} ref={ref}>
{children}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
);
});
Scroller.displayName = 'Scroller';
const FontMenuItems = ({ onSelect }: { onSelect: (font: string) => void }) => {
const systemFontFamily = useService(SystemFontFamilyService).systemFontFamily;
useEffect(() => {
if (systemFontFamily.fontList$.value.length === 0) {
systemFontFamily.loadFontList();
}
systemFontFamily.clearSearch();
}, [systemFontFamily]);
const isLoading = useLiveData(systemFontFamily.isLoading$);
const result = useLiveData(systemFontFamily.result$);
const searchText = useLiveData(systemFontFamily.searchText$);
const onInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
systemFontFamily.search(e.target.value);
},
[systemFontFamily]
);
const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation(); // avoid typeahead search built-in in the menu
},
[]
);
return (
<div>
<input
value={searchText ?? ''}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
autoFocus
className={searchInput}
placeholder="Type here ..."
/>
<MenuSeparator />
{isLoading ? (
<Loading />
) : (
<Scrollable.Root style={{ height: '200px' }}>
<Scrollable.Viewport>
{result.length > 0 ? (
<Virtuoso
totalCount={result.length}
components={{
Scroller: Scroller,
}}
itemContent={index => (
<FontMenuItem
key={result[index].fullName}
font={result[index]}
onSelect={onSelect}
/>
)}
/>
) : (
<div>No font found</div>
)}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
)}
</div>
);
};
const FontMenuItem = ({
font,
onSelect,
}: {
font: FontData;
onSelect: (font: string) => void;
}) => {
const handleFontSelect = useCallback(
() => onSelect(font.fullName),
[font, onSelect]
);
const fontFamily = getFontFamily(font.family);
return (
<MenuItem
key={font.fullName}
onSelect={handleFontSelect}
style={{ fontFamily }}
>
{font.fullName}
</MenuItem>
);
};
const CustomFontFamilySettings = () => {
const t = useI18n();
const { appSettings, updateSettings } = useAppSettingHelper();
const fontFamily = getFontFamily(appSettings.customFontFamily);
const onCustomFontFamilyChange = useCallback(
(fontFamily: string) => {
updateSettings('customFontFamily', fontFamily);
},
[updateSettings]
);
if (appSettings.fontStyle !== 'Custom' || !environment.isDesktop) {
return null;
}
return (
<SettingRow
name={t[
'com.affine.settings.editorSettings.general.font-family.custom.title'
]()}
desc={t[
'com.affine.settings.editorSettings.general.font-family.custom.description'
]()}
>
<Menu
items={<FontMenuItems onSelect={onCustomFontFamilyChange} />}
contentOptions={{
align: 'end',
style: { width: '250px' },
}}
>
<MenuTrigger className={menuTrigger} style={{ fontFamily }}>
{appSettings.customFontFamily || 'Select a font'}
</MenuTrigger>
</Menu>
</SettingRow>
);
};
const NewDocDefaultModeSettings = () => {
const t = useI18n();
const [value, setValue] = useState<DocMode>('page');
@ -104,16 +290,7 @@ export const General = () => {
>
<FontFamilySettings />
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.general.font-family.custom.title'
]()}
desc={t[
'com.affine.settings.editorSettings.general.font-family.custom.description'
]()}
>
<Switch />
</SettingRow>
<CustomFontFamilySettings />
<SettingRow
name={t[
'com.affine.settings.editorSettings.general.font-family.title'

View File

@ -49,3 +49,18 @@ export const shapeIndicator = style({
boxShadow: 'none',
backgroundColor: cssVarV2('layer/background/tertiary'),
});
export const searchInput = style({
flexGrow: 1,
padding: '10px 0',
margin: '-10px 0',
border: 'none',
outline: 'none',
fontSize: cssVar('fontSm'),
fontFamily: 'inherit',
color: 'inherit',
backgroundColor: 'transparent',
'::placeholder': {
color: cssVarV2('text/placeholder'),
},
});

View File

@ -1,7 +1,7 @@
import './page-detail-editor.css';
import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspace-page';
import { assertExists, DisposableGroup } from '@blocksuite/global/utils';
import { DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { Doc as BlockSuiteDoc, DocCollection } from '@blocksuite/store';
import {
@ -10,6 +10,7 @@ import {
useLiveData,
useService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import type { CSSProperties } from 'react';
import { memo, Suspense, useCallback, useMemo } from 'react';
@ -56,9 +57,15 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
const fontStyle = fontStyleOptions.find(
option => option.key === appSettings.fontStyle
);
assertExists(fontStyle);
return fontStyle.value;
}, [appSettings.fontStyle]);
if (!fontStyle) {
return cssVar('fontSansFamily');
}
const customFontFamily = appSettings.customFontFamily;
return customFontFamily && fontStyle.key === 'Custom'
? `${customFontFamily}, ${fontStyle.value}`
: fontStyle.value;
}, [appSettings.customFontFamily, appSettings.fontStyle]);
const blockId = useRouterHash();

View File

@ -16,6 +16,7 @@ import { configurePermissionsModule } from './permissions';
import { configureWorkspacePropertiesModule } from './properties';
import { configureQuickSearchModule } from './quicksearch';
import { configureShareDocsModule } from './share-doc';
import { configureSystemFontFamilyModule } from './system-font-family';
import { configureTagModule } from './tag';
import { configureTelemetryModule } from './telemetry';
import { configureThemeEditorModule } from './theme-editor';
@ -41,4 +42,5 @@ export function configureCommonModules(framework: Framework) {
configureExplorerModule(framework);
configureThemeEditorModule(framework);
configureEditorModule(framework);
configureSystemFontFamilyModule(framework);
}

View File

@ -0,0 +1,69 @@
import {
effect,
Entity,
fromPromise,
LiveData,
mapInto,
onComplete,
onStart,
} from '@toeverything/infra';
import { exhaustMap } from 'rxjs';
export type FontData = {
family: string;
fullName: string;
postscriptName: string;
style: string;
};
export class SystemFontFamily extends Entity {
constructor() {
super();
}
readonly searchText$ = new LiveData<string | null>(null);
readonly isLoading$ = new LiveData<boolean>(false);
readonly fontList$ = new LiveData<FontData[]>([]);
readonly result$ = LiveData.computed(get => {
const fontList = get(this.fontList$);
const searchText = get(this.searchText$);
if (!searchText) {
return fontList;
}
const filteredFonts = fontList.filter(font =>
font.fullName.toLowerCase().includes(searchText.toLowerCase())
);
return filteredFonts;
}).throttleTime(500);
loadFontList = effect(
exhaustMap(() => {
return fromPromise(async () => {
if (!(window as any).queryLocalFonts) {
return [];
}
const fonts = await (window as any).queryLocalFonts();
return fonts;
}).pipe(
mapInto(this.fontList$),
// TODO: catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.next(true);
}),
onComplete(() => {
this.isLoading$.next(false);
})
);
})
);
search(searchText: string) {
this.searchText$.next(searchText);
}
clearSearch() {
this.searchText$.next(null);
}
}

View File

@ -0,0 +1,11 @@
import type { Framework } from '@toeverything/infra';
import { SystemFontFamily } from './entities/system-font-family';
import { SystemFontFamilyService } from './services/system-font-family';
export type { FontData } from './entities/system-font-family';
export { SystemFontFamilyService } from './services/system-font-family';
export function configureSystemFontFamilyModule(framework: Framework) {
framework.service(SystemFontFamilyService).entity(SystemFontFamily);
}

View File

@ -0,0 +1,8 @@
import { Service } from '@toeverything/infra';
import { SystemFontFamily } from '../entities/system-font-family';
export class SystemFontFamilyService extends Service {
public readonly systemFontFamily =
this.framework.createEntity(SystemFontFamily);
}