feat: add color picker

This commit is contained in:
2026-08-23 06:12:37 +05:00
parent 3701fc8045
commit 7b799969c4
6 changed files with 162 additions and 43 deletions
+43 -33
View File
@@ -9,42 +9,52 @@
interface Props { interface Props {
params: ParamDef[]; params: ParamDef[];
values: Record<string, any>; values: Record<string, any>;
pipetteTargetId?: string | null;
onPipetteToggle?: (id: string) => void;
} }
let { params, values = $bindable() }: Props = $props(); let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: Props = $props();
</script> </script>
{#each params as param (param.id)} {#each params as param (param.id)}
{#if param.type === 'checkbox'} <div class="field">
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} /> {#if param.type === 'checkbox'}
{:else if param.type === 'number'} <CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
<TextField {:else if param.type === 'number'}
id={param.id} <TextField
label={param.label} id={param.id}
type="number" label={param.label}
min={param.min} type="number"
max={param.max} min={param.min}
step={param.step} max={param.max}
bind:value={values[param.id]} step={param.step}
/> bind:value={values[param.id]}
{:else if param.type === 'slider'} />
<SliderField {:else if param.type === 'slider'}
id={param.id} <SliderField
label={param.label} id={param.id}
min={param.min} label={param.label}
max={param.max} min={param.min}
step={param.step} max={param.max}
default={param.default} step={param.step}
bind:value={values[param.id]} default={param.default}
/> bind:value={values[param.id]}
{:else if param.type === 'select'} />
<SelectField {:else if param.type === 'select'}
id={param.id} <SelectField
label={param.label} id={param.id}
options={param.options} label={param.label}
bind:value={values[param.id]} options={param.options}
/> bind:value={values[param.id]}
{:else if param.type === 'color'} />
<ColorField id={param.id} label={param.label} bind:value={values[param.id]} /> {:else if param.type === 'color'}
{/if} <ColorField
id={param.id}
label={param.label}
bind:value={values[param.id]}
pipetteActive={pipetteTargetId === param.id}
onPipetteToggle={() => onPipetteToggle?.(param.id)}
/>
{/if}
</div>
{/each} {/each}
+33 -3
View File
@@ -1,7 +1,14 @@
<script lang="ts"> <script lang="ts">
import { rgbToHex } from '$lib/core/color';
import type { PixelImage } from '$lib/core/types'; import type { PixelImage } from '$lib/core/types';
let { image }: { image: PixelImage | null } = $props(); interface Props {
image: PixelImage | null;
pipetteActive?: boolean;
onPickColor?: (hex: string) => void;
}
let { image, pipetteActive = false, onPickColor }: Props = $props();
let canvas = $state<HTMLCanvasElement | undefined>(); let canvas = $state<HTMLCanvasElement | undefined>();
@@ -13,10 +20,29 @@
if (!ctx) return; if (!ctx) return;
ctx.putImageData(new ImageData(image.data, image.width, image.height), 0, 0); ctx.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
}); });
function pickColor(event: MouseEvent) {
if (!pipetteActive || !onPickColor || !canvas) return;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const x = Math.floor((event.clientX - rect.left) * (canvas.width / rect.width));
const y = Math.floor((event.clientY - rect.top) * (canvas.height / rect.height));
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
onPickColor(rgbToHex(r, g, b));
}
</script> </script>
{#if image} {#if image}
<canvas bind:this={canvas} title="{image.width} × {image.height}"></canvas> <!-- svelte-ignore a11y_no_static_element_interactions -->
<canvas
bind:this={canvas}
title="{image.width} × {image.height}"
class:pipette={pipetteActive}
onclick={pickColor}
></canvas>
<p class="dims">{image.width} × {image.height} px</p> <p class="dims">{image.width} × {image.height} px</p>
{/if} {/if}
@@ -32,9 +58,13 @@
border-radius: var(--radius-m); border-radius: var(--radius-m);
} }
canvas.pipette {
cursor: crosshair;
}
.dims { .dims {
margin-top: var(--space-1); margin-top: var(--space-1);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: var(--text-s);
} }
</style> </style>
+21 -1
View File
@@ -26,6 +26,17 @@
let runToken = 0; let runToken = 0;
let lastRunSource: PixelImage | null = null; let lastRunSource: PixelImage | null = null;
let lastRunValuesJson = ''; let lastRunValuesJson = '';
let pipetteTargetId = $state<string | null>(null);
function handlePipetteToggle(id: string) {
pipetteTargetId = pipetteTargetId === id ? null : id;
}
function handlePickColor(hex: string) {
if (!pipetteTargetId) return;
values[pipetteTargetId] = hex;
pipetteTargetId = null;
}
async function handleFile(file: File) { async function handleFile(file: File) {
errorText = ''; errorText = '';
@@ -36,6 +47,7 @@
result = null; result = null;
previewResult = null; previewResult = null;
showMask = false; showMask = false;
pipetteTargetId = null;
info = isInfo ? imageInfo(source) : null; info = isInfo ? imageInfo(source) : null;
if (isInfo) { if (isInfo) {
status = 'loaded'; status = 'loaded';
@@ -90,6 +102,7 @@
result = null; result = null;
previewResult = null; previewResult = null;
showMask = false; showMask = false;
pipetteTargetId = null;
info = null; info = null;
errorText = ''; errorText = '';
status = 'idle'; status = 'idle';
@@ -132,6 +145,8 @@
onFile={handleFile} onFile={handleFile}
onError={(message) => (errorText = message)} onError={(message) => (errorText = message)}
onReset={reset} onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
/> />
</div> </div>
<div class="cell"> <div class="cell">
@@ -151,7 +166,12 @@
</div> </div>
{#if source && !isInfo} {#if source && !isInfo}
<ParamsCard params={tool.params} bind:values /> <ParamsCard
params={tool.params}
bind:values
{pipetteTargetId}
onPipetteToggle={handlePipetteToggle}
/>
{/if} {/if}
</section> </section>
@@ -5,15 +5,17 @@
interface Props { interface Props {
params: ParamDef[]; params: ParamDef[];
values: Record<string, any>; values: Record<string, any>;
pipetteTargetId?: string | null;
onPipetteToggle?: (id: string) => void;
} }
let { params, values = $bindable() }: Props = $props(); let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: Props = $props();
</script> </script>
<div class="panel root"> <div class="panel root">
<h2 class="heading-section">Параметры</h2> <h2 class="heading-section">Параметры</h2>
{#if params.length > 0} {#if params.length > 0}
<ParamForm {params} bind:values /> <ParamForm {params} bind:values {pipetteTargetId} {onPipetteToggle} />
{:else} {:else}
<p class="hint text-caption text-muted"> <p class="hint text-caption text-muted">
У этого инструмента нет параметров — результат уже готов. У этого инструмента нет параметров — результат уже готов.
@@ -10,9 +10,11 @@
onFile: (file: File) => void; onFile: (file: File) => void;
onError: (message: string) => void; onError: (message: string) => void;
onReset: () => void; onReset: () => void;
pipetteActive?: boolean;
onPickColor?: (hex: string) => void;
} }
let { source, onFile, onError, onReset }: Props = $props(); let { source, onFile, onError, onReset, pipetteActive = false, onPickColor }: Props = $props();
</script> </script>
<div class="container"> <div class="container">
@@ -22,7 +24,7 @@
{:else} {:else}
<DropOverlay {onFile} {onError}> <DropOverlay {onFile} {onError}>
<div class="media"> <div class="media">
<Preview image={source} /> <Preview image={source} pipetteActive={pipetteActive} onPickColor={onPickColor} />
</div> </div>
<Button variant="secondary" onclick={onReset}>Заменить изображение</Button> <Button variant="secondary" onclick={onReset}>Заменить изображение</Button>
</DropOverlay> </DropOverlay>
+57 -2
View File
@@ -6,20 +6,75 @@
label: string; label: string;
value?: string; value?: string;
hint?: string; hint?: string;
pipetteActive?: boolean;
onPipetteToggle?: () => void;
} }
let { id, label, value = $bindable('#000000'), hint }: Props = $props(); let { id, label, value = $bindable('#000000'), hint, pipetteActive = false, onPipetteToggle }:
Props = $props();
</script> </script>
<Field {id} {label} {hint}> <Field {id} {label} {hint}>
<input id={id} class="control swatch" type="color" bind:value /> <div class="row">
<input id={id} class="control swatch" type="color" bind:value />
{#if onPipetteToggle}
<button
type="button"
class="pipette"
aria-label="Пипетка"
aria-pressed={pipetteActive}
title="Пипетка"
onclick={onPipetteToggle}
>
</button>
{/if}
</div>
</Field> </Field>
<style> <style>
.row {
display: flex;
align-items: center;
gap: var(--space-1);
}
.swatch { .swatch {
height: var(--control-height); height: var(--control-height);
width: 3.5rem; width: 3.5rem;
padding: 2px; padding: 2px;
cursor: pointer; cursor: pointer;
} }
.pipette {
flex: none;
width: var(--control-height);
height: var(--control-height);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast),
background var(--transition-fast);
}
.pipette:hover {
border-color: var(--accent);
color: var(--accent);
}
.pipette[aria-pressed='true'] {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
</style> </style>