Files
SnapOtter/apps/web/src/components/editor/tools/move-tool.tsx
T
SnapOtter bcd59c20c7 feat(web): apply Otter Orange design system and UI improvements
Adopt the landing page's Otter Orange (#E07832) design system across
the web app, replacing the generic blue (#2563eb) theme. Light mode
uses warm cream/stone neutrals, dark mode uses deep brown tones.

UI changes:
- Remove Quick Actions section from home page
- Add modality filter tabs to file-uploaded view
- Remove colored left-border accents from tool panels and grid cards
- Use standard dropzone for pdf-to-image (custom-results mode)
- Fix PDF document viewer to use ArrayBuffer instead of blob URL
- Fix missing FileImage import in dropzone
2026-06-14 14:06:25 +08:00

481 lines
14 KiB
TypeScript

import type Konva from "konva";
import { useCallback, useEffect, useRef } from "react";
import { Transformer } from "react-konva";
import type { SmartGuide } from "@/components/editor/common/smart-guides";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// 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,
canvasSize?: { width: number; height: number },
): void {
const selected = objects.filter((o) => objectIds.includes(o.id));
if (selected.length === 0) 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,
}));
// Single object: align relative to canvas bounds
if (selected.length === 1 && canvasSize) {
const b = bounds[0];
switch (direction) {
case "left":
updateObject(b.id, { x: 0 });
break;
case "center-h":
updateObject(b.id, { x: canvasSize.width / 2 - b.w / 2 });
break;
case "right":
updateObject(b.id, { x: canvasSize.width - b.w });
break;
case "top":
updateObject(b.id, { y: 0 });
break;
case "center-v":
updateObject(b.id, { y: canvasSize.height / 2 - b.h / 2 });
break;
case "bottom":
updateObject(b.id, { y: canvasSize.height - b.h });
break;
}
return;
}
if (selected.length < 2) return;
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.snappingEnabled);
// 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 id = node.id();
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Normalize scale into absolute dimensions
const newWidth = Math.max(1, node.width() * scaleX);
const newHeight = Math.max(1, node.height() * scaleY);
node.scaleX(1);
node.scaleY(1);
// Look up the object type to write the correct attributes
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 = newWidth / 2;
newAttrs.radiusY = newHeight / 2;
} else if (obj?.type === "polygon") {
// Convert to radius for polygons (average of width/height / 2)
newAttrs.radius = (newWidth + newHeight) / 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 = newWidth;
newAttrs.height = newHeight;
}
updateObject(id, newAttrs);
},
[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;
const attrs = obj.attrs as unknown as Record<string, unknown>;
updateObject(id, {
x: ((attrs.x as number) ?? 0) + dx,
y: ((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="#E07832"
anchorFill="#ffffff"
anchorCornerRadius={2}
borderStroke="#E07832"
borderStrokeWidth={1}
padding={2}
/>
);
}