mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-agent-a82b6128' into feat/image-editor
# Conflicts: # apps/web/package.json # apps/web/src/stores/editor-store.ts # apps/web/src/types/editor.ts # pnpm-lock.yaml
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Transformer } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { SmartGuide } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smart guide calculation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findSmartGuides(
|
||||
node: Konva.Node,
|
||||
allNodes: Konva.Node[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
threshold: number,
|
||||
): SmartGuide[] {
|
||||
const box = node.getClientRect({ relativeTo: node.getParent() ?? undefined });
|
||||
const guides: SmartGuide[] = [];
|
||||
|
||||
const dragEdges = {
|
||||
left: box.x,
|
||||
right: box.x + box.width,
|
||||
centerX: box.x + box.width / 2,
|
||||
top: box.y,
|
||||
bottom: box.y + box.height,
|
||||
centerY: box.y + box.height / 2,
|
||||
};
|
||||
|
||||
// Canvas edges + center
|
||||
const canvasSnaps = [
|
||||
{ pos: 0, type: "canvas" as const, orient: "vertical" as const },
|
||||
{
|
||||
pos: canvasWidth / 2,
|
||||
type: "canvas" as const,
|
||||
orient: "vertical" as const,
|
||||
},
|
||||
{ pos: canvasWidth, type: "canvas" as const, orient: "vertical" as const },
|
||||
{ pos: 0, type: "canvas" as const, orient: "horizontal" as const },
|
||||
{
|
||||
pos: canvasHeight / 2,
|
||||
type: "canvas" as const,
|
||||
orient: "horizontal" as const,
|
||||
},
|
||||
{
|
||||
pos: canvasHeight,
|
||||
type: "canvas" as const,
|
||||
orient: "horizontal" as const,
|
||||
},
|
||||
];
|
||||
|
||||
for (const snap of canvasSnaps) {
|
||||
if (snap.orient === "vertical") {
|
||||
for (const edge of [dragEdges.left, dragEdges.centerX, dragEdges.right]) {
|
||||
if (Math.abs(edge - snap.pos) < threshold) {
|
||||
guides.push({
|
||||
orientation: "vertical",
|
||||
position: snap.pos,
|
||||
type: snap.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const edge of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) {
|
||||
if (Math.abs(edge - snap.pos) < threshold) {
|
||||
guides.push({
|
||||
orientation: "horizontal",
|
||||
position: snap.pos,
|
||||
type: snap.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other objects
|
||||
for (const other of allNodes) {
|
||||
if (other === node) continue;
|
||||
const ob = other.getClientRect({
|
||||
relativeTo: other.getParent() ?? undefined,
|
||||
});
|
||||
const targetEdges = {
|
||||
left: ob.x,
|
||||
right: ob.x + ob.width,
|
||||
centerX: ob.x + ob.width / 2,
|
||||
top: ob.y,
|
||||
bottom: ob.y + ob.height,
|
||||
centerY: ob.y + ob.height / 2,
|
||||
};
|
||||
|
||||
for (const edgeVal of [targetEdges.left, targetEdges.centerX, targetEdges.right]) {
|
||||
for (const dragVal of [dragEdges.left, dragEdges.centerX, dragEdges.right]) {
|
||||
if (Math.abs(dragVal - edgeVal) < threshold) {
|
||||
guides.push({
|
||||
orientation: "vertical",
|
||||
position: edgeVal,
|
||||
type: dragVal === dragEdges.centerX ? "center" : "edge",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const edgeVal of [targetEdges.top, targetEdges.centerY, targetEdges.bottom]) {
|
||||
for (const dragVal of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) {
|
||||
if (Math.abs(dragVal - edgeVal) < threshold) {
|
||||
guides.push({
|
||||
orientation: "horizontal",
|
||||
position: edgeVal,
|
||||
type: dragVal === dragEdges.centerY ? "center" : "edge",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return guides;
|
||||
}
|
||||
|
||||
function snapPosition(
|
||||
pos: { x: number; y: number },
|
||||
guides: SmartGuide[],
|
||||
box: { width: number; height: number },
|
||||
threshold: number,
|
||||
): { x: number; y: number } {
|
||||
let { x, y } = pos;
|
||||
|
||||
for (const g of guides) {
|
||||
if (g.orientation === "vertical") {
|
||||
if (Math.abs(x - g.position) < threshold) x = g.position;
|
||||
else if (Math.abs(x + box.width / 2 - g.position) < threshold) x = g.position - box.width / 2;
|
||||
else if (Math.abs(x + box.width - g.position) < threshold) x = g.position - box.width;
|
||||
} else {
|
||||
if (Math.abs(y - g.position) < threshold) y = g.position;
|
||||
else if (Math.abs(y + box.height / 2 - g.position) < threshold)
|
||||
y = g.position - box.height / 2;
|
||||
else if (Math.abs(y + box.height - g.position) < threshold) y = g.position - box.height;
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported helpers for alignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function alignObjects(
|
||||
direction:
|
||||
| "left"
|
||||
| "center-h"
|
||||
| "right"
|
||||
| "top"
|
||||
| "center-v"
|
||||
| "bottom"
|
||||
| "distribute-h"
|
||||
| "distribute-v",
|
||||
objectIds: string[],
|
||||
objects: { id: string; attrs: Record<string, unknown> }[],
|
||||
updateObject: (id: string, attrs: Record<string, unknown>) => void,
|
||||
): void {
|
||||
const selected = objects.filter((o) => objectIds.includes(o.id));
|
||||
if (selected.length < 2 && !direction.startsWith("distribute")) return;
|
||||
if (selected.length < 3 && direction.startsWith("distribute")) return;
|
||||
|
||||
const bounds = selected.map((o) => ({
|
||||
id: o.id,
|
||||
x: (o.attrs.x as number) ?? 0,
|
||||
y: (o.attrs.y as number) ?? 0,
|
||||
w: (o.attrs.width as number) ?? 0,
|
||||
h: (o.attrs.height as number) ?? 0,
|
||||
}));
|
||||
|
||||
switch (direction) {
|
||||
case "left": {
|
||||
const minX = Math.min(...bounds.map((b) => b.x));
|
||||
for (const b of bounds) updateObject(b.id, { x: minX });
|
||||
break;
|
||||
}
|
||||
case "center-h": {
|
||||
const minX = Math.min(...bounds.map((b) => b.x));
|
||||
const maxX = Math.max(...bounds.map((b) => b.x + b.w));
|
||||
const center = (minX + maxX) / 2;
|
||||
for (const b of bounds) updateObject(b.id, { x: center - b.w / 2 });
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
const maxX = Math.max(...bounds.map((b) => b.x + b.w));
|
||||
for (const b of bounds) updateObject(b.id, { x: maxX - b.w });
|
||||
break;
|
||||
}
|
||||
case "top": {
|
||||
const minY = Math.min(...bounds.map((b) => b.y));
|
||||
for (const b of bounds) updateObject(b.id, { y: minY });
|
||||
break;
|
||||
}
|
||||
case "center-v": {
|
||||
const minY = Math.min(...bounds.map((b) => b.y));
|
||||
const maxY = Math.max(...bounds.map((b) => b.y + b.h));
|
||||
const center = (minY + maxY) / 2;
|
||||
for (const b of bounds) updateObject(b.id, { y: center - b.h / 2 });
|
||||
break;
|
||||
}
|
||||
case "bottom": {
|
||||
const maxY = Math.max(...bounds.map((b) => b.y + b.h));
|
||||
for (const b of bounds) updateObject(b.id, { y: maxY - b.h });
|
||||
break;
|
||||
}
|
||||
case "distribute-h": {
|
||||
const sorted = [...bounds].sort((a, b) => a.x - b.x);
|
||||
const totalW = sorted.reduce((s, b) => s + b.w, 0);
|
||||
const minX = sorted[0].x;
|
||||
const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].w;
|
||||
const gap = (maxX - minX - totalW) / (sorted.length - 1);
|
||||
let cx = minX;
|
||||
for (const b of sorted) {
|
||||
updateObject(b.id, { x: cx });
|
||||
cx += b.w + gap;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "distribute-v": {
|
||||
const sorted = [...bounds].sort((a, b) => a.y - b.y);
|
||||
const totalH = sorted.reduce((s, b) => s + b.h, 0);
|
||||
const minY = sorted[0].y;
|
||||
const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].h;
|
||||
const gap = (maxY - minY - totalH) / (sorted.length - 1);
|
||||
let cy = minY;
|
||||
for (const b of sorted) {
|
||||
updateObject(b.id, { y: cy });
|
||||
cy += b.h + gap;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useMoveTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MoveToolApi {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
smartGuides: SmartGuide[];
|
||||
onSelect: (e: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onStageClick: (e: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onDragStart: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onTransformEnd: (e: Konva.KonvaEventObject<Event>) => void;
|
||||
nudge: (dx: number, dy: number) => void;
|
||||
}
|
||||
|
||||
export function useMoveTool(): MoveToolApi {
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
const smartGuidesRef = useRef<SmartGuide[]>([]);
|
||||
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const setSelectedObjects = useEditorStore((s) => s.setSelectedObjects);
|
||||
const updateObject = useEditorStore((s) => s.updateObject);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const snapToGuides = useEditorStore((s) => s.snapToGuides);
|
||||
|
||||
// Attach transformer to selected nodes
|
||||
useEffect(() => {
|
||||
const tr = transformerRef.current;
|
||||
if (!tr) 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();
|
||||
}, [selectedObjectIds]);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const target = e.target;
|
||||
const id = target.id();
|
||||
if (!id) return;
|
||||
|
||||
if (e.evt.shiftKey) {
|
||||
// Toggle multi-select
|
||||
if (selectedObjectIds.includes(id)) {
|
||||
setSelectedObjects(selectedObjectIds.filter((i) => i !== id));
|
||||
} else {
|
||||
setSelectedObjects([...selectedObjectIds, id]);
|
||||
}
|
||||
} else {
|
||||
setSelectedObjects([id]);
|
||||
}
|
||||
},
|
||||
[selectedObjectIds, setSelectedObjects],
|
||||
);
|
||||
|
||||
const onStageClick = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// Clicked on stage background - deselect
|
||||
if (e.target === e.target.getStage()) {
|
||||
setSelectedObjects([]);
|
||||
}
|
||||
},
|
||||
[setSelectedObjects],
|
||||
);
|
||||
|
||||
const onDragStart = useCallback((_e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
// No-op -- selection already handled by onClick
|
||||
}, []);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const node = e.target;
|
||||
if (!snapToGuides) return;
|
||||
|
||||
const stage = node.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const allNodes = stage
|
||||
.find("Rect, Ellipse, Text, Image, Line, Arrow, RegularPolygon, Star")
|
||||
.filter((n) => n.id() && n.id() !== node.id());
|
||||
|
||||
const guides = findSmartGuides(node, allNodes, canvasSize.width, canvasSize.height, 5);
|
||||
smartGuidesRef.current = guides;
|
||||
|
||||
if (guides.length > 0) {
|
||||
const box = node.getClientRect({
|
||||
relativeTo: node.getParent() ?? undefined,
|
||||
});
|
||||
const snapped = snapPosition(
|
||||
{ x: node.x(), y: node.y() },
|
||||
guides,
|
||||
{ width: box.width, height: box.height },
|
||||
5,
|
||||
);
|
||||
node.position(snapped);
|
||||
}
|
||||
},
|
||||
[canvasSize, snapToGuides],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const node = e.target;
|
||||
smartGuidesRef.current = [];
|
||||
updateObject(node.id(), {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
});
|
||||
},
|
||||
[updateObject],
|
||||
);
|
||||
|
||||
const onTransformEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<Event>) => {
|
||||
const node = e.target;
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
|
||||
// Normalize scale into width/height
|
||||
const newWidth = Math.max(1, node.width() * scaleX);
|
||||
const newHeight = Math.max(1, node.height() * scaleY);
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
|
||||
updateObject(node.id(), {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
rotation: node.rotation(),
|
||||
});
|
||||
},
|
||||
[updateObject],
|
||||
);
|
||||
|
||||
const nudge = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
updateObject(id, {
|
||||
x: ((obj.attrs.x as number) ?? 0) + dx,
|
||||
y: ((obj.attrs.y as number) ?? 0) + dy,
|
||||
});
|
||||
}
|
||||
},
|
||||
[selectedObjectIds, updateObject],
|
||||
);
|
||||
|
||||
return {
|
||||
transformerRef,
|
||||
smartGuides: smartGuidesRef.current,
|
||||
onSelect,
|
||||
onStageClick,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragEnd,
|
||||
onTransformEnd,
|
||||
nudge,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MoveToolTransformer -- Konva Transformer component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MoveToolTransformer({
|
||||
transformerRef,
|
||||
}: {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
}) {
|
||||
return (
|
||||
<Transformer
|
||||
ref={transformerRef}
|
||||
rotateEnabled
|
||||
flipEnabled
|
||||
keepRatio={false}
|
||||
anchorSize={8}
|
||||
anchorStroke="#3b82f6"
|
||||
anchorFill="#ffffff"
|
||||
anchorCornerRadius={2}
|
||||
borderStroke="#3b82f6"
|
||||
borderStrokeWidth={1}
|
||||
padding={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Ellipse, Group, Line, Rect } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { SelectionMode, SelectionState, SelectionType } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marching ants animation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DASH = [6, 4];
|
||||
const MARCH_SPEED = 1;
|
||||
|
||||
function useMarchingAnts(layerRef: React.RefObject<Konva.Layer | null>) {
|
||||
const dashOffsetRef = useRef(0);
|
||||
const animRef = useRef<Konva.Animation | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
// Dynamically import Konva for Animation
|
||||
import("konva").then((KonvaModule) => {
|
||||
const anim = new KonvaModule.default.Animation(() => {
|
||||
dashOffsetRef.current -= MARCH_SPEED;
|
||||
}, layer);
|
||||
animRef.current = anim;
|
||||
anim.start();
|
||||
});
|
||||
|
||||
return () => {
|
||||
animRef.current?.stop();
|
||||
};
|
||||
}, [layerRef]);
|
||||
|
||||
return dashOffsetRef;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Magic wand -- flood fill to generate selection mask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function floodFillMask(
|
||||
imageData: ImageData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
tolerance: number,
|
||||
contiguous: boolean,
|
||||
): boolean[][] {
|
||||
const { width, height, data } = imageData;
|
||||
const mask: boolean[][] = Array.from(
|
||||
{ length: height },
|
||||
() => Array(width).fill(false) as boolean[],
|
||||
);
|
||||
|
||||
const sx = Math.round(startX);
|
||||
const sy = Math.round(startY);
|
||||
if (sx < 0 || sx >= width || sy < 0 || sy >= height) return mask;
|
||||
|
||||
const idx = (sy * width + sx) * 4;
|
||||
const targetR = data[idx];
|
||||
const targetG = data[idx + 1];
|
||||
const targetB = data[idx + 2];
|
||||
|
||||
function colorDist(i: number): number {
|
||||
const dr = data[i] - targetR;
|
||||
const dg = data[i + 1] - targetG;
|
||||
const db = data[i + 2] - targetB;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
if (contiguous) {
|
||||
// Scanline flood fill
|
||||
const stack: [number, number][] = [[sx, sy]];
|
||||
while (stack.length > 0) {
|
||||
const item = stack.pop();
|
||||
if (!item) break;
|
||||
const [cx, cy] = item;
|
||||
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
|
||||
if (mask[cy][cx]) continue;
|
||||
const ci = (cy * width + cx) * 4;
|
||||
if (colorDist(ci) > tolerance) continue;
|
||||
mask[cy][cx] = true;
|
||||
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
||||
}
|
||||
} else {
|
||||
// Select all matching pixels
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const ci = (y * width + x) * 4;
|
||||
if (colorDist(ci) <= tolerance) {
|
||||
mask[y][x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
function maskToBounds(
|
||||
mask: boolean[][],
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
let found = false;
|
||||
|
||||
for (let y = 0; y < mask.length; y++) {
|
||||
for (let x = 0; x < mask[y].length; x++) {
|
||||
if (mask[y][x]) {
|
||||
found = true;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return null;
|
||||
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection mask modification utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function expandMask(mask: boolean[][], amount: number): boolean[][] {
|
||||
const h = mask.length;
|
||||
const w = mask[0]?.length ?? 0;
|
||||
const result: boolean[][] = Array.from({ length: h }, () => Array(w).fill(false) as boolean[]);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!mask[y][x]) continue;
|
||||
for (let dy = -amount; dy <= amount; dy++) {
|
||||
for (let dx = -amount; dx <= amount; dx++) {
|
||||
const ny = y + dy;
|
||||
const nx = x + dx;
|
||||
if (ny >= 0 && ny < h && nx >= 0 && nx < w) {
|
||||
if (dx * dx + dy * dy <= amount * amount) {
|
||||
result[ny][nx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function contractMask(mask: boolean[][], amount: number): boolean[][] {
|
||||
const inverted = mask.map((row) => row.map((v) => !v));
|
||||
const expanded = expandMask(inverted, amount);
|
||||
return expanded.map((row) => row.map((v) => !v));
|
||||
}
|
||||
|
||||
export function featherMask(mask: boolean[][], radius: number): number[][] {
|
||||
const h = mask.length;
|
||||
const w = mask[0]?.length ?? 0;
|
||||
const result: number[][] = Array.from({ length: h }, () => Array(w).fill(0) as number[]);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (mask[y][x]) {
|
||||
result[y][x] = 1;
|
||||
continue;
|
||||
}
|
||||
// Distance to nearest mask pixel within radius
|
||||
let minDist = radius + 1;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const ny = y + dy;
|
||||
const nx = x + dx;
|
||||
if (ny >= 0 && ny < h && nx >= 0 && nx < w && mask[ny][nx]) {
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
minDist = Math.min(minDist, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (minDist <= radius) {
|
||||
result[y][x] = 1 - minDist / radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function invertMask(mask: boolean[][]): boolean[][] {
|
||||
return mask.map((row) => row.map((v) => !v));
|
||||
}
|
||||
|
||||
/** Ray-casting point-in-polygon test */
|
||||
export function pointInPolygon(x: number, y: number, points: number[]): boolean {
|
||||
let inside = false;
|
||||
const n = points.length / 2;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = points[i * 2];
|
||||
const yi = points[i * 2 + 1];
|
||||
const xj = points[j * 2];
|
||||
const yj = points[j * 2 + 1];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useSelectionTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SelectionToolApi {
|
||||
selectionType: SelectionType;
|
||||
setSelectionType: (t: SelectionType) => void;
|
||||
isDrawing: boolean;
|
||||
currentPoints: number[];
|
||||
onMouseDown: (pos: { x: number; y: number }, stage?: Konva.Stage) => void;
|
||||
onMouseMove: (pos: { x: number; y: number }) => void;
|
||||
onMouseUp: () => void;
|
||||
onDoubleClick: () => void;
|
||||
selectAll: () => void;
|
||||
deselect: () => void;
|
||||
magicWandSelect: (
|
||||
stage: Konva.Stage,
|
||||
x: number,
|
||||
y: number,
|
||||
tolerance: number,
|
||||
contiguous: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useSelectionTool(): SelectionToolApi {
|
||||
const [selectionType, setSelectionType] = useState<SelectionType>("rect");
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentPoints, setCurrentPoints] = useState<number[]>([]);
|
||||
const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
const setSelection = useEditorStore((s) => s.setSelection);
|
||||
const selectionMode = useEditorStore((s) => s.selectionMode);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const existingSelection = useEditorStore((s) => s.selection);
|
||||
|
||||
const mergeSelection = useCallback(
|
||||
(newSel: SelectionState, mode: SelectionMode) => {
|
||||
if (mode === "new" || !existingSelection) {
|
||||
setSelection(newSel);
|
||||
return;
|
||||
}
|
||||
|
||||
const eb = existingSelection.bounds;
|
||||
const nb = newSel.bounds;
|
||||
|
||||
if (mode === "add") {
|
||||
const x = Math.min(eb.x, nb.x);
|
||||
const y = Math.min(eb.y, nb.y);
|
||||
setSelection({
|
||||
...newSel,
|
||||
bounds: {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(eb.x + eb.width, nb.x + nb.width) - x,
|
||||
height: Math.max(eb.y + eb.height, nb.y + nb.height) - y,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// subtract: use the new bounds minus overlap (simplified)
|
||||
setSelection(newSel);
|
||||
}
|
||||
},
|
||||
[existingSelection, setSelection],
|
||||
);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(pos: { x: number; y: number }, _stage?: Konva.Stage) => {
|
||||
setIsDrawing(true);
|
||||
startRef.current = pos;
|
||||
if (selectionType === "lasso") {
|
||||
setCurrentPoints([pos.x, pos.y]);
|
||||
} else {
|
||||
setCurrentPoints([]);
|
||||
}
|
||||
},
|
||||
[selectionType],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(pos: { x: number; y: number }) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
if (selectionType === "lasso") {
|
||||
setCurrentPoints((prev) => [...prev, pos.x, pos.y]);
|
||||
} else {
|
||||
const s = startRef.current;
|
||||
setCurrentPoints([s.x, s.y, pos.x, pos.y]);
|
||||
}
|
||||
},
|
||||
[isDrawing, selectionType],
|
||||
);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (!isDrawing) return;
|
||||
setIsDrawing(false);
|
||||
|
||||
if (selectionType === "lasso") {
|
||||
if (currentPoints.length < 6) {
|
||||
setSelection(null);
|
||||
setCurrentPoints([]);
|
||||
return;
|
||||
}
|
||||
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
||||
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
||||
const bounds = {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
};
|
||||
mergeSelection({ type: "lasso", points: currentPoints, bounds }, selectionMode);
|
||||
} else {
|
||||
if (currentPoints.length < 4) {
|
||||
setCurrentPoints([]);
|
||||
return;
|
||||
}
|
||||
const [x1, y1, x2, y2] = currentPoints;
|
||||
const x = Math.min(x1, x2);
|
||||
const y = Math.min(y1, y2);
|
||||
const w = Math.abs(x2 - x1);
|
||||
const h = Math.abs(y2 - y1);
|
||||
if (w < 2 || h < 2) {
|
||||
setSelection(null);
|
||||
setCurrentPoints([]);
|
||||
return;
|
||||
}
|
||||
mergeSelection(
|
||||
{
|
||||
type: selectionType,
|
||||
points: [],
|
||||
bounds: { x, y, width: w, height: h },
|
||||
},
|
||||
selectionMode,
|
||||
);
|
||||
}
|
||||
setCurrentPoints([]);
|
||||
}, [isDrawing, currentPoints, selectionType, selectionMode, mergeSelection, setSelection]);
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
// Close polygonal lasso
|
||||
if (selectionType === "lasso" && currentPoints.length >= 6) {
|
||||
setIsDrawing(false);
|
||||
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
||||
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
||||
mergeSelection(
|
||||
{
|
||||
type: "lasso",
|
||||
points: currentPoints,
|
||||
bounds: {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
},
|
||||
},
|
||||
selectionMode,
|
||||
);
|
||||
setCurrentPoints([]);
|
||||
}
|
||||
}, [selectionType, currentPoints, selectionMode, mergeSelection]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelection({
|
||||
type: "rect",
|
||||
points: [],
|
||||
bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height },
|
||||
});
|
||||
}, [canvasSize, setSelection]);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
setSelection(null);
|
||||
}, [setSelection]);
|
||||
|
||||
const magicWandSelect = useCallback(
|
||||
(stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => {
|
||||
const canvas = stage.toCanvas();
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const mask = floodFillMask(imageData, x, y, tolerance, contiguous);
|
||||
const bounds = maskToBounds(mask);
|
||||
if (!bounds) return;
|
||||
mergeSelection({ type: "rect", points: [], bounds }, selectionMode);
|
||||
},
|
||||
[selectionMode, mergeSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
selectionType,
|
||||
setSelectionType,
|
||||
isDrawing,
|
||||
currentPoints,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onDoubleClick,
|
||||
selectAll,
|
||||
deselect,
|
||||
magicWandSelect,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectionOverlay -- renders selection outline with marching ants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject<Konva.Layer | null> }) {
|
||||
const selection = useEditorStore((s) => s.selection);
|
||||
const dashOffset = useMarchingAnts(layerRef);
|
||||
|
||||
if (!selection) return null;
|
||||
|
||||
const { type, bounds, points } = selection;
|
||||
|
||||
if (type === "lasso" && points.length >= 6) {
|
||||
return (
|
||||
<Group>
|
||||
<Line
|
||||
points={points}
|
||||
closed
|
||||
stroke="#000000"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current}
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={points}
|
||||
closed
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current + DASH[0]}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "ellipse") {
|
||||
const rx = bounds.width / 2;
|
||||
const ry = bounds.height / 2;
|
||||
return (
|
||||
<Group>
|
||||
<Ellipse
|
||||
x={bounds.x + rx}
|
||||
y={bounds.y + ry}
|
||||
radiusX={rx}
|
||||
radiusY={ry}
|
||||
stroke="#000000"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current}
|
||||
listening={false}
|
||||
/>
|
||||
<Ellipse
|
||||
x={bounds.x + rx}
|
||||
y={bounds.y + ry}
|
||||
radiusX={rx}
|
||||
radiusY={ry}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current + DASH[0]}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// Rectangular selection
|
||||
return (
|
||||
<Group>
|
||||
<Rect
|
||||
x={bounds.x}
|
||||
y={bounds.y}
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
stroke="#000000"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current}
|
||||
listening={false}
|
||||
/>
|
||||
<Rect
|
||||
x={bounds.x}
|
||||
y={bounds.y}
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current + DASH[0]}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActiveSelectionPreview -- rendered during drag to show selection shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ActiveSelectionPreview({
|
||||
type,
|
||||
points,
|
||||
}: {
|
||||
type: SelectionType;
|
||||
points: number[];
|
||||
}) {
|
||||
if (type === "lasso" && points.length >= 4) {
|
||||
return (
|
||||
<Line points={points} stroke="#3b82f6" strokeWidth={1} dash={[4, 4]} listening={false} />
|
||||
);
|
||||
}
|
||||
|
||||
if (points.length < 4) return null;
|
||||
|
||||
const [x1, y1, x2, y2] = points;
|
||||
const x = Math.min(x1, x2);
|
||||
const y = Math.min(y1, y2);
|
||||
const w = Math.abs(x2 - x1);
|
||||
const h = Math.abs(y2 - y1);
|
||||
|
||||
if (type === "ellipse") {
|
||||
return (
|
||||
<Ellipse
|
||||
x={x + w / 2}
|
||||
y={y + h / 2}
|
||||
radiusX={w / 2}
|
||||
radiusY={h / 2}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
dash={[4, 4]}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={h}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
dash={[4, 4]}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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