feat: add color picker glass
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { rgbToHex } from '$lib/core/color';
|
import { rgbToHex } from '$lib/core/color';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
import PipetteLoupe from './tool/PipetteLoupe.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
image: PixelImage | null;
|
image: PixelImage | null;
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
||||||
|
|
||||||
let canvas = $state<HTMLCanvasElement | undefined>();
|
let canvas = $state<HTMLCanvasElement | undefined>();
|
||||||
|
let hover = $state<{ clientX: number; clientY: number; px: number; py: number; hex: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!canvas || !image) return;
|
if (!canvas || !image) return;
|
||||||
@@ -21,17 +25,40 @@
|
|||||||
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) {
|
function pixelAt(event: MouseEvent): { px: number; py: number; hex: string } | null {
|
||||||
if (!pipetteActive || !onPickColor || !canvas) return;
|
if (!canvas || !image) return null;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) return;
|
if (rect.width === 0 || rect.height === 0) return null;
|
||||||
const x = Math.floor((event.clientX - rect.left) * (canvas.width / rect.width));
|
const px = Math.floor((event.clientX - rect.left) * (canvas.width / rect.width));
|
||||||
const y = Math.floor((event.clientY - rect.top) * (canvas.height / rect.height));
|
const py = Math.floor((event.clientY - rect.top) * (canvas.height / rect.height));
|
||||||
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return;
|
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return null;
|
||||||
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
|
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
|
||||||
onPickColor(rgbToHex(r, g, b));
|
return { px, py, hex: rgbToHex(r, g, b) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickColor(event: MouseEvent) {
|
||||||
|
if (!pipetteActive || !onPickColor) return;
|
||||||
|
const pixel = pixelAt(event);
|
||||||
|
if (pixel) {
|
||||||
|
onPickColor(pixel.hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackHover(event: MouseEvent) {
|
||||||
|
if (!pipetteActive) {
|
||||||
|
hover = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pixel = pixelAt(event);
|
||||||
|
hover = pixel
|
||||||
|
? { clientX: event.clientX, clientY: event.clientY, px: pixel.px, py: pixel.py, hex: pixel.hex }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHover() {
|
||||||
|
hover = null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -42,8 +69,20 @@
|
|||||||
title="{image.width} × {image.height}"
|
title="{image.width} × {image.height}"
|
||||||
class:pipette={pipetteActive}
|
class:pipette={pipetteActive}
|
||||||
onclick={pickColor}
|
onclick={pickColor}
|
||||||
|
onmousemove={trackHover}
|
||||||
|
onmouseleave={clearHover}
|
||||||
></canvas>
|
></canvas>
|
||||||
<p class="dims">{image.width} × {image.height} px</p>
|
<p class="dims">{image.width} × {image.height} px</p>
|
||||||
|
{#if pipetteActive && hover && image}
|
||||||
|
<PipetteLoupe
|
||||||
|
{image}
|
||||||
|
px={hover.px}
|
||||||
|
py={hover.py}
|
||||||
|
hex={hover.hex}
|
||||||
|
clientX={hover.clientX}
|
||||||
|
clientY={hover.clientY}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
image: PixelImage;
|
||||||
|
px: number;
|
||||||
|
py: number;
|
||||||
|
hex: string;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { image, px, py, hex, clientX, clientY }: Props = $props();
|
||||||
|
|
||||||
|
const SIZE = 110;
|
||||||
|
const ZOOM = 8;
|
||||||
|
const BLOCK = 15;
|
||||||
|
const HALF = Math.floor(BLOCK / 2);
|
||||||
|
const CENTER = SIZE / 2;
|
||||||
|
|
||||||
|
let loupeCanvas = $state<HTMLCanvasElement | undefined>();
|
||||||
|
|
||||||
|
let srcCanvas: HTMLCanvasElement | null = null;
|
||||||
|
let srcKey: PixelImage | null = null;
|
||||||
|
|
||||||
|
function ensureSource(): HTMLCanvasElement | null {
|
||||||
|
if (!srcCanvas || srcKey !== image) {
|
||||||
|
srcCanvas = document.createElement('canvas');
|
||||||
|
srcCanvas.width = image.width;
|
||||||
|
srcCanvas.height = image.height;
|
||||||
|
srcCanvas.getContext('2d')?.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
||||||
|
srcKey = image;
|
||||||
|
}
|
||||||
|
return srcCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockX = $derived(clamp(px - HALF, 0, Math.max(0, image.width - BLOCK)));
|
||||||
|
const blockY = $derived(clamp(py - HALF, 0, Math.max(0, image.height - BLOCK)));
|
||||||
|
const flipBelow = $derived(clientY < SIZE + 24);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!loupeCanvas) return;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
loupeCanvas.width = SIZE * dpr;
|
||||||
|
loupeCanvas.height = SIZE * dpr;
|
||||||
|
const ctx = loupeCanvas.getContext('2d');
|
||||||
|
const src = ensureSource();
|
||||||
|
if (!ctx || !src) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.fillStyle = '#111318';
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
|
ctx.drawImage(
|
||||||
|
src,
|
||||||
|
blockX,
|
||||||
|
blockY,
|
||||||
|
BLOCK,
|
||||||
|
BLOCK,
|
||||||
|
CENTER + (blockX - px) * ZOOM,
|
||||||
|
CENTER + (blockY - py) * ZOOM,
|
||||||
|
BLOCK * ZOOM,
|
||||||
|
BLOCK * ZOOM
|
||||||
|
);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(CENTER - ZOOM / 2 + 0.5, CENTER - ZOOM / 2 + 0.5, ZOOM, ZOOM);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="loupe"
|
||||||
|
class:flip={flipBelow}
|
||||||
|
style="left:{clientX}px;top:{clientY}px"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<canvas bind:this={loupeCanvas} style="width:{SIZE}px;height:{SIZE}px"></canvas>
|
||||||
|
<p class="hex">{hex}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loupe {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translate(-50%, calc(-100% - 16px));
|
||||||
|
padding: 4px 4px 2px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loupe.flip {
|
||||||
|
transform: translate(-50%, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hex {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user