mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-15 06:58:29 +03:00
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:
parent
b333cde336
commit
20174b9cbe
@ -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,
|
||||
|
@ -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']()}
|
||||
|
@ -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'
|
||||
|
@ -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'),
|
||||
},
|
||||
});
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
Loading…
Reference in New Issue
Block a user