基本形成色卡页面功能。

This commit is contained in:
徐涛 2025-01-10 14:24:18 +08:00
parent 6708c40ffb
commit 9fec4a31e9
11 changed files with 356 additions and 1 deletions

View File

@ -1,6 +1,8 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { ColorFunctionProvider } from './ColorFunctionContext';
import { Notifications } from './components/Notifications';
import { ColorCards } from './pages/Cards';
import { CardsDetail } from './pages/CardsDetail';
import { Harmonies } from './pages/Harmonies';
import { Home } from './pages/Home';
import { LightenDarken } from './pages/LightenDarken';
@ -37,6 +39,14 @@ const routes = createBrowserRouter([
{ path: 'lighten-darken', element: <LightenDarken /> },
{ path: 'mixer', element: <Mixer /> },
{ path: 'wacg', element: <WACGCheck /> },
{
path: 'cards',
element: <ColorCards />,
children: [
{ path: 'chinese', element: <CardsDetail mainTag="chinese" /> },
{ path: 'japanese', element: <CardsDetail mainTag="japanese" /> },
],
},
],
},
]);

View File

@ -170,7 +170,7 @@
}
/* 输入框以及输入框组合体默认样式 */
:where(input, textarea) {
:where(input, textarea, select) {
border: 1px solid oklch(from var(--color-bg) calc(l + (1 - l) * 0.1) c h);
border-radius: var(--border-radius-xxs);
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 4);

View File

@ -7,3 +7,16 @@ export type HarmonyColor = {
color: string;
ratio: number;
};
export type ColorDescription = {
name: string;
pinyin: string[];
hue: number;
lightness: number;
category: string;
tags: string[];
rgb: [number, number, number];
hsl: [number, number, number];
lab: [number, number, number];
oklch: [number, number, number];
};

View File

@ -0,0 +1,42 @@
@layer pages {
.card {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-n);
font-size: var(--font-size-xxs);
line-height: var(--font-size-xxs);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-xs);
cursor: pointer;
}
.color_block {
width: 100%;
height: 5em;
}
.description_line {
display: flex;
flex-direction: row;
align-items: center;
padding: var(--spacing-xs) var(--spacing-s);
}
.title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xxs);
flex: 1;
.name {
font-size: var(--font-size-xs);
line-height: var(--font-size-xs);
font-weight: bold;
}
.en_name {
font-style: italic;
color: var(--color-neutral-focus);
}
}
.color_value {
text-transform: uppercase;
}
}

View File

@ -0,0 +1,74 @@
import { capitalize } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { useColorFunction } from '../../ColorFunctionContext';
import { useCopyColor } from '../../hooks/useCopyColor';
import { ColorDescription } from '../../models';
import styles from './ColorCard.module.css';
type ColorCardProps = {
color: ColorDescription;
copyMode?: 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklch';
};
export function ColorCard({ color, copyMode }: ColorCardProps) {
const { colorFn } = useColorFunction();
const copytToClipboard = useCopyColor();
const colorHex = useMemo(() => {
const [r, g, b] = color.rgb;
if (colorFn) {
try {
const hex = colorFn.rgb_to_hex(r, g, b);
return hex;
} catch (e) {
console.error('[Convert RGB]', e);
}
}
return `${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}, [colorFn, color]);
const handleCopy = useCallback(() => {
switch (copyMode) {
case 'rgb':
copytToClipboard(`rgb(${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})`);
break;
case 'hsl':
copytToClipboard(
`hsl(${color.hsl[0].toFixed(1)}, ${(color.hsl[1] * 100).toFixed(2)}%, ${(
color.hsl[2] * 100
).toFixed(2)}%)`,
);
break;
case 'lab':
copytToClipboard(
`lab(${color.lab[0].toFixed(1)}, ${color.lab[1].toFixed(2)}, ${color.lab[2].toFixed(2)})`,
);
break;
case 'oklch':
copytToClipboard(
`oklch(${(color.oklch[0] * 100).toFixed(2)}%, ${color.oklch[1].toFixed(
4,
)}, ${color.oklch[2].toFixed(1)})`,
);
break;
case 'hex':
default:
copytToClipboard(`#${colorHex}`);
break;
}
}, [copytToClipboard, color, copyMode, colorHex]);
return (
<div className={styles.card} onClick={handleCopy}>
<div
className={styles.color_block}
style={{ backgroundColor: `rgb(${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})` }}
/>
<div className={styles.description_line}>
<div className={styles.title}>
<span className={styles.name}>{color.name}</span>
<span className={styles.en_name}>{color.pinyin.map(capitalize).join(' ')}</span>
</div>
<div className={styles.color_value}>#{colorHex}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import cx from 'clsx';
import { NavLink } from 'react-router-dom';
import styles from './CardsNavigation.module.css';
export function CardsNavigation() {
return (
<div className={styles.cards_list}>
<menu className={styles.nav_menu}>
<li>
<NavLink
to="chinese"
className={({ isActive }) => cx(styles.nav_link, isActive && styles.active)}>
Chinese Traditional
</NavLink>
</li>
<li>
<NavLink
to="japanese"
className={({ isActive }) => cx(styles.nav_link, isActive && styles.active)}>
Japanese Traditional
</NavLink>
</li>
</menu>
</div>
);
}

View File

@ -0,0 +1,33 @@
@layer pages {
.cards_list {
max-width: calc(var(--spacing) * 125);
flex: 1 1 calc(var(--spacing) * 125);
padding: calc(var(--spacing) * 4) 0;
box-shadow: 2px 0 8px oklch(from var(--color-black) l c h / 65%);
z-index: 40;
}
.nav_menu {
flex: 1 0;
padding: var(--spacing-n);
padding-block-start: var(--spacing-s);
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-xs);
li {
list-style: none;
a.nav_link {
display: inline-block;
width: 100%;
padding-inline: var(--spacing-l);
padding-block: var(--spacing-s);
&.active {
background-color: var(--color-primary-active);
}
&:hover {
background-color: var(--color-primary-hover);
}
}
}
}
}

View File

@ -0,0 +1,13 @@
@layer pages {
.cards_workspace {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
align-items: stretch;
}
.cards_container {
flex: 1 0;
}
}

14
src/pages/Cards.tsx Normal file
View File

@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom';
import { CardsNavigation } from '../page-components/color-cards/CardNavigation';
import styles from './Cards.module.css';
export function ColorCards() {
return (
<div className={styles.cards_workspace}>
<CardsNavigation />
<div className={styles.cards_container}>
<Outlet />
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
@layer pages {
.cards_workspace {
padding: var(--spacing-l) var(--spacing-m);
flex-direction: column;
.filters {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-s);
.cate_select {
padding: var(--spacing-xxs) var(--spacing-s);
min-width: 7em;
font-size: var(--font-size-s);
}
}
.cards_container {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--spacing-s);
padding: 0 var(--spacing-s);
.card {
flex-basis: calc((100% - var(--spacing-s) * 4) / 5);
}
}
}
}

102
src/pages/CardsDetail.tsx Normal file
View File

@ -0,0 +1,102 @@
import cx from 'clsx';
import { isEqual } from 'lodash-es';
import { ChangeEvent, useMemo, useState } from 'react';
import { useColorFunction } from '../ColorFunctionContext';
import { HSegmentedControl } from '../components/HSegmentedControl';
import { ScrollArea } from '../components/ScrollArea';
import { ColorDescription } from '../models';
import { ColorCard } from '../page-components/cards-detail/ColorCard';
import styles from './CardsDetail.module.css';
type CardsDetailProps = {
mainTag: string;
};
export function CardsDetail({ mainTag }: CardsDetailProps) {
const { colorFn } = useColorFunction();
const categories = useMemo(() => {
if (!colorFn) {
return [];
}
try {
const embededCategories = colorFn.color_categories() as { label: string; value: string }[];
return embededCategories.filter((cate) => !isEqual(cate.get('value'), 'unknown'));
} catch (e) {
console.error('[Fetch color categories]', e);
}
return [];
}, [colorFn]);
const [colorCategory, setCategory] = useState<string | 'null'>('null');
const handleSelectCategory = (e: ChangeEvent<HTMLSelectElement>) => {
const selectedValue = e.target.value;
setCategory(selectedValue);
};
const [mode, setMode] = useState<'hex' | 'rgb' | 'hsl' | 'lab' | 'oklch'>('hex');
const colors = useMemo(() => {
if (!colorFn) {
return [];
}
try {
const colorCate = isEqual(colorCategory, 'null') ? undefined : colorCategory;
let tag = '';
switch (mainTag) {
case 'japanese':
tag = 'japanese_traditional';
break;
case 'chinese':
tag = 'chinese_traditional';
break;
default:
tag = '';
break;
}
const embedColors = colorFn.search_color_cards(tag, colorCate) as ColorDescription[];
console.debug('[Fetch cards]', embedColors);
return embedColors;
} catch (e) {
console.error('[Fetch colors]', e);
}
return [];
}, [colorFn, mainTag, colorCategory]);
return (
<div className={cx('workspace', styles.cards_workspace)}>
<div className={styles.filters}>
<span>Show</span>
<select
className={styles.cate_select}
value={colorCategory}
onChange={handleSelectCategory}>
<option value="null">All</option>
{categories.map((cate, index) => (
<option key={`${cate.get('value')}-${index}`} value={cate.get('value')}>
{cate.get('label')}
</option>
))}
</select>
<span>colors.</span>
<div>Copy color value in</div>
<HSegmentedControl
options={[
{ label: 'HEX', value: 'hex' },
{ label: 'RGB', value: 'rgb' },
{ label: 'HSL', value: 'hsl' },
{ label: 'LAB', value: 'lab' },
{ label: 'OKLCH', value: 'oklch' },
]}
value={mode}
onChange={setMode}
/>
</div>
<ScrollArea enableY>
<div className={styles.cards_container}>
{colors.map((c, index) => (
<div key={`${c.name}-${index}`} className={styles.card}>
<ColorCard color={c} copyMode={mode} />
</div>
))}
</div>
</ScrollArea>
</div>
);
}