完成Swatch Scheme的构建基本功能。

This commit is contained in:
徐涛 2025-02-07 22:22:33 +08:00
parent 6728ca1be2
commit 320b750834
5 changed files with 455 additions and 2 deletions

View File

@ -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} />}
</>
);
} }

View 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);
}
}
}

View 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>
);
}

View 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;
}
}
}

View 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>
);
}