Skip to content

Commit d9fd2eb

Browse files
Merge pull request #576 from riccardoperra/feat/font-experimental-system-api
feat: implement local system font picker for theme builder
2 parents 0dfd704 + 78ae2cf commit d9fd2eb

File tree

19 files changed

+980
-81
lines changed

19 files changed

+980
-81
lines changed

apps/codeimage/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
"@codemirror/search": "^6.4.0",
6363
"@codemirror/state": "^6.2.0",
6464
"@codemirror/view": "^6.11.0",
65-
"@codeui/kit": "^0.0.34",
65+
"@codeui/kit": "^0.0.36",
6666
"@floating-ui/core": "^1.2.2",
6767
"@floating-ui/dom": "^1.2.3",
6868
"@formatjs/intl-relativetimeformat": "11.1.4",

apps/codeimage/src/components/CustomEditor/CustomEditor.tsx

+7-6
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
lineNumbers,
2727
rectangularSelection,
2828
} from '@codemirror/view';
29-
import {SUPPORTED_FONTS} from '@core/configuration/font';
3029
import {createCodeMirror, createEditorReadonly} from 'solid-codemirror';
3130
import {
3231
createEffect,
@@ -67,8 +66,11 @@ interface CustomEditorProps {
6766
export default function CustomEditor(props: VoidProps<CustomEditorProps>) {
6867
const {themeArray: themes} = getThemeStore();
6968
const languages = SUPPORTED_LANGUAGES;
70-
const fonts = SUPPORTED_FONTS;
71-
const {state: editorState, canvasEditorEvents} = getRootEditorStore();
69+
const {
70+
state: editorState,
71+
canvasEditorEvents,
72+
computed: {selectedFont},
73+
} = getRootEditorStore();
7274
const {editor} = getActiveEditorStore();
7375
const selectedLanguage = createMemo(() =>
7476
languages.find(language => language.id === editor()?.languageId),
@@ -139,9 +141,8 @@ export default function CustomEditor(props: VoidProps<CustomEditorProps>) {
139141
});
140142

141143
const customFontExtension = (): Extension => {
142-
const fontName =
143-
fonts.find(({id}) => editorState.options.fontId === id)?.name ||
144-
fonts[0].name,
144+
const font = selectedFont();
145+
const fontName = font.name,
145146
fontWeight = editorState.options.fontWeight,
146147
enableLigatures = editorState.options.enableLigatures;
147148

apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx

+17-47
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import {getActiveEditorStore} from '@codeimage/store/editor/activeEditor';
77
import {dispatchUpdateTheme} from '@codeimage/store/effects/onThemeChange';
88
import {getThemeStore} from '@codeimage/store/theme/theme.store';
99
import {createSelectOptions, Select} from '@codeui/kit';
10-
import {SUPPORTED_FONTS} from '@core/configuration/font';
1110
import {getUmami} from '@core/constants/umami';
1211
import {DynamicSizedContainer} from '@ui/DynamicSizedContainer/DynamicSizedContainer';
1312
import {SegmentedField} from '@ui/SegmentedField/SegmentedField';
1413
import {SkeletonLine} from '@ui/Skeleton/Skeleton';
15-
import {createMemo, ParentComponent, Show} from 'solid-js';
14+
import {ParentComponent, Show} from 'solid-js';
1615
import {AppLocaleEntries} from '../../i18n';
16+
import {FontPicker} from './controls/FontPicker/FontPicker';
1717
import {PanelDivider} from './PanelDivider';
1818
import {PanelHeader} from './PanelHeader';
1919
import {PanelRow, TwoColumnPanelRow} from './PanelRow';
@@ -41,7 +41,7 @@ export const EditorStyleForm: ParentComponent = () => {
4141
const {
4242
state,
4343
actions: {setShowLineNumbers, setFontWeight, setFontId, setEnableLigatures},
44-
computed: {font},
44+
computed: {selectedFont},
4545
} = getRootEditorStore();
4646

4747
const languagesOptions = createSelectOptions(
@@ -80,26 +80,22 @@ export const EditorStyleForm: ParentComponent = () => {
8080
{key: 'label', valueKey: 'value'},
8181
);
8282

83-
const memoizedFontWeights = createMemo(() =>
84-
font().types.map(type => ({
83+
const fontWeightByFont = () => {
84+
const font = selectedFont();
85+
if (!font) {
86+
return [];
87+
}
88+
return font.types.map(type => ({
8589
label: type.name,
86-
value: type.weight as number,
87-
})),
88-
);
90+
value: type.weight,
91+
}));
92+
};
8993

90-
const fontWeightOptions = createSelectOptions(memoizedFontWeights, {
94+
const fontWeightOptions = createSelectOptions(fontWeightByFont, {
9195
key: 'label',
9296
valueKey: 'value',
9397
});
9498

95-
const fontOptions = createSelectOptions(
96-
SUPPORTED_FONTS.map(font => ({
97-
label: font.name,
98-
value: font.id,
99-
})),
100-
{key: 'label', valueKey: 'value'},
101-
);
102-
10399
return (
104100
<Show when={editor()}>
105101
{editor => (
@@ -216,40 +212,14 @@ export const EditorStyleForm: ParentComponent = () => {
216212
<DynamicSizedContainer>
217213
<PanelHeader label={t('frame.font')} />
218214

219-
<PanelRow for={'frameFontField'} label={t('frame.font')}>
215+
<PanelRow for={'aspectRatio'} label={t('frame.font')}>
220216
<TwoColumnPanelRow>
221217
<SuspenseEditorItem
222218
fallback={<SkeletonLine width={'100%'} height={'26px'} />}
223219
>
224-
<Select
225-
options={fontOptions.options()}
226-
{...fontOptions.props()}
227-
{...fontOptions.controlled(
228-
() => font().id,
229-
fontId => {
230-
setFontId(fontId ?? SUPPORTED_FONTS[0].id);
231-
if (
232-
!font()
233-
.types.map(type => type.weight as number)
234-
.includes(state.options.fontWeight)
235-
) {
236-
setFontWeight(font().types[0].weight);
237-
}
238-
},
239-
)}
240-
aria-label={'Font'}
241-
id={'frameFontField'}
242-
size={'xs'}
243-
itemLabel={itemLabelProps => (
244-
<span
245-
style={{
246-
'font-family': `${itemLabelProps.label}, monospace`,
247-
'font-size': '80%',
248-
}}
249-
>
250-
{itemLabelProps.label}
251-
</span>
252-
)}
220+
<FontPicker
221+
value={selectedFont()?.id}
222+
onChange={fontId => setFontId(fontId)}
253223
/>
254224
</SuspenseEditorItem>
255225
</TwoColumnPanelRow>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {textFieldStyles, themeVars} from '@codeimage/ui';
2+
import {responsiveStyle, themeTokens} from '@codeui/kit';
3+
import {createVar, style} from '@vanilla-extract/css';
4+
5+
export const input = style([
6+
textFieldStyles.baseField,
7+
{
8+
paddingLeft: themeVars.spacing['3'],
9+
paddingRight: themeVars.spacing['3'],
10+
flex: 1,
11+
justifyContent: 'space-between',
12+
userSelect: 'none',
13+
display: 'flex',
14+
alignItems: 'center',
15+
gap: themeVars.spacing['3'],
16+
},
17+
]);
18+
19+
export const inputValue = style({
20+
overflow: 'hidden',
21+
whiteSpace: 'nowrap',
22+
textOverflow: 'ellipsis',
23+
});
24+
25+
export const inputIcon = style({
26+
flexShrink: 0,
27+
});
28+
29+
export const fontListboxHeight = createVar();
30+
31+
export const fontPickerPopover = style([
32+
{
33+
width: '360px',
34+
maxWidth: '360px',
35+
vars: {
36+
[fontListboxHeight]: '350px',
37+
},
38+
},
39+
responsiveStyle({
40+
md: {
41+
maxWidth: 'initial',
42+
},
43+
}),
44+
]);
45+
46+
export const experimentalFlag = style({
47+
color: themeVars.dynamicColors.descriptionTextColor,
48+
fontSize: themeVars.fontSize.xs,
49+
});
50+
51+
export const centeredContent = style({
52+
width: '100%',
53+
height: fontListboxHeight,
54+
display: 'flex',
55+
flexDirection: 'column',
56+
gap: '1rem',
57+
alignItems: 'center',
58+
justifyContent: 'center',
59+
textAlign: 'center',
60+
});
61+
62+
export const virtualizedFontListboxWrapper = style({
63+
height: fontListboxHeight,
64+
});
65+
66+
export const virtualizedFontListbox = style({
67+
maxHeight: fontListboxHeight,
68+
overflow: 'auto',
69+
height: '100%',
70+
});
71+
72+
export const virtualizedFontListboxSearch = style({
73+
flex: 1,
74+
});
75+
76+
export const virtualizedFontListboxToolbar = style({
77+
display: 'flex',
78+
justifyContent: 'space-between',
79+
gap: themeTokens.spacing['2'],
80+
':first-child': {
81+
flex: 1,
82+
},
83+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {EditorConfigStore} from '@codeimage/store/editor/config.store';
2+
import {Box, FlexField, HStack, Text, VStack} from '@codeimage/ui';
3+
import {
4+
As,
5+
IconButton,
6+
icons,
7+
Listbox,
8+
Popover,
9+
PopoverContent,
10+
PopoverTrigger,
11+
} from '@codeui/kit';
12+
import {useModality} from '@core/hooks/isMobile';
13+
import {DynamicSizedContainer} from '@ui/DynamicSizedContainer/DynamicSizedContainer';
14+
import {
15+
ExperimentalFeatureTooltip,
16+
ExperimentalIcon,
17+
} from '@ui/ExperimentalFeatureTooltip/ExperimentalFeatureTooltip';
18+
import {SegmentedField} from '@ui/SegmentedField/SegmentedField';
19+
import {createSignal, Match, Switch} from 'solid-js';
20+
import {provideState} from 'statebuilder';
21+
import {CloseIcon} from '../../../Icons/CloseIcon';
22+
import * as styles from './FontPicker.css';
23+
import {createFontPickerListboxProps} from './FontPickerListbox';
24+
import {FontSystemPicker} from './FontSystemPicker';
25+
26+
interface FontPickerProps {
27+
value: string;
28+
onChange: (value: string) => void;
29+
}
30+
31+
type FontPickerModality = 'default' | 'system';
32+
33+
/**
34+
* @experimental
35+
*/
36+
export function FontPicker(props: FontPickerProps) {
37+
const [open, setOpen] = createSignal(false);
38+
const [mode, setMode] = createSignal<FontPickerModality>('default');
39+
const modality = useModality();
40+
const configState = provideState(EditorConfigStore);
41+
42+
const webListboxItems = () =>
43+
configState.get.fonts
44+
.filter(font => font.type === 'web')
45+
.map(font => ({
46+
label: font.name,
47+
value: font.id,
48+
}));
49+
50+
const webListboxProps = createFontPickerListboxProps({
51+
onEsc: () => setOpen(false),
52+
onChange: props.onChange,
53+
get value() {
54+
return props.value;
55+
},
56+
get items() {
57+
return webListboxItems();
58+
},
59+
});
60+
61+
const selectedFont = () =>
62+
[...configState.get.fonts, ...configState.get.systemFonts].find(
63+
font => font.id === props.value,
64+
);
65+
66+
return (
67+
<Popover
68+
placement={modality === 'mobile' ? undefined : 'right-end'}
69+
open={open()}
70+
onOpenChange={setOpen}
71+
>
72+
<PopoverTrigger asChild>
73+
<As component={'div'} class={styles.input}>
74+
<span class={styles.inputValue}>
75+
{selectedFont()?.name ?? 'No font selected'}
76+
</span>
77+
<icons.SelectorIcon class={styles.inputIcon} />
78+
</As>
79+
</PopoverTrigger>
80+
<PopoverContent variant={'bordered'} class={styles.fontPickerPopover}>
81+
<Box
82+
display={'flex'}
83+
justifyContent={'spaceBetween'}
84+
alignItems={'center'}
85+
marginBottom={4}
86+
>
87+
<ExperimentalFeatureTooltip feature={'Aspect ratio'}>
88+
<HStack spacing={'2'} alignItems={'flexEnd'}>
89+
<Text weight={'semibold'}>Fonts</Text>
90+
<Text class={styles.experimentalFlag} size={'xs'}>
91+
<Box as={'span'} display={'flex'} alignItems={'center'}>
92+
<ExperimentalIcon size={'xs'} />
93+
<Box marginLeft={'1'}>Experimental</Box>
94+
</Box>
95+
</Text>
96+
</HStack>
97+
</ExperimentalFeatureTooltip>
98+
99+
<IconButton
100+
size={'xs'}
101+
aria-label={'Close'}
102+
theme={'secondary'}
103+
onClick={() => setOpen(false)}
104+
>
105+
<CloseIcon />
106+
</IconButton>
107+
</Box>
108+
109+
<DynamicSizedContainer>
110+
<VStack spacing={2}>
111+
<FlexField>
112+
<SegmentedField<FontPickerModality>
113+
value={mode()}
114+
autoWidth
115+
onChange={item => setMode(item)}
116+
items={[
117+
{label: 'Default', value: 'default'},
118+
{label: 'System', value: 'system'},
119+
]}
120+
size={'sm'}
121+
/>
122+
</FlexField>
123+
124+
<Switch>
125+
<Match when={mode() === 'default'}>
126+
<Listbox bordered {...webListboxProps} />
127+
</Match>
128+
<Match when={mode() === 'system'}>
129+
<FontSystemPicker
130+
onEsc={() => setOpen(false)}
131+
value={props.value}
132+
onChange={value => props.onChange(value)}
133+
/>
134+
</Match>
135+
</Switch>
136+
</VStack>
137+
</DynamicSizedContainer>
138+
</PopoverContent>
139+
</Popover>
140+
);
141+
}

0 commit comments

Comments
 (0)