69
common/resources/client/ui/font-selector/font-selector-filters.tsx
Executable file
69
common/resources/client/ui/font-selector/font-selector-filters.tsx
Executable file
@@ -0,0 +1,69 @@
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {SearchIcon} from '@common/icons/material/Search';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Select} from '@common/ui/forms/select/select';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {FontSelectorState} from '@common/ui/font-selector/font-selector-state';
|
||||
|
||||
export interface FontSelectorFilterValue {
|
||||
query: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface FiltersHeaderProps {
|
||||
state: FontSelectorState;
|
||||
}
|
||||
export function FontSelectorFilters({
|
||||
state: {filters, setFilters},
|
||||
}: FiltersHeaderProps) {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<div className="mb-24 items-center gap-24 @xs:flex">
|
||||
<TextField
|
||||
className="mb-12 flex-auto @xs:mb-0"
|
||||
value={filters.query}
|
||||
onChange={e => {
|
||||
setFilters({
|
||||
...filters,
|
||||
query: e.target.value,
|
||||
});
|
||||
}}
|
||||
startAdornment={<SearchIcon />}
|
||||
placeholder={trans(message('Search fonts'))}
|
||||
/>
|
||||
<Select
|
||||
className="flex-auto"
|
||||
selectionMode="single"
|
||||
selectedValue={filters.category}
|
||||
onSelectionChange={value => {
|
||||
setFilters({
|
||||
...filters,
|
||||
category: value as string,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Item value="">
|
||||
<Trans message="All categories" />
|
||||
</Item>
|
||||
<Item value="serif">
|
||||
<Trans message="Serif" />
|
||||
</Item>
|
||||
<Item value="sans-serif">
|
||||
<Trans message="Sans serif" />
|
||||
</Item>
|
||||
<Item value="display">
|
||||
<Trans message="Display" />
|
||||
</Item>
|
||||
<Item value="handwriting">
|
||||
<Trans message="Handwriting" />
|
||||
</Item>
|
||||
<Item value="monospace">
|
||||
<Trans message="Monospace" />
|
||||
</Item>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
common/resources/client/ui/font-selector/font-selector-pagination.tsx
Executable file
50
common/resources/client/ui/font-selector/font-selector-pagination.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
|
||||
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
|
||||
import React from 'react';
|
||||
import {FontSelectorState} from '@common/ui/font-selector/font-selector-state';
|
||||
|
||||
interface FontSelectorPaginationProps {
|
||||
state: FontSelectorState;
|
||||
}
|
||||
export function FontSelectorPagination({
|
||||
state: {currentPage = 0, setCurrentPage, filteredFonts, pages},
|
||||
}: FontSelectorPaginationProps) {
|
||||
const total = filteredFonts?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-24 text-sm mt-30 pt-14 border-t">
|
||||
{total > 0 && (
|
||||
<div>
|
||||
<Trans
|
||||
message=":from - :to of :total"
|
||||
values={{
|
||||
from: currentPage * 20 + 1,
|
||||
to: Math.min((currentPage + 1) * 20, total),
|
||||
total,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted">
|
||||
<IconButton
|
||||
disabled={currentPage < 1}
|
||||
onClick={() => {
|
||||
setCurrentPage(Math.max(0, currentPage - 1));
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={currentPage >= pages.length - 1}
|
||||
onClick={() => {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
common/resources/client/ui/font-selector/font-selector-state.ts
Executable file
83
common/resources/client/ui/font-selector/font-selector-state.ts
Executable file
@@ -0,0 +1,83 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {FontSelectorFilterValue} from '@common/ui/font-selector/font-selector-filters';
|
||||
import {FontConfig, useValueLists} from '@common/http/value-lists';
|
||||
import {useFilter} from '@common/i18n/use-filter';
|
||||
import {BrowserSafeFonts} from '@common/ui/font-picker/browser-safe-fonts';
|
||||
import {chunkArray} from '@common/utils/array/chunk-array';
|
||||
import {loadFonts} from '@common/ui/font-picker/load-fonts';
|
||||
|
||||
export interface FontSelectorState extends UseFontSelectorProps {
|
||||
fonts: FontConfig[];
|
||||
filteredFonts: FontConfig[];
|
||||
pages: FontConfig[][];
|
||||
isLoading: boolean;
|
||||
filters: FontSelectorFilterValue;
|
||||
setFilters: (filters: FontSelectorFilterValue) => void;
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
}
|
||||
|
||||
export interface UseFontSelectorProps {
|
||||
value?: FontConfig;
|
||||
onChange: (value: FontConfig) => void;
|
||||
}
|
||||
export function useFontSelectorState({
|
||||
value,
|
||||
onChange,
|
||||
}: UseFontSelectorProps): FontSelectorState {
|
||||
const {data, isLoading} = useValueLists(['googleFonts']);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const [filters, setFilterState] = useState<FontSelectorFilterValue>({
|
||||
query: '',
|
||||
category: value?.category ?? '',
|
||||
});
|
||||
const {contains} = useFilter({
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
const setFilters = useCallback((filters: FontSelectorFilterValue) => {
|
||||
setFilterState(filters);
|
||||
// reset to first page when searching or changing category
|
||||
setCurrentPage(0);
|
||||
}, []);
|
||||
|
||||
const allFonts = useMemo(() => {
|
||||
return BrowserSafeFonts.concat(data?.googleFonts ?? []);
|
||||
}, [data?.googleFonts]);
|
||||
|
||||
const filteredFonts = useMemo(() => {
|
||||
return allFonts.filter(font => {
|
||||
return (
|
||||
contains(font.family, filters.query) &&
|
||||
(!filters.category ||
|
||||
font.category?.toLowerCase() === filters.category.toLowerCase())
|
||||
);
|
||||
});
|
||||
}, [allFonts, filters, contains]);
|
||||
|
||||
const pages = useMemo(() => {
|
||||
return chunkArray(filteredFonts, 20);
|
||||
}, [filteredFonts]);
|
||||
const fonts = pages[currentPage];
|
||||
|
||||
useEffect(() => {
|
||||
const id = 'font-selector';
|
||||
if (fonts?.length) {
|
||||
loadFonts(fonts, {id});
|
||||
}
|
||||
}, [fonts, currentPage]);
|
||||
|
||||
return {
|
||||
fonts: fonts || [],
|
||||
currentPage,
|
||||
filteredFonts: filteredFonts || [],
|
||||
setCurrentPage,
|
||||
isLoading,
|
||||
filters,
|
||||
setFilters,
|
||||
value,
|
||||
onChange,
|
||||
pages,
|
||||
};
|
||||
}
|
||||
125
common/resources/client/ui/font-selector/font-selector.tsx
Executable file
125
common/resources/client/ui/font-selector/font-selector.tsx
Executable file
@@ -0,0 +1,125 @@
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {ButtonBase} from '@common/ui/buttons/button-base';
|
||||
import clsx from 'clsx';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import fontImage from './font.svg';
|
||||
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
|
||||
import {FontSelectorFilters} from '@common/ui/font-selector/font-selector-filters';
|
||||
import {
|
||||
FontSelectorState,
|
||||
UseFontSelectorProps,
|
||||
useFontSelectorState,
|
||||
} from '@common/ui/font-selector/font-selector-state';
|
||||
import {FontSelectorPagination} from '@common/ui/font-selector/font-selector-pagination';
|
||||
import {FontConfig} from '@common/http/value-lists';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
|
||||
interface FontSelectorProps extends UseFontSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
export function FontSelector(props: FontSelectorProps) {
|
||||
const state = useFontSelectorState(props);
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<FontSelectorFilters state={state} />
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<FontList state={state} />
|
||||
</AnimatePresence>
|
||||
<FontSelectorPagination state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FontListProps {
|
||||
state: FontSelectorState;
|
||||
}
|
||||
function FontList({state}: FontListProps) {
|
||||
const {isLoading, fonts} = state;
|
||||
|
||||
const gridClassName =
|
||||
'grid gap-24 grid-cols-[repeat(auto-fill,minmax(90px,1fr))] items-start';
|
||||
|
||||
if (isLoading) {
|
||||
return <FontListSkeleton className={gridClassName} />;
|
||||
}
|
||||
|
||||
if (!fonts?.length) {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-60"
|
||||
size="sm"
|
||||
image={<SvgImage src={fontImage} />}
|
||||
title={<Trans message="No matching fonts" />}
|
||||
description={
|
||||
<Trans message="Try another search query or different category" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div key="font-list" {...opacityAnimation} className={gridClassName}>
|
||||
{fonts?.map(font => (
|
||||
<FontButton key={font.family} font={font} state={state} />
|
||||
))}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FontButtonProps {
|
||||
font: FontConfig;
|
||||
state: FontSelectorState;
|
||||
}
|
||||
function FontButton({font, state: {value, onChange}}: FontButtonProps) {
|
||||
const isActive = value?.family === font.family;
|
||||
const displayName = font.family.split(',')[0].replace(/"/g, '');
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
key={font.family}
|
||||
display="block"
|
||||
onClick={() => {
|
||||
onChange(font);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex aspect-square items-center justify-center rounded-panel border text-4xl transition-bg-color hover:bg-hover md:text-5xl',
|
||||
isActive && 'ring-2 ring-primary ring-offset-2',
|
||||
)}
|
||||
>
|
||||
<span style={{fontFamily: font.family}}>Aa</span>
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'mt-6 block overflow-hidden overflow-ellipsis whitespace-nowrap text-sm',
|
||||
isActive && 'text-primary',
|
||||
)}
|
||||
>
|
||||
{font.label ? <Trans {...font.label} /> : displayName}
|
||||
</span>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
|
||||
interface FontListSkeletonProps {
|
||||
className: string;
|
||||
}
|
||||
function FontListSkeleton({className}: FontListSkeletonProps) {
|
||||
const items = Array.from(Array(20).keys());
|
||||
return (
|
||||
<m.div key="font-list-skeleton" {...opacityAnimation} className={className}>
|
||||
{items.map(index => (
|
||||
<div key={index}>
|
||||
<div className="aspect-square">
|
||||
<Skeleton display="block" variant="rect" />
|
||||
</div>
|
||||
<Skeleton className="mt-6 text-sm" />
|
||||
</div>
|
||||
))}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/ui/font-selector/font.svg
Executable file
1
common/resources/client/ui/font-selector/font.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
Reference in New Issue
Block a user