Files
SnapOtter/apps/web/src/components/editor/tools/eyedropper-tool.tsx
T
SnapOtter a9547f5944 feat: add color system panel, eyedropper tool, and color swatch components
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.
2026-05-06 23:23:05 +08:00

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