Files
SnapOtter/apps/web/src/components/editor/tools/crop-tool.tsx
T
SnapOtter 01dcfa624b feat: add selection, crop, transform tools and supporting infrastructure for image editor
Implements Agent 2 deliverables for the image editor: move tool with Konva
Transformer and smart guide snapping, rectangular/elliptical marquee and
lasso selection with marching ants animation, magic wand flood fill, crop
tool with darkened overlay and rule-of-thirds grid, free transform with
numeric inputs, context menu with z-ordering and clipboard operations,
rulers with drag-to-create guides, smart alignment guides, and canvas/image
resize dialogs. Also creates the shared editor types and Zustand store with
full layer management, z-ordering, clipboard, guide, and document operations.
2026-05-06 23:29:29 +08:00

284 lines
7.7 KiB
TypeScript

import type Konva from "konva";
import { useCallback, useEffect, useRef, useState } from "react";
import { Group, Line, Rect, Transformer } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
import type { CropState } from "@/types/editor";
// ---------------------------------------------------------------------------
// Aspect ratio presets
// ---------------------------------------------------------------------------
export const ASPECT_RATIOS = [
{ label: "Free", value: null },
{ label: "1:1", value: 1 },
{ label: "4:3", value: 4 / 3 },
{ label: "3:4", value: 3 / 4 },
{ label: "16:9", value: 16 / 9 },
{ label: "9:16", value: 9 / 16 },
{ label: "3:2", value: 3 / 2 },
{ label: "2:3", value: 2 / 3 },
] as const;
// ---------------------------------------------------------------------------
// Hook: useCropTool
// ---------------------------------------------------------------------------
export interface CropToolApi {
cropRef: React.RefObject<Konva.Rect | null>;
transformerRef: React.RefObject<Konva.Transformer | null>;
cropState: CropState | null;
aspectRatio: string;
setAspectRatio: (label: string) => void;
initCrop: () => void;
applyCrop: () => void;
cancelCrop: () => void;
updateCropSize: (w: number, h: number) => void;
swapDimensions: () => void;
}
export function useCropTool(): CropToolApi {
const cropRef = useRef<Konva.Rect | null>(null);
const transformerRef = useRef<Konva.Transformer | null>(null);
const [aspectRatio, setAspectRatioState] = useState("Free");
const cropState = useEditorStore((s) => s.cropState);
const setCropState = useEditorStore((s) => s.setCropState);
const applyCropAction = useEditorStore((s) => s.applyCrop);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Attach transformer to crop rect
useEffect(() => {
const tr = transformerRef.current;
const node = cropRef.current;
if (tr && node && cropState) {
tr.nodes([node]);
tr.getLayer()?.batchDraw();
}
}, [cropState]);
const initCrop = useCallback(() => {
const margin = 0.1;
setCropState({
x: canvasSize.width * margin,
y: canvasSize.height * margin,
width: canvasSize.width * (1 - 2 * margin),
height: canvasSize.height * (1 - 2 * margin),
aspectRatio: null,
});
}, [canvasSize, setCropState]);
const setAspectRatio = useCallback(
(label: string) => {
setAspectRatioState(label);
const preset = ASPECT_RATIOS.find((p) => p.label === label);
if (!preset || !preset.value || !cropState) return;
const ratio = preset.value;
let w = cropState.width;
let h = w / ratio;
if (h > canvasSize.height) {
h = canvasSize.height * 0.8;
w = h * ratio;
}
setCropState({
...cropState,
width: w,
height: h,
aspectRatio: label,
});
},
[cropState, canvasSize, setCropState],
);
const applyCrop = useCallback(() => {
applyCropAction();
}, [applyCropAction]);
const cancelCrop = useCallback(() => {
setCropState(null);
}, [setCropState]);
const updateCropSize = useCallback(
(w: number, h: number) => {
if (!cropState) return;
setCropState({ ...cropState, width: w, height: h });
},
[cropState, setCropState],
);
const swapDimensions = useCallback(() => {
if (!cropState) return;
setCropState({
...cropState,
width: cropState.height,
height: cropState.width,
});
}, [cropState, setCropState]);
return {
cropRef,
transformerRef,
cropState,
aspectRatio,
setAspectRatio,
initCrop,
applyCrop,
cancelCrop,
updateCropSize,
swapDimensions,
};
}
// ---------------------------------------------------------------------------
// CropOverlay -- renders darkened overlay + crop region + rule-of-thirds
// ---------------------------------------------------------------------------
export function CropOverlay() {
const cropState = useEditorStore((s) => s.cropState);
const canvasSize = useEditorStore((s) => s.canvasSize);
const setCropState = useEditorStore((s) => s.setCropState);
const cropRectRef = useRef<Konva.Rect | null>(null);
const trRef = useRef<Konva.Transformer | null>(null);
useEffect(() => {
if (trRef.current && cropRectRef.current && cropState) {
trRef.current.nodes([cropRectRef.current]);
trRef.current.getLayer()?.batchDraw();
}
}, [cropState]);
if (!cropState) return null;
const { x, y, width, height } = cropState;
const cw = canvasSize.width;
const ch = canvasSize.height;
const overlayFill = "rgba(0, 0, 0, 0.5)";
// Rule-of-thirds grid lines
const thirdW = width / 3;
const thirdH = height / 3;
const handleTransformEnd = () => {
const node = cropRectRef.current;
if (!node) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const newW = Math.max(10, node.width() * scaleX);
const newH = Math.max(10, node.height() * scaleY);
node.scaleX(1);
node.scaleY(1);
setCropState({
...cropState,
x: node.x(),
y: node.y(),
width: newW,
height: newH,
});
};
const handleDragEnd = () => {
const node = cropRectRef.current;
if (!node) return;
setCropState({
...cropState,
x: Math.max(0, Math.min(node.x(), cw - width)),
y: Math.max(0, Math.min(node.y(), ch - height)),
});
};
return (
<Group listening={false}>
{/* Darkened overlays: top, bottom, left, right */}
<Rect x={0} y={0} width={cw} height={y} fill={overlayFill} listening={false} />
<Rect
x={0}
y={y + height}
width={cw}
height={ch - y - height}
fill={overlayFill}
listening={false}
/>
<Rect x={0} y={y} width={x} height={height} fill={overlayFill} listening={false} />
<Rect
x={x + width}
y={y}
width={cw - x - width}
height={height}
fill={overlayFill}
listening={false}
/>
{/* Crop region */}
<Rect
ref={cropRectRef}
x={x}
y={y}
width={width}
height={height}
stroke="#ffffff"
strokeWidth={1}
draggable
listening
onDragEnd={handleDragEnd}
onTransformEnd={handleTransformEnd}
/>
{/* Rule-of-thirds grid */}
<Line
points={[x + thirdW, y, x + thirdW, y + height]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
<Line
points={[x + thirdW * 2, y, x + thirdW * 2, y + height]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
<Line
points={[x, y + thirdH, x + width, y + thirdH]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
<Line
points={[x, y + thirdH * 2, x + width, y + thirdH * 2]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
{/* Transformer */}
<Transformer
ref={trRef}
rotateEnabled={false}
flipEnabled={false}
keepRatio={false}
anchorSize={8}
anchorStroke="#ffffff"
anchorFill="#3b82f6"
anchorCornerRadius={1}
borderStroke="#ffffff"
borderStrokeWidth={1}
enabledAnchors={[
"top-left",
"top-center",
"top-right",
"middle-left",
"middle-right",
"bottom-left",
"bottom-center",
"bottom-right",
]}
boundBoxFunc={(_oldBox, newBox) => ({
...newBox,
width: Math.max(10, newBox.width),
height: Math.max(10, newBox.height),
})}
/>
</Group>
);
}