mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Transformer } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transform state -- position/size/rotation for the options bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransformValues {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useTransformTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransformToolApi {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
isTransforming: boolean;
|
||||
values: TransformValues;
|
||||
lockedAspect: boolean;
|
||||
setLockedAspect: (v: boolean) => void;
|
||||
activate: () => void;
|
||||
applyTransform: () => void;
|
||||
cancelTransform: () => void;
|
||||
setValues: (v: Partial<TransformValues>) => void;
|
||||
flipHorizontal: () => void;
|
||||
flipVertical: () => void;
|
||||
}
|
||||
|
||||
export function useTransformTool(): TransformToolApi {
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
const [isTransforming, setIsTransforming] = useState(false);
|
||||
const [lockedAspect, setLockedAspect] = useState(false);
|
||||
const [values, setValuesState] = useState<TransformValues>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
rotation: 0,
|
||||
});
|
||||
const preTransformRef = useRef<TransformValues | null>(null);
|
||||
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const objects = useEditorStore((s) => s.objects);
|
||||
const updateObject = useEditorStore((s) => s.updateObject);
|
||||
const setTool = useEditorStore((s) => s.setTool);
|
||||
|
||||
// Read values from selected object(s)
|
||||
useEffect(() => {
|
||||
if (!isTransforming || selectedObjectIds.length === 0) return;
|
||||
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
|
||||
if (!obj) return;
|
||||
const v: TransformValues = {
|
||||
x: (obj.attrs.x as number) ?? 0,
|
||||
y: (obj.attrs.y as number) ?? 0,
|
||||
width: (obj.attrs.width as number) ?? 0,
|
||||
height: (obj.attrs.height as number) ?? 0,
|
||||
rotation: (obj.attrs.rotation as number) ?? 0,
|
||||
};
|
||||
setValuesState(v);
|
||||
}, [isTransforming, selectedObjectIds, objects]);
|
||||
|
||||
// Attach transformer
|
||||
useEffect(() => {
|
||||
const tr = transformerRef.current;
|
||||
if (!tr || !isTransforming) return;
|
||||
const stage = tr.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const nodes = selectedObjectIds
|
||||
.map((id) => stage.findOne(`#${id}`))
|
||||
.filter(Boolean) as Konva.Node[];
|
||||
tr.nodes(nodes);
|
||||
tr.getLayer()?.batchDraw();
|
||||
}, [isTransforming, selectedObjectIds]);
|
||||
|
||||
const activate = useCallback(() => {
|
||||
if (selectedObjectIds.length === 0) return;
|
||||
setIsTransforming(true);
|
||||
// Store pre-transform state for cancel
|
||||
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
|
||||
if (obj) {
|
||||
preTransformRef.current = {
|
||||
x: (obj.attrs.x as number) ?? 0,
|
||||
y: (obj.attrs.y as number) ?? 0,
|
||||
width: (obj.attrs.width as number) ?? 0,
|
||||
height: (obj.attrs.height as number) ?? 0,
|
||||
rotation: (obj.attrs.rotation as number) ?? 0,
|
||||
};
|
||||
}
|
||||
}, [selectedObjectIds, objects]);
|
||||
|
||||
const applyTransform = useCallback(() => {
|
||||
setIsTransforming(false);
|
||||
preTransformRef.current = null;
|
||||
setTool("move");
|
||||
}, [setTool]);
|
||||
|
||||
const cancelTransform = useCallback(() => {
|
||||
// Restore pre-transform state
|
||||
if (preTransformRef.current && selectedObjectIds.length > 0) {
|
||||
const prev = preTransformRef.current;
|
||||
for (const id of selectedObjectIds) {
|
||||
updateObject(id, {
|
||||
x: prev.x,
|
||||
y: prev.y,
|
||||
width: prev.width,
|
||||
height: prev.height,
|
||||
rotation: prev.rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsTransforming(false);
|
||||
preTransformRef.current = null;
|
||||
setTool("move");
|
||||
}, [selectedObjectIds, updateObject, setTool]);
|
||||
|
||||
const setValues = useCallback(
|
||||
(v: Partial<TransformValues>) => {
|
||||
setValuesState((prev) => {
|
||||
const next = { ...prev, ...v };
|
||||
|
||||
// If aspect is locked, derive height from width ratio or vice versa
|
||||
if (lockedAspect && prev.width > 0 && prev.height > 0) {
|
||||
const ratio = prev.width / prev.height;
|
||||
if (v.width !== undefined && v.height === undefined) {
|
||||
next.height = next.width / ratio;
|
||||
} else if (v.height !== undefined && v.width === undefined) {
|
||||
next.width = next.height * ratio;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to selected objects
|
||||
for (const id of selectedObjectIds) {
|
||||
updateObject(id, {
|
||||
x: next.x,
|
||||
y: next.y,
|
||||
width: next.width,
|
||||
height: next.height,
|
||||
rotation: next.rotation,
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[selectedObjectIds, updateObject, lockedAspect],
|
||||
);
|
||||
|
||||
const flipHorizontal = useCallback(() => {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
const currentScale = (obj.attrs.scaleX as number) ?? 1;
|
||||
updateObject(id, { scaleX: -currentScale });
|
||||
}
|
||||
}, [selectedObjectIds, objects, updateObject]);
|
||||
|
||||
const flipVertical = useCallback(() => {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
const currentScale = (obj.attrs.scaleY as number) ?? 1;
|
||||
updateObject(id, { scaleY: -currentScale });
|
||||
}
|
||||
}, [selectedObjectIds, objects, updateObject]);
|
||||
|
||||
return {
|
||||
transformerRef,
|
||||
isTransforming,
|
||||
values,
|
||||
lockedAspect,
|
||||
setLockedAspect,
|
||||
activate,
|
||||
applyTransform,
|
||||
cancelTransform,
|
||||
setValues,
|
||||
flipHorizontal,
|
||||
flipVertical,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TransformToolTransformer -- Konva Transformer for free transform mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TransformToolTransformer({
|
||||
transformerRef,
|
||||
onTransformEnd,
|
||||
}: {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
|
||||
}) {
|
||||
const handleTransformEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<Event>) => {
|
||||
const node = e.target;
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
const newW = Math.max(1, node.width() * scaleX);
|
||||
const newH = Math.max(1, node.height() * scaleY);
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
node.width(newW);
|
||||
node.height(newH);
|
||||
onTransformEnd?.(e);
|
||||
},
|
||||
[onTransformEnd],
|
||||
);
|
||||
|
||||
return (
|
||||
<Transformer
|
||||
ref={transformerRef}
|
||||
rotateEnabled
|
||||
flipEnabled
|
||||
keepRatio={false}
|
||||
rotationSnaps={[
|
||||
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270, 285,
|
||||
300, 315, 330, 345,
|
||||
]}
|
||||
anchorSize={8}
|
||||
anchorStroke="#3b82f6"
|
||||
anchorFill="#ffffff"
|
||||
anchorCornerRadius={2}
|
||||
borderStroke="#3b82f6"
|
||||
borderStrokeWidth={1}
|
||||
borderDash={[4, 4]}
|
||||
padding={0}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user