mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
277 lines
8.7 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|