From 85ef8f8b88359976fdae3818784128dcb0e48993 Mon Sep 17 00:00:00 2001 From: Riccardo Perra Date: Sat, 25 Nov 2023 22:56:30 +0100 Subject: [PATCH 1/7] feat: implement local system font picker for theme builder This update introduces a local system font picker --- .../PropertyEditor/EditorStyleForm.tsx | 17 ++ .../controls/FontPicker/FontPicker.css.ts | 32 ++++ .../controls/FontPicker/FontPicker.tsx | 126 +++++++++++++++ .../controls/FontPicker/FontPickerListbox.tsx | 44 ++++++ .../controls/FontPicker/FontSystemPicker.tsx | 148 ++++++++++++++++++ apps/codeimage/src/hooks/use-local-fonts.ts | 72 +++++++++ 6 files changed, 439 insertions(+) create mode 100644 apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontPicker.css.ts create mode 100644 apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontPicker.tsx create mode 100644 apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontPickerListbox.tsx create mode 100644 apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontSystemPicker.tsx create mode 100644 apps/codeimage/src/hooks/use-local-fonts.ts diff --git a/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx b/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx index a6a31240a..d78d03e10 100644 --- a/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx +++ b/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx @@ -14,6 +14,7 @@ import {SegmentedField} from '@ui/SegmentedField/SegmentedField'; import {SkeletonLine} from '@ui/Skeleton/Skeleton'; import {createMemo, ParentComponent, Show} from 'solid-js'; import {AppLocaleEntries} from '../../i18n'; +import {FontPicker} from './controls/FontPicker/FontPicker'; import {PanelDivider} from './PanelDivider'; import {PanelHeader} from './PanelHeader'; import {PanelRow, TwoColumnPanelRow} from './PanelRow'; @@ -255,6 +256,22 @@ export const EditorStyleForm: ParentComponent = () => { + + + } + > + { + console.log('font id', fontId, SUPPORTED_FONTS); + setFontId(fontId ?? SUPPORTED_FONTS[0].id); + }} + /> + + + + void; +} + +type FontPickerModality = 'default' | 'system'; + +/** + * @experimental + */ +export function FontPicker(props: FontPickerProps) { + const [open, setOpen] = createSignal(false); + const [mode, setMode] = createSignal('default'); + const modality = useModality(); + + const webListboxProps = createFontPickerListboxProps({ + onEsc: () => setOpen(false), + onChange: props.onChange, + get value() { + return props.value; + }, + get items() { + return SUPPORTED_FONTS.map(font => ({ + label: font.name, + value: font.id, + })); + }, + }); + + return ( + + + + {props.value ?? 'Auto'} + + + + + + + Fonts + + + + Experimental + + + + + + setOpen(false)} + > + + + + + + + + + value={mode()} + autoWidth + onChange={item => setMode(item)} + items={[ + {label: 'Default', value: 'default'}, + {label: 'System', value: 'system'}, + ]} + size={'sm'} + /> + + + + + + + + setOpen(false)} + value={props.value} + onChange={value => props.onChange(value)} + /> + + + + + + + ); +} diff --git a/apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontPickerListbox.tsx b/apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontPickerListbox.tsx new file mode 100644 index 000000000..adf224758 --- /dev/null +++ b/apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontPickerListbox.tsx @@ -0,0 +1,44 @@ +import {Listbox} from '@codeui/kit'; + +interface FontPickerListboxProps { + onEsc: () => void; + value: string; + onChange: (value: string) => void; + items: Item[]; +} + +type Item = {label: string; value: string}; + +export const createFontPickerListboxProps = (props: FontPickerListboxProps) => { + return { + autoFocus: true, + shouldFocusWrap: false, + selectionMode: 'single', + size: 'sm', + disallowEmptySelection: true, + onKeyDown: evt => { + if (evt.key === 'Escape') { + props.onEsc(); + } + }, + get options() { + return props.items; + }, + optionValue: item => item.value, + optionTextValue: item => item.label, + itemLabel: item => ( + + {item.label} + + ), + get value() { + return [props.value]; + }, + onChange: values => props.onChange(values.keys().next().value), + } satisfies Parameters>[0]; +}; diff --git a/apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontSystemPicker.tsx b/apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontSystemPicker.tsx new file mode 100644 index 000000000..0ee291440 --- /dev/null +++ b/apps/codeimage/src/components/PropertyEditor/controls/FontPicker/FontSystemPicker.tsx @@ -0,0 +1,148 @@ +import {LoadingCircle} from '@codeimage/ui'; +import {Button, VirtualizedListbox} from '@codeui/kit'; +import { + createEffect, + createMemo, + Match, + onMount, + Show, + Suspense, + Switch, + untrack, +} from 'solid-js'; +import {createStore, unwrap} from 'solid-js/store'; +import { + checkLocalFontPermission, + useLocalFonts, +} from '../../../../hooks/use-local-fonts'; +import {createFontPickerListboxProps} from './FontPickerListbox'; + +interface FontSystemPickerProps { + onEsc: () => void; + onChange: (value: string) => void; + value: string; +} + +export function FontSystemPicker(props: FontSystemPickerProps) { + const [state, setState] = createStore({ + permissionState: null as PermissionState | null, + fonts: {} as Record, + loading: false, + error: null as string | null, + }); + + onMount(() => { + checkLocalFontPermission() + .then(permission => { + permission.onchange = function () { + setState('permissionState', this.state); + }; + const {state} = permission; + setState('permissionState', state); + setState('loading', true); + return loadFonts(); + }) + .catch(e => setState('error', e)); + }); + + async function loadFonts() { + untrack(() => { + if (!state.loading) { + setState('loading', true); + } + }); + await new Promise(r => setTimeout(r, 250)); + return useLocalFonts().then(fonts => { + setState(state => ({ + ...state, + fonts: fonts, + loading: false, + error: null, + })); + }); + } + + const fonts = createMemo(() => { + console.log('updating font'); + return Object.entries(unwrap(state.fonts)).map(([k, v]) => ({ + label: v[0].family, + value: k, + })); + }); + + const listboxProps = createFontPickerListboxProps({ + onEsc: () => props.onEsc(), + onChange: props.onChange, + get value() { + return props.value; + }, + get items() { + return fonts(); + }, + }); + + createEffect(() => console.log(state.fonts)); + return ( + + + +
+ + Waiting for permission +
+
+ denied + + + + + + + } + when={!state.loading} + > +
+ +
+
+
+
+
+ ); +} diff --git a/apps/codeimage/src/hooks/use-local-fonts.ts b/apps/codeimage/src/hooks/use-local-fonts.ts new file mode 100644 index 000000000..8d001c628 --- /dev/null +++ b/apps/codeimage/src/hooks/use-local-fonts.ts @@ -0,0 +1,72 @@ +declare global { + interface Window { + /** + * query local fonts + * + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/queryLocalFonts MDN Reference} + */ + queryLocalFonts?: (options?: { + postscriptNames: string[]; + }) => Promise; + } + + interface FontData { + /** + * the family of the font face + */ + readonly family: string; + /** + * the full name of the font face + */ + readonly fullName: string; + /** + * the PostScript name of the font face + */ + readonly postscriptName: string; + /** + * the style of the font face + */ + readonly style: string; + /** + * get a Promise that fulfills with a Blob containing the raw bytes of the underlying font file + */ + readonly blob: () => Promise; + } +} + +export async function useLocalFonts(): Promise> { + const {queryLocalFonts} = window; + if (!queryLocalFonts) { + return {}; + } + const fonts: Record = {}; + const styleSheet = new CSSStyleSheet(); + try { + const pickedFonts = await queryLocalFonts(); + console.log(pickedFonts); + for (const metadata of pickedFonts) { + if (!fonts[metadata.family]) { + fonts[metadata.family] = []; + } + fonts[metadata.family].push(metadata); + } + } catch (err) { + console.warn(err.name, err.message); + } + console.log(fonts); + return fonts; +} + +export async function checkLocalFontPermission() { + const {navigator} = window; + return navigator.permissions.query({ + name: 'local-fonts', + } as unknown as PermissionDescriptor); +} + +export async function revokeLocalFontPermission() { + const {navigator} = window; + return navigator.permissions.query({ + name: 'local-fonts', + } as unknown as PermissionDescriptor); +} From 78ffb4d46f06413867da7cbe165284306ad78408 Mon Sep 17 00:00:00 2001 From: Riccardo Perra Date: Sun, 26 Nov 2023 18:57:30 +0100 Subject: [PATCH 2/7] feat: refactor state and editor to use configStore --- .../components/CustomEditor/CustomEditor.tsx | 13 +- .../PropertyEditor/EditorStyleForm.tsx | 79 +++----- .../controls/FontPicker/FontPicker.css.ts | 31 ++- .../controls/FontPicker/FontPicker.tsx | 29 ++- .../controls/FontPicker/FontSystemPicker.tsx | 187 +++++++++--------- apps/codeimage/src/core/configuration.ts | 6 +- apps/codeimage/src/core/configuration/font.ts | 16 +- apps/codeimage/src/hooks/use-local-fonts.ts | 27 ++- apps/codeimage/src/index.tsx | 10 +- .../src/state/editor/config.store.ts | 150 ++++++++++++++ apps/codeimage/src/state/editor/editor.ts | 38 +++- .../src/state/plugins/withIndexedDbPlugin.ts | 12 +- 12 files changed, 406 insertions(+), 192 deletions(-) create mode 100644 apps/codeimage/src/state/editor/config.store.ts diff --git a/apps/codeimage/src/components/CustomEditor/CustomEditor.tsx b/apps/codeimage/src/components/CustomEditor/CustomEditor.tsx index 45d04a06d..f829423dd 100644 --- a/apps/codeimage/src/components/CustomEditor/CustomEditor.tsx +++ b/apps/codeimage/src/components/CustomEditor/CustomEditor.tsx @@ -26,7 +26,6 @@ import { lineNumbers, rectangularSelection, } from '@codemirror/view'; -import {SUPPORTED_FONTS} from '@core/configuration/font'; import {createCodeMirror, createEditorReadonly} from 'solid-codemirror'; import { createEffect, @@ -67,8 +66,11 @@ interface CustomEditorProps { export default function CustomEditor(props: VoidProps) { const {themeArray: themes} = getThemeStore(); const languages = SUPPORTED_LANGUAGES; - const fonts = SUPPORTED_FONTS; - const {state: editorState, canvasEditorEvents} = getRootEditorStore(); + const { + state: editorState, + canvasEditorEvents, + computed: {selectedFont}, + } = getRootEditorStore(); const {editor} = getActiveEditorStore(); const selectedLanguage = createMemo(() => languages.find(language => language.id === editor()?.languageId), @@ -139,9 +141,8 @@ export default function CustomEditor(props: VoidProps) { }); const customFontExtension = (): Extension => { - const fontName = - fonts.find(({id}) => editorState.options.fontId === id)?.name || - fonts[0].name, + const font = selectedFont(); + const fontName = font.name, fontWeight = editorState.options.fontWeight, enableLigatures = editorState.options.enableLigatures; diff --git a/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx b/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx index d78d03e10..ca5ac5a9f 100644 --- a/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx +++ b/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx @@ -4,15 +4,16 @@ import {CustomTheme} from '@codeimage/highlight'; import {useI18n} from '@codeimage/locale'; import {getRootEditorStore} from '@codeimage/store/editor'; import {getActiveEditorStore} from '@codeimage/store/editor/activeEditor'; +import {EditorConfigStore} from '@codeimage/store/editor/config.store'; import {dispatchUpdateTheme} from '@codeimage/store/effects/onThemeChange'; import {getThemeStore} from '@codeimage/store/theme/theme.store'; import {createSelectOptions, Select} from '@codeui/kit'; -import {SUPPORTED_FONTS} from '@core/configuration/font'; import {getUmami} from '@core/constants/umami'; import {DynamicSizedContainer} from '@ui/DynamicSizedContainer/DynamicSizedContainer'; import {SegmentedField} from '@ui/SegmentedField/SegmentedField'; import {SkeletonLine} from '@ui/Skeleton/Skeleton'; -import {createMemo, ParentComponent, Show} from 'solid-js'; +import {ParentComponent, Show} from 'solid-js'; +import {provideState} from 'statebuilder'; import {AppLocaleEntries} from '../../i18n'; import {FontPicker} from './controls/FontPicker/FontPicker'; import {PanelDivider} from './PanelDivider'; @@ -35,6 +36,7 @@ const languages: readonly LanguageDefinition[] = [...SUPPORTED_LANGUAGES].sort( ); export const EditorStyleForm: ParentComponent = () => { + const configStore = provideState(EditorConfigStore); const {themeArray} = getThemeStore(); const [t] = useI18n(); const {editor, setLanguageId, formatter, setFormatterName} = @@ -42,7 +44,7 @@ export const EditorStyleForm: ParentComponent = () => { const { state, actions: {setShowLineNumbers, setFontWeight, setFontId, setEnableLigatures}, - computed: {font}, + computed: {selectedFont}, } = getRootEditorStore(); const languagesOptions = createSelectOptions( @@ -81,23 +83,28 @@ export const EditorStyleForm: ParentComponent = () => { {key: 'label', valueKey: 'value'}, ); - const memoizedFontWeights = createMemo(() => - font().types.map(type => ({ + const fontWeightByFont = () => { + const font = selectedFont(); + if (!font) { + return []; + } + return font.types.map(type => ({ label: type.name, - value: type.weight as number, - })), - ); + value: type.weight, + })); + }; - const fontWeightOptions = createSelectOptions(memoizedFontWeights, { + const fontWeightOptions = createSelectOptions(fontWeightByFont, { key: 'label', valueKey: 'value', }); const fontOptions = createSelectOptions( - SUPPORTED_FONTS.map(font => ({ - label: font.name, - value: font.id, - })), + () => + configStore.get.fonts.map(font => ({ + label: font.name, + value: font.id, + })), {key: 'label', valueKey: 'value'}, ); @@ -217,56 +224,14 @@ export const EditorStyleForm: ParentComponent = () => { - - - } - > -