diff --git a/src/page-components/scheme/SwatchScheme.tsx b/src/page-components/scheme/SwatchScheme.tsx index 09c6ddb..a196c54 100644 --- a/src/page-components/scheme/SwatchScheme.tsx +++ b/src/page-components/scheme/SwatchScheme.tsx @@ -1,3 +1,35 @@ -export function SwatchScheme() { - return
Swatch Scheme
; +import { isEqual, isNil } from 'lodash-es'; +import { useState } from 'react'; +import { Tab } from '../../components/Tab'; +import { SchemeContent } from '../../models'; +import { SwatchSchemeStorage } from '../../swatch_scheme'; +import { SchemeExport } from './Export'; +import { SwatchSchemeBuilder } from './swatch-scheme/Builder'; +import { SwatchSchemePreview } from './swatch-scheme/Preview'; + +const tabOptions = [ + { title: 'Overview', id: 'overview' }, + { title: 'Builder', id: 'builder' }, + { title: 'Exports', id: 'export' }, +]; + +type SwatchSchemeProps = { + scheme: SchemeContent; +}; + +export function SwatchScheme({ scheme }: SwatchSchemeProps) { + const [activeTab, setActiveTab] = useState<(typeof tabOptions)[number]['id']>(() => + isNil(scheme.schemeStorage.scheme) ? 'builder' : 'overview', + ); + + return ( + <> + + {isEqual(activeTab, 'overview') && } + {isEqual(activeTab, 'builder') && ( + setActiveTab('overview')} /> + )} + {isEqual(activeTab, 'export') && } + + ); } diff --git a/src/page-components/scheme/swatch-scheme/Builder.module.css b/src/page-components/scheme/swatch-scheme/Builder.module.css new file mode 100644 index 0000000..caea7a2 --- /dev/null +++ b/src/page-components/scheme/swatch-scheme/Builder.module.css @@ -0,0 +1,49 @@ +@layer pages { + .builder_layout { + padding: var(--spacing-s) var(--spacing-m); + font-size: var(--font-size-s); + line-height: 1.3em; + display: grid; + grid-template-columns: repeat(3, 200px); + align-items: center; + gap: var(--spacing-xs); + .label { + max-width: 200px; + grid-column: 1; + padding-inline-end: var(--spacing-m); + text-align: right; + } + .parameter_row { + grid-column: 2 / span 2; + display: flex; + align-items: center; + gap: var(--spacing-s); + } + .error_msg { + color: var(--color-danger); + font-size: var(--font-size-xs); + } + .segment_title { + grid-column: 1 / span 2; + text-align: center; + } + .parameter_input { + max-width: 8em; + } + h5 { + font-size: var(--font-size-m); + line-height: 1.7em; + } + } + .delete_btn { + font-size: var(--font-size-xs); + color: var(--color-yuebai); + background-color: oklch(from var(--color-danger) l c h / 0.25); + &:hover { + background-color: oklch(from var(--color-danger-hover) l c h / 0.65); + } + &:active { + background-color: oklch(from var(--color-danger-active) l c h / 0.65); + } + } +} diff --git a/src/page-components/scheme/swatch-scheme/Builder.tsx b/src/page-components/scheme/swatch-scheme/Builder.tsx new file mode 100644 index 0000000..9951a9d --- /dev/null +++ b/src/page-components/scheme/swatch-scheme/Builder.tsx @@ -0,0 +1,278 @@ +import { includes, isEmpty, isNaN } from 'lodash-es'; +import { useActionState, useCallback, useMemo, useState } from 'react'; +import { + ColorShifting, + SwatchEntry, + SwatchSchemeSetting, +} from '../../../color_functions/color_module'; +import { useColorFunction } from '../../../ColorFunctionContext'; +import { ActionIcon } from '../../../components/ActionIcon'; +import { FloatColorPicker } from '../../../components/FloatColorPicker'; +import { ScrollArea } from '../../../components/ScrollArea'; +import { Switch } from '../../../components/Switch'; +import { SchemeContent } from '../../../models'; +import { useUpdateScheme } from '../../../stores/schemes'; +import { QSwatchEntry, QSwatchSchemeSetting, SwatchSchemeStorage } from '../../../swatch_scheme'; +import { mapToObject } from '../../../utls'; +import styles from './Builder.module.css'; + +type IdenticalColorEntry = { + id: string; + name: string; + color: string; +}; + +type ColorEntryProps = { + entry: IdenticalColorEntry; + onDelete?: (index: string) => void; +}; + +function ColorEntry({ entry, onDelete }: ColorEntryProps) { + return ( + <> +
+ +
+
+ +
+
+ onDelete?.(entry.id)} + /> +
+ + ); +} + +type SwatchSchemeBuilderProps = { + scheme: SchemeContent; + onBuildCompleted?: () => void; +}; + +export function SwatchSchemeBuilder({ scheme, onBuildCompleted }: SwatchSchemeBuilderProps) { + const { colorFn } = useColorFunction(); + const updateScheme = useUpdateScheme(scheme.id); + const originalColors = useMemo(() => { + return (scheme.schemeStorage.source?.colors ?? []).map( + (color, index) => + ({ id: `oc_${index}`, name: color.name, color: color.color } as IdenticalColorEntry), + ); + }, [scheme.schemeStorage.source]); + const [newColors, setNewColors] = useState([]); + const [deleted, setDeleted] = useState([]); + const addEntryAction = useCallback(() => { + setNewColors((prev) => [...prev, { id: `nc_${prev.length}`, name: '', color: '' }]); + }, []); + const colorKeys = useMemo(() => { + return [...originalColors, ...newColors] + .map((color) => color.id) + .filter((id) => !includes(deleted, id)); + }, [originalColors, newColors, deleted]); + const defaultSetting = useMemo(() => { + try { + if (!colorFn) throw 'Web Assembly functions is not available'; + const defaultValues = colorFn.swatch_scheme_default_settings(); + if (scheme.schemeStorage.source?.setting) { + return new SwatchSchemeSetting( + scheme.schemeStorage.source.setting.amount ?? defaultValues.amount, + scheme.schemeStorage.source.setting.min_lightness ?? defaultValues.min_lightness, + scheme.schemeStorage.source.setting.max_lightness ?? defaultValues.max_lightness, + scheme.schemeStorage.source.setting.include_primary ?? defaultValues.include_primary, + new ColorShifting( + scheme.schemeStorage.source.setting.dark_convert.chroma ?? + defaultValues.dark_convert.chroma, + scheme.schemeStorage.source.setting.dark_convert.lightness ?? + defaultValues.dark_convert.lightness, + ), + ); + } + return defaultValues; + } catch (e) { + console.error('[Q scheme builder]', e); + } + return null; + }, [scheme.schemeStorage.source]); + + const [errMsg, handleSubmitAction] = useActionState((state, formData) => { + const errMsg = new Map(); + + try { + const swatchAmount = Number(formData.get('amount')); + if (isNaN(swatchAmount) || swatchAmount <= 0) { + errMsg.set('amount', 'MUST be a positive number'); + } + if (swatchAmount > 30) { + errMsg.set('amount', 'MUST be less than 30'); + } + + const minLightness = Number(formData.get('min_lightness')); + if (isNaN(minLightness) || minLightness < 0 || minLightness > 100) { + errMsg.set('min', 'MUST be a number between 0 and 100'); + } + + const maxLightness = Number(formData.get('max_lightness')); + if (isNaN(maxLightness) || maxLightness < 0 || maxLightness > 100) { + errMsg.set('max', 'MUST be a number between 0 and 100'); + } + + const includePrimary = Boolean(formData.get('include_primary')); + const darkConvertChroma = Number(formData.get('dark_chroma')) / 100.0; + const darkConvertLightness = Number(formData.get('dark_lightness')) / 100.0; + + const swatchSetting = new SwatchSchemeSetting( + swatchAmount, + minLightness / 100.0, + maxLightness / 100.0, + includePrimary, + new ColorShifting(darkConvertChroma, darkConvertLightness), + ); + const dumpedSettings = swatchSetting.toJsValue() as QSwatchSchemeSetting; + const entries: SwatchEntry[] = []; + for (const key of colorKeys) { + const name = String(formData.get(`name_${key}`)); + const color = String(formData.get(`color_${key}`)); + if (isEmpty(name) || isEmpty(color)) continue; + entries.push(new SwatchEntry(name, color)); + } + const dumpedEntries = entries.map((entry) => entry.toJsValue() as QSwatchEntry); + if (isEmpty(entries)) { + errMsg.set('color', 'At least one color is required'); + } + + if (!isEmpty(errMsg)) return errMsg; + + const generatedScheme = colorFn?.generate_swatch_scheme(entries, swatchSetting); + console.debug('[generated scheme]', generatedScheme); + updateScheme((prev) => { + prev.schemeStorage.source = { + colors: dumpedEntries, + setting: dumpedSettings, + }; + prev.schemeStorage.scheme = mapToObject(generatedScheme[0]); + prev.schemeStorage.cssVariables = generatedScheme[1]; + prev.schemeStorage.scssVariables = generatedScheme[2]; + prev.schemeStorage.jsVariables = generatedScheme[3]; + return prev; + }); + + onBuildCompleted?.(); + } catch (e) { + console.error('[build swatch scheme]', e); + } + + return errMsg; + }, new Map()); + + return ( + +
+
Automatic Parameters
+ +
+
+ +
+ {errMsg.has('amount') && {errMsg.get('amount')}} +
+ +
+
+ + % +
+ {errMsg.has('min') && {errMsg.get('min')}} +
+ +
+
+ + % +
+ {errMsg.has('max') && {errMsg.get('max')}} +
+ +
+ +
+ + + +
+ + % +
+
+ + % +
+
Swatch Colors
+ + +
+ +
+ {originalColors + .filter((c) => !includes(deleted, c.id)) + .map((color) => ( + setDeleted((prev) => [...prev, index])} + /> + ))} + {newColors + .filter((c) => !includes(deleted, c.id)) + .map((color) => ( + setDeleted((prev) => [...prev, index])} + /> + ))} + {errMsg.has('color') && ( +
+ {errMsg.get('color')} +
+ )} +
+ +
+ +
+ ); +} diff --git a/src/page-components/scheme/swatch-scheme/Preview.module.css b/src/page-components/scheme/swatch-scheme/Preview.module.css new file mode 100644 index 0000000..95f9866 --- /dev/null +++ b/src/page-components/scheme/swatch-scheme/Preview.module.css @@ -0,0 +1,27 @@ +@layer pages { + .preview_layout { + padding: var(--spacing-s) var(--spacing-m); + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--spacing-m); + } + .scheme_block { + display: grid; + gap: var(--spacing-m) var(--spacing-xs); + align-items: center; + } + .scheme_cell { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + font-size: var(--font-size-s); + .block { + height: 3em; + } + .label { + text-align: center; + } + } +} diff --git a/src/page-components/scheme/swatch-scheme/Preview.tsx b/src/page-components/scheme/swatch-scheme/Preview.tsx new file mode 100644 index 0000000..4339895 --- /dev/null +++ b/src/page-components/scheme/swatch-scheme/Preview.tsx @@ -0,0 +1,67 @@ +import { ScrollArea } from '../../../components/ScrollArea'; +import { SchemeContent } from '../../../models'; +import { SwatchSchemeStorage } from '../../../swatch_scheme'; +import styles from './Preview.module.css'; + +type SwatchCellProps = { + color: string; +}; + +export function SwatchCell({ color }: SwatchCellProps) { + return ( +
+
+
+ ); +} + +type SchemeBlockProps = { + amount: number; + scheme: Record; +}; + +export function SchemeBlock({ amount, scheme }: SchemeBlockProps) { + return ( +
+
+ {Array.from({ length: amount }).map((_, index) => ( +
+
{index * 100}
+
+ ))} + {Object.entries(scheme).map(([name, colors]) => ( + <> +
{name}
+ {colors.map((color, index) => ( + + ))} + + ))} +
+ ); +} + +type SwatchSchemePreviewProps = { + scheme: SchemeContent; +}; + +export function SwatchSchemePreview({ scheme }: SwatchSchemePreviewProps) { + return ( + +
+

Light Scheme

+ +

Dark Scheme

+ +
+
+ ); +}