mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user