完成Swatch Scheme的构建基本功能。
This commit is contained in:
parent
6728ca1be2
commit
320b750834
|
@ -1,3 +1,35 @@
|
||||||
export function SwatchScheme() {
|
import { isEqual, isNil } from 'lodash-es';
|
||||||
return <div>Swatch Scheme</div>;
|
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<SwatchSchemeStorage>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwatchScheme({ scheme }: SwatchSchemeProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<(typeof tabOptions)[number]['id']>(() =>
|
||||||
|
isNil(scheme.schemeStorage.scheme) ? 'builder' : 'overview',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tab tabs={tabOptions} activeTab={activeTab} onActive={setActiveTab} />
|
||||||
|
{isEqual(activeTab, 'overview') && <SwatchSchemePreview scheme={scheme} />}
|
||||||
|
{isEqual(activeTab, 'builder') && (
|
||||||
|
<SwatchSchemeBuilder scheme={scheme} onBuildCompleted={() => setActiveTab('overview')} />
|
||||||
|
)}
|
||||||
|
{isEqual(activeTab, 'export') && <SchemeExport scheme={scheme} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
49
src/page-components/scheme/swatch-scheme/Builder.module.css
Normal file
49
src/page-components/scheme/swatch-scheme/Builder.module.css
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
278
src/page-components/scheme/swatch-scheme/Builder.tsx
Normal file
278
src/page-components/scheme/swatch-scheme/Builder.tsx
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="input_wrapper">
|
||||||
|
<input type="text" name={`name_${entry.id}`} defaultValue={entry.name} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FloatColorPicker
|
||||||
|
name={`color_${entry.id}`}
|
||||||
|
color={isEmpty(entry.color) ? undefined : entry.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ActionIcon
|
||||||
|
icon="tabler:trash"
|
||||||
|
extendClassName={styles.delete_btn}
|
||||||
|
onClick={() => onDelete?.(entry.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwatchSchemeBuilderProps = {
|
||||||
|
scheme: SchemeContent<SwatchSchemeStorage>;
|
||||||
|
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<IdenticalColorEntry[]>([]);
|
||||||
|
const [deleted, setDeleted] = useState<string[]>([]);
|
||||||
|
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<string, string>();
|
||||||
|
|
||||||
|
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<string, string>());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea enableY>
|
||||||
|
<form action={handleSubmitAction} className={styles.builder_layout}>
|
||||||
|
<h5 className={styles.segment_title}>Automatic Parameters</h5>
|
||||||
|
<label className={styles.label}>Swatch Amount</label>
|
||||||
|
<div className={styles.parameter_row}>
|
||||||
|
<div className="input_wrapper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="amount"
|
||||||
|
defaultValue={defaultSetting?.amount ?? 0}
|
||||||
|
className={styles.parameter_input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errMsg.has('amount') && <span className={styles.error_msg}>{errMsg.get('amount')}</span>}
|
||||||
|
</div>
|
||||||
|
<label className={styles.label}>Minimum Lightness</label>
|
||||||
|
<div className={styles.parameter_row}>
|
||||||
|
<div className="input_wrapper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="min_lightness"
|
||||||
|
defaultValue={((defaultSetting?.min_lightness ?? 0) * 100).toFixed(2)}
|
||||||
|
className={styles.parameter_input}
|
||||||
|
/>
|
||||||
|
<span>%</span>
|
||||||
|
</div>
|
||||||
|
{errMsg.has('min') && <span className={styles.error_msg}>{errMsg.get('min')}</span>}
|
||||||
|
</div>
|
||||||
|
<label className={styles.label}>Maximum Lightness</label>
|
||||||
|
<div className={styles.parameter_row}>
|
||||||
|
<div className="input_wrapper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="max_lightness"
|
||||||
|
defaultValue={((defaultSetting?.max_lightness ?? 0) * 100).toFixed(2)}
|
||||||
|
className={styles.parameter_input}
|
||||||
|
/>
|
||||||
|
<span>%</span>
|
||||||
|
</div>
|
||||||
|
{errMsg.has('max') && <span className={styles.error_msg}>{errMsg.get('max')}</span>}
|
||||||
|
</div>
|
||||||
|
<label className={styles.label}>Include Primary Color</label>
|
||||||
|
<div>
|
||||||
|
<Switch name="include_primary" checked={defaultSetting?.include_primary ?? false} />
|
||||||
|
</div>
|
||||||
|
<label style={{ gridColumn: 2 }}>Chroma shifting</label>
|
||||||
|
<label style={{ gridColumn: 3 }}>Lightness shifting</label>
|
||||||
|
<label className={styles.label}>Convert to Dark scheme</label>
|
||||||
|
<div className="input_wrapper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="dark_chroma"
|
||||||
|
defaultValue={((defaultSetting?.dark_convert.chroma ?? 0) * 100).toFixed(2)}
|
||||||
|
className={styles.parameter_input}
|
||||||
|
/>
|
||||||
|
<span>%</span>
|
||||||
|
</div>
|
||||||
|
<div className="input_wrapper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="dark_lightness"
|
||||||
|
defaultValue={((defaultSetting?.dark_convert.lightness ?? 0) * 100).toFixed(2)}
|
||||||
|
className={styles.parameter_input}
|
||||||
|
/>
|
||||||
|
<span>%</span>
|
||||||
|
</div>
|
||||||
|
<h5 className={styles.segment_title}>Swatch Colors</h5>
|
||||||
|
<label style={{ gridColumn: 1 }}>Name</label>
|
||||||
|
<label>Color</label>
|
||||||
|
<div>
|
||||||
|
<button type="button" className="small" onClick={addEntryAction}>
|
||||||
|
Add Color
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{originalColors
|
||||||
|
.filter((c) => !includes(deleted, c.id))
|
||||||
|
.map((color) => (
|
||||||
|
<ColorEntry
|
||||||
|
key={color.id}
|
||||||
|
entry={color}
|
||||||
|
onDelete={(index) => setDeleted((prev) => [...prev, index])}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{newColors
|
||||||
|
.filter((c) => !includes(deleted, c.id))
|
||||||
|
.map((color) => (
|
||||||
|
<ColorEntry
|
||||||
|
key={color.id}
|
||||||
|
entry={color}
|
||||||
|
onDelete={(index) => setDeleted((prev) => [...prev, index])}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{errMsg.has('color') && (
|
||||||
|
<div style={{ gridColumn: '1 / span 2' }}>
|
||||||
|
<span className={styles.error_msg}>{errMsg.get('color')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: '2 / span 2' }}>
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Build Scheme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
27
src/page-components/scheme/swatch-scheme/Preview.module.css
Normal file
27
src/page-components/scheme/swatch-scheme/Preview.module.css
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
67
src/page-components/scheme/swatch-scheme/Preview.tsx
Normal file
67
src/page-components/scheme/swatch-scheme/Preview.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className={styles.scheme_cell}>
|
||||||
|
<div className={styles.block} style={{ backgroundColor: `#${color}` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SchemeBlockProps = {
|
||||||
|
amount: number;
|
||||||
|
scheme: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SchemeBlock({ amount, scheme }: SchemeBlockProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.scheme_block}
|
||||||
|
style={{ gridTemplateColumns: `minmax(120px, 1fr) repeat(${amount}, 1fr)` }}>
|
||||||
|
<div />
|
||||||
|
{Array.from({ length: amount }).map((_, index) => (
|
||||||
|
<div key={index} className={styles.scheme_cell}>
|
||||||
|
<div className={styles.label}>{index * 100}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.entries(scheme).map(([name, colors]) => (
|
||||||
|
<>
|
||||||
|
<h6>{name}</h6>
|
||||||
|
{colors.map((color, index) => (
|
||||||
|
<SwatchCell key={index} color={color} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwatchSchemePreviewProps = {
|
||||||
|
scheme: SchemeContent<SwatchSchemeStorage>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwatchSchemePreview({ scheme }: SwatchSchemePreviewProps) {
|
||||||
|
return (
|
||||||
|
<ScrollArea enableY>
|
||||||
|
<div className={styles.preview_layout}>
|
||||||
|
<h2>Light Scheme</h2>
|
||||||
|
<SchemeBlock
|
||||||
|
amount={scheme.schemeStorage.source?.setting?.amount ?? 0}
|
||||||
|
scheme={scheme.schemeStorage.scheme.light}
|
||||||
|
/>
|
||||||
|
<h2>Dark Scheme</h2>
|
||||||
|
<SchemeBlock
|
||||||
|
amount={scheme.schemeStorage.source?.setting?.amount ?? 0}
|
||||||
|
scheme={scheme.schemeStorage.scheme.dark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user