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