Files
SnapOtter/apps/web/src/components/editor/tools/transform-tool.tsx
T
SnapOtter dd73a8a50a fix: resolve 41 bugs and wire 21 unimplemented features in image editor
Canvas rendering:
- Fix Konva filter application order (filters before cache)
- Implement 6 missing filters (motionBlur, radialBlur, surfaceBlur, vignette, grain, sharpen)
- Implement exposure, vibrance, warmth adjustments as custom Konva filters
- Apply layer blend modes via globalCompositeOperation
- Apply object effects (drop shadow, outer glow, stroke) to all shapes
- Mount SmartGuidesOverlay during move tool drag
- Clip pixel grid to visible viewport (200-line cap for performance)

Store logic:
- resizeImage now scales all objects proportionally (points, radii, fontSize)
- rotate/flip/trim handle line/arrow points arrays and center-based objects
- applyCrop creates cropped source image via offscreen canvas
- invertSelection creates mask from bounds when no mask exists
- cutObjects uses single atomic set() to prevent race conditions
- sendToBack respects layer ordering in multi-layer documents
- Add batchNudge() and commitHistory() for undoable nudge operations
- Add updateLayerThumbnail() method

Tool hooks:
- Fix clone stamp/dodge/burn perf (toDataURL only on mouseUp, not every move)
- Fix magic wand zoom/pixelRatio with explicit stage.toCanvas() viewport
- Fix eyedropper sampling with unzoomed canvas export
- Fix selection tool stale closure via isDrawingRef
- Implement polygonal lasso (click-to-place vertices, double-click to close)
- Implement selection subtract mode (geometric and mask-based)
- Implement gradient live preview during drag
- Fix transform/move tool to persist changes and handle ellipse/polygon/star

UI wiring:
- Mount rulers and guidelines in editor page
- Wire histogram with live canvas imageData
- Wire autosave recovery with blob-to-dataURL conversion
- Wire fill dialog to Shift+Backspace shortcut
- Wire eyedropper and transform options to options bar
- Fix history panel undo/redo button reactive state via useSyncExternalStore
- Fix layer row name click to select layer (timer-based click/dblclick)
- Fix zoom animation coordinate drift with progressive store sync
- Fix copy merged to use Konva stage composite export

Tests:
- 49 new unit tests (store fixes + konva filters)
- 8 new E2E test files with 39 test cases
2026-05-08 16:43:27 +08:00

277 lines
8.7 KiB
TypeScript

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 a = obj.attrs as unknown as Record<string, unknown>;
const v: TransformValues = {
x: (a.x as number) ?? 0,
y: (a.y as number) ?? 0,
width: (a.width as number) ?? 0,
height: (a.height as number) ?? 0,
rotation: (a.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) {
const a = obj.attrs as unknown as Record<string, unknown>;
preTransformRef.current = {
x: (a.x as number) ?? 0,
y: (a.y as number) ?? 0,
width: (a.width as number) ?? 0,
height: (a.height as number) ?? 0,
rotation: (a.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 a = obj.attrs as unknown as Record<string, unknown>;
const currentScale = (a.scaleX as number) ?? 1;
updateObject(id, { scaleX: -currentScale } as Record<string, unknown>);
}
}, [selectedObjectIds, objects, updateObject]);
const flipVertical = useCallback(() => {
for (const id of selectedObjectIds) {
const obj = objects.find((o) => o.id === id);
if (!obj) continue;
const a = obj.attrs as unknown as Record<string, unknown>;
const currentScale = (a.scaleY as number) ?? 1;
updateObject(id, { scaleY: -currentScale } as Record<string, unknown>);
}
}, [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 id = node.id();
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const newW = Math.max(1, node.width() * scaleX);
const newH = Math.max(1, node.height() * scaleY);
// Reset scale on the Konva node
node.scaleX(1);
node.scaleY(1);
node.width(newW);
node.height(newH);
// Directly update the store with the new attributes
if (id) {
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
const newAttrs: Record<string, unknown> = {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
};
if (obj?.type === "ellipse") {
// Convert width/height to radiusX/radiusY for ellipses
newAttrs.radiusX = newW / 2;
newAttrs.radiusY = newH / 2;
} else if (obj?.type === "polygon") {
// Convert width/height to radius for polygons
newAttrs.radius = (newW + newH) / 4;
} else if (obj?.type === "star") {
// Scale outerRadius and innerRadius proportionally for stars
const starAttrs = obj.attrs as { outerRadius: number; innerRadius: number };
const avgScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
newAttrs.outerRadius = starAttrs.outerRadius * avgScale;
newAttrs.innerRadius = starAttrs.innerRadius * avgScale;
} else {
// Rectangles, images, text: use width/height directly
newAttrs.width = newW;
newAttrs.height = newH;
}
useEditorStore.getState().updateObject(id, newAttrs);
}
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}
/>
);
}