mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Color panel with foreground/background swatches, react-colorful picker popover, HEX/RGB/HSL input modes, recent colors row, swap (X) and reset (D). Eyedropper tool samples composited pixels via stage.toCanvas() with 1x1/3x3/5x5 sample size options, alt+click for background color.
152 lines
4.1 KiB
TypeScript
152 lines
4.1 KiB
TypeScript
// apps/web/src/components/editor/tools/eyedropper-tool.tsx
|
|
|
|
import type Konva from "konva";
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { useEditorStore } from "@/stores/editor-store";
|
|
import type { SampleSize } from "../options/eyedropper-options";
|
|
|
|
/**
|
|
* Sample a single pixel or averaged region from a canvas context at (x, y).
|
|
* Returns hex color string.
|
|
*/
|
|
function samplePixelColor(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
sampleSize: SampleSize,
|
|
): string {
|
|
const half = Math.floor(sampleSize / 2);
|
|
const startX = x - half;
|
|
const startY = y - half;
|
|
|
|
const imageData = ctx.getImageData(startX, startY, sampleSize, sampleSize);
|
|
const data = imageData.data;
|
|
const pixelCount = sampleSize * sampleSize;
|
|
|
|
let rSum = 0;
|
|
let gSum = 0;
|
|
let bSum = 0;
|
|
|
|
for (let i = 0; i < pixelCount; i++) {
|
|
rSum += data[i * 4];
|
|
gSum += data[i * 4 + 1];
|
|
bSum += data[i * 4 + 2];
|
|
}
|
|
|
|
const r = Math.round(rSum / pixelCount);
|
|
const g = Math.round(gSum / pixelCount);
|
|
const b = Math.round(bSum / pixelCount);
|
|
|
|
return rgbToHex(r, g, b);
|
|
}
|
|
|
|
function rgbToHex(r: number, g: number, b: number): string {
|
|
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
|
|
}
|
|
|
|
interface UseEyedropperToolOptions {
|
|
stageRef: React.RefObject<Konva.Stage | null>;
|
|
sampleSize: SampleSize;
|
|
}
|
|
|
|
export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) {
|
|
const setForegroundColor = useEditorStore((s) => s.setForegroundColor);
|
|
const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor);
|
|
const [sampledColor, setSampledColor] = useState<string | null>(null);
|
|
const canvasCache = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
/**
|
|
* Export the visible stage to a flat canvas for pixel sampling.
|
|
* Cached so repeated clicks during one drag don't re-export.
|
|
*/
|
|
const getStageCanvas = useCallback((): HTMLCanvasElement | null => {
|
|
const stage = stageRef.current;
|
|
if (!stage) return null;
|
|
|
|
// Use toCanvas to get a composited view of all visible layers
|
|
const canvas = stage.toCanvas({
|
|
pixelRatio: 1,
|
|
});
|
|
canvasCache.current = canvas;
|
|
return canvas;
|
|
}, [stageRef]);
|
|
|
|
/**
|
|
* Invalidate the cache (call on mousedown so we get fresh data).
|
|
*/
|
|
const invalidateCache = useCallback(() => {
|
|
canvasCache.current = null;
|
|
}, []);
|
|
|
|
/**
|
|
* Sample color at stage pointer position.
|
|
* Returns the sampled hex color, or null if sampling failed.
|
|
*/
|
|
const sampleAtPointer = useCallback(
|
|
(e: Konva.KonvaEventObject<MouseEvent>): string | null => {
|
|
const stage = e.target.getStage();
|
|
if (!stage) return null;
|
|
|
|
const pointer = stage.getPointerPosition();
|
|
if (!pointer) return null;
|
|
|
|
const canvas = canvasCache.current ?? getStageCanvas();
|
|
if (!canvas) return null;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return null;
|
|
|
|
// pointer coordinates are already in stage pixel space
|
|
const x = Math.round(pointer.x);
|
|
const y = Math.round(pointer.y);
|
|
|
|
// Bounds check
|
|
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) {
|
|
return null;
|
|
}
|
|
|
|
const color = samplePixelColor(ctx, x, y, sampleSize);
|
|
setSampledColor(color);
|
|
return color;
|
|
},
|
|
[getStageCanvas, sampleSize],
|
|
);
|
|
|
|
/**
|
|
* Handle click/mousedown on the canvas for eyedropper sampling.
|
|
* Alt+click sets background color; normal click sets foreground.
|
|
*/
|
|
const handleEyedropperClick = useCallback(
|
|
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
invalidateCache();
|
|
|
|
const color = sampleAtPointer(e);
|
|
if (!color) return;
|
|
|
|
if (e.evt.altKey) {
|
|
setBackgroundColor(color);
|
|
} else {
|
|
setForegroundColor(color);
|
|
}
|
|
},
|
|
[invalidateCache, sampleAtPointer, setForegroundColor, setBackgroundColor],
|
|
);
|
|
|
|
/**
|
|
* Handle mousemove while eyedropper is active (for live preview).
|
|
*/
|
|
const handleEyedropperMove = useCallback(
|
|
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
sampleAtPointer(e);
|
|
},
|
|
[sampleAtPointer],
|
|
);
|
|
|
|
return {
|
|
handleEyedropperClick,
|
|
handleEyedropperMove,
|
|
sampledColor,
|
|
invalidateCache,
|
|
};
|
|
}
|