From 4feccbf1f961cafeccfd07836fb20065ba55efb2 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 26 Apr 2026 10:50:22 +0330 Subject: [PATCH] guide line and magnet --- src/pages/editor/components/EditorCanvas.tsx | 209 +++++++++++++++++- .../editor/components/canvas/GuidesLayer.tsx | 81 +++++++ .../components/canvas/ObjectRenderer.tsx | 11 +- src/pages/editor/components/canvas/useSnap.ts | 44 ++++ .../components/tools/RectangleShape.tsx | 7 +- 5 files changed, 335 insertions(+), 17 deletions(-) create mode 100644 src/pages/editor/components/canvas/GuidesLayer.tsx create mode 100644 src/pages/editor/components/canvas/useSnap.ts diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index cc73b4a..3c25139 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Stage, Layer, Rect } from "react-konva"; import Konva from "konva"; import { useEditorStore } from "../store/editorStore"; @@ -10,6 +10,8 @@ import { useTransformHandlers } from "./canvas/useTransformHandlers"; import { useObjectHandlers } from "./canvas/useObjectHandlers"; import { useMaskGroupState } from "./canvas/useMaskGroupState"; import ObjectsLayer from "./canvas/ObjectsLayer"; +import GuidesLayer from "./canvas/GuidesLayer"; +import { getSnappedPosition, type Guide } from "./canvas/useSnap"; import CellEditor from "@/components/CellEditor"; import ZoomControls from "./ZoomControls"; @@ -20,6 +22,8 @@ type EditorCanvasProps = { const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { const stageRef = useRef(null); const layerRef = useRef(null); + const activeGuideIdsRef = useRef([]); + const stageWrapRef = useRef(null); const { tool, @@ -45,6 +49,14 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { useTransformHandlers(transformerRef, layerRef); + const [guides, setGuides] = useState([]); + const [activeGuideIds, setActiveGuideIds] = useState([]); + const [selectedGuideId, setSelectedGuideId] = useState(null); + const [draftGuide, setDraftGuide] = useState<{ + orientation: Guide["orientation"]; + position: number; + } | null>(null); + useEffect(() => { if (stageRef.current) { setStageRef(stageRef); @@ -56,19 +68,198 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { const finalScale = stageSize.scale * zoom; + const updateActiveGuides = (nextActiveGuideIds: string[]) => { + const prev = activeGuideIdsRef.current; + const changed = + prev.length !== nextActiveGuideIds.length || + prev.some((id, index) => id !== nextActiveGuideIds[index]); + + if (changed) { + activeGuideIdsRef.current = nextActiveGuideIds; + setActiveGuideIds(nextActiveGuideIds); + } + }; + + const handleGuidePositionChange = (id: string, position: number) => { + setGuides((prev) => + prev.map((guide) => + guide.id === id + ? { + ...guide, + position, + } + : guide, + ), + ); + }; + + const addGuide = (orientation: Guide["orientation"], position: number) => { + const id = `guide-${orientation}-${crypto.randomUUID()}`; + setGuides((prev) => [...prev, { id, orientation, position }]); + setSelectedGuideId(id); + }; + + const removeGuide = (id: string) => { + setGuides((prev) => prev.filter((guide) => guide.id !== id)); + setActiveGuideIds((prev) => prev.filter((activeId) => activeId !== id)); + setSelectedGuideId((prev) => (prev === id ? null : prev)); + }; + + const handleGuideDragEnd = (id: string, position: number, shouldRemove: boolean) => { + if (shouldRemove) { + removeGuide(id); + return; + } + handleGuidePositionChange(id, position); + }; + + useEffect(() => { + const handleDeleteGuide = (e: KeyboardEvent) => { + if (!selectedGuideId) return; + const target = e.target as HTMLElement; + if ( + target?.tagName === "INPUT" || + target?.tagName === "TEXTAREA" || + target?.isContentEditable + ) { + return; + } + + if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + removeGuide(selectedGuideId); + } + }; + + window.addEventListener("keydown", handleDeleteGuide); + return () => { + window.removeEventListener("keydown", handleDeleteGuide); + }; + }, [selectedGuideId]); + + const startRulerGuideDrag = (orientation: Guide["orientation"]) => { + const updateFromPointer = (event: MouseEvent) => { + const rect = stageWrapRef.current?.getBoundingClientRect(); + if (!rect) return null; + const rawPos = + orientation === "vertical" + ? (event.clientX - rect.left) / finalScale + : (event.clientY - rect.top) / finalScale; + return rawPos; + }; + + const updateDraft = (event: MouseEvent) => { + const pointerPos = updateFromPointer(event); + if (pointerPos === null) return; + const boundedPos = + orientation === "vertical" + ? Math.max(0, Math.min(stageSize.width, pointerPos)) + : Math.max(0, Math.min(stageSize.height, pointerPos)); + setDraftGuide({ orientation, position: boundedPos }); + }; + + const handleMouseMove = (event: MouseEvent) => { + updateDraft(event); + }; + + const handleMouseUp = (event: MouseEvent) => { + const rect = stageWrapRef.current?.getBoundingClientRect(); + if (!rect) { + setDraftGuide(null); + return; + } + + const isInside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (isInside) { + const pointerPos = updateFromPointer(event); + if (pointerPos !== null) { + const boundedPos = orientation === "vertical" + ? Math.max(0, Math.min(stageSize.width, pointerPos)) + : Math.max(0, Math.min(stageSize.height, pointerPos)); + addGuide(orientation, boundedPos); + } + } + + setDraftGuide(null); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp, { once: true }); + }; + + const handleDragMove = (e: Konva.KonvaEventObject) => { + const node = e.target; + if (!node || node.name() === "guide-line") return; + + const snapped = getSnappedPosition({ x: node.x(), y: node.y() }, guides); + + if (node.x() !== snapped.position.x || node.y() !== snapped.position.y) { + node.position(snapped.position); + node.getLayer()?.batchDraw(); + } + + updateActiveGuides(snapped.activeGuideIds); + }; + + const handleDragEnd = () => { + updateActiveGuides([]); + }; + + const handleStageMouseDownWithGuideReset = (e: Konva.KonvaEventObject) => { + if (e.target?.name() !== "guide-line") { + setSelectedGuideId(null); + } + handleStageMouseDown(e); + }; return (
-
+
+
+
{ + e.preventDefault(); + startRulerGuideDrag("vertical"); + }} + title="Drag to create vertical guide" + /> +
{ + e.preventDefault(); + startRulerGuideDrag("horizontal"); + }} + title="Drag to create horizontal guide" + /> +
+
{ onGroup={handleGroupWithMask} onUngroup={handleUngroupFromMask} /> + +
+
{editingCell && ( void; + onGuideDragEnd: (id: string, position: number, shouldRemove: boolean) => void; +}; + +const GuidesLayer = ({ + guides, + stageWidth, + stageHeight, + activeGuideIds, + selectedGuideId, + draftGuide, + onGuideSelect, + onGuideDragEnd, +}: GuidesLayerProps) => { + return ( + + {guides.map((guide) => { + const isVertical = guide.orientation === "vertical"; + const isActive = activeGuideIds.includes(guide.id); + const isSelected = selectedGuideId === guide.id; + + return ( + + isVertical ? { x: pos.x, y: 0 } : { x: 0, y: pos.y } + } + onMouseDown={() => onGuideSelect(guide.id)} + onDragEnd={(e) => { + const node = e.target; + const nextPosition = isVertical ? node.x() : node.y(); + const shouldRemove = isVertical + ? nextPosition < 0 || nextPosition > stageWidth + : nextPosition < 0 || nextPosition > stageHeight; + onGuideDragEnd(guide.id, nextPosition, shouldRemove); + }} + /> + ); + })} + {draftGuide && ( + + )} + + ); +}; + +export default GuidesLayer; diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx index 3694c0f..59d0731 100644 --- a/src/pages/editor/components/canvas/ObjectRenderer.tsx +++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx @@ -255,11 +255,8 @@ const ObjectRenderer = ({ } }} onDragMove={(e) => { - const node = e.target; - onUpdate(obj.id, { - x: node.x(), - y: node.y(), - }); + // Snapping is handled at Stage level for smoother dragging. + e.target.getLayer()?.batchDraw(); }} onDragEnd={(e) => { const node = e.target; @@ -308,10 +305,6 @@ const ObjectRenderer = ({ const handleGroupDragMove = (e: Konva.KonvaEventObject) => { const node = e.target; - onUpdate(obj.id, { - x: node.x(), - y: node.y(), - }); // به‌روزرسانی transformer در حین drag if (transformerRef.current && isSelected) { transformerRef.current.nodes([node]); diff --git a/src/pages/editor/components/canvas/useSnap.ts b/src/pages/editor/components/canvas/useSnap.ts new file mode 100644 index 0000000..8077d7f --- /dev/null +++ b/src/pages/editor/components/canvas/useSnap.ts @@ -0,0 +1,44 @@ +export type Guide = { + id: string; + orientation: "vertical" | "horizontal"; + position: number; +}; + +const SNAP_THRESHOLD = 5; + +export type SnappedPositionResult = { + position: { x: number; y: number }; + activeGuideIds: string[]; +}; + +export const getSnappedPosition = ( + pos: { x: number; y: number }, + guides: Guide[], +): SnappedPositionResult => { + let x = pos.x; + let y = pos.y; + const activeGuideIds: string[] = []; + + guides.forEach((guide) => { + if ( + guide.orientation === "vertical" && + Math.abs(pos.x - guide.position) < SNAP_THRESHOLD + ) { + x = guide.position; + activeGuideIds.push(guide.id); + } + + if ( + guide.orientation === "horizontal" && + Math.abs(pos.y - guide.position) < SNAP_THRESHOLD + ) { + y = guide.position; + activeGuideIds.push(guide.id); + } + }); + + return { + position: { x, y }, + activeGuideIds, + }; +}; diff --git a/src/pages/editor/components/tools/RectangleShape.tsx b/src/pages/editor/components/tools/RectangleShape.tsx index 1c12b21..c1d7eee 100644 --- a/src/pages/editor/components/tools/RectangleShape.tsx +++ b/src/pages/editor/components/tools/RectangleShape.tsx @@ -48,11 +48,8 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap } }} onDragMove={(e) => { - const node = e.target; - onUpdate(obj.id, { - x: node.x(), - y: node.y(), - }); + // Snapping is applied at Stage level, avoid store updates per frame. + e.target.getLayer()?.batchDraw(); }} onDragEnd={(e) => { const node = e.target;