diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 28d7ffc..626d25a 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -14,6 +14,7 @@ import GuidesLayer from "./canvas/GuidesLayer"; import { getSnappedPosition, type Guide } from "./canvas/useSnap"; import CellEditor from "@/components/CellEditor"; import ZoomControls from "./ZoomControls"; +import { createScopedId } from "../store/editorStore.helpers"; type EditorCanvasProps = { catalogSize?: string; @@ -24,10 +25,6 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { const layerRef = useRef(null); const activeGuideIdsRef = useRef([]); const stageWrapRef = useRef(null); - const draggingGuideRef = useRef<{ - id: string; - orientation: Guide["orientation"]; - } | null>(null); const { tool, @@ -100,7 +97,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { }; const addGuide = (orientation: Guide["orientation"], position: number) => { - const id = `guide-${orientation}-${crypto.randomUUID()}`; + const id = createScopedId(`guide-${orientation}`); setGuides([...guides, { id, orientation, position }]); setSelectedGuideId(id); }; @@ -112,7 +109,6 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { }; const handleGuideDragEnd = (id: string, position: number, shouldRemove: boolean) => { - draggingGuideRef.current = null; if (shouldRemove) { removeGuide(id); return; @@ -120,51 +116,6 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { handleGuidePositionChange(id, position); }; - useEffect(() => { - const forceFinishGuideDrag = () => { - const draggingGuide = draggingGuideRef.current; - if (!draggingGuide) return; - - const stage = stageRef.current; - if (!stage) return; - - const node = stage.findOne(`#${draggingGuide.id}`) as Konva.Node | null; - if (!node) { - draggingGuideRef.current = null; - return; - } - - if (node.isDragging()) { - node.stopDrag(); - } - - const nextPosition = - draggingGuide.orientation === "vertical" - ? node.x() - : node.y(); - const shouldRemove = - draggingGuide.orientation === "vertical" - ? nextPosition < 0 || nextPosition > stageSize.width - : nextPosition < 0 || nextPosition > stageSize.height; - - handleGuideDragEnd(draggingGuide.id, nextPosition, shouldRemove); - }; - - window.addEventListener("pointerup", forceFinishGuideDrag); - window.addEventListener("mouseup", forceFinishGuideDrag); - window.addEventListener("touchend", forceFinishGuideDrag); - window.addEventListener("pointercancel", forceFinishGuideDrag); - window.addEventListener("blur", forceFinishGuideDrag); - - return () => { - window.removeEventListener("pointerup", forceFinishGuideDrag); - window.removeEventListener("mouseup", forceFinishGuideDrag); - window.removeEventListener("touchend", forceFinishGuideDrag); - window.removeEventListener("pointercancel", forceFinishGuideDrag); - window.removeEventListener("blur", forceFinishGuideDrag); - }; - }, [stageSize.height, stageSize.width]); - useEffect(() => { const handleDeleteGuide = (e: KeyboardEvent) => { if (!selectedGuideId) return; @@ -396,10 +347,6 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { selectedGuideId={selectedGuideId} draftGuide={draftGuide} onGuideSelect={setSelectedGuideId} - onGuideDragStart={(id, orientation) => { - draggingGuideRef.current = { id, orientation }; - setSelectedGuideId(id); - }} onGuideDragEnd={handleGuideDragEnd} /> diff --git a/src/pages/editor/components/canvas/GuidesLayer.tsx b/src/pages/editor/components/canvas/GuidesLayer.tsx index 875275e..26615f6 100644 --- a/src/pages/editor/components/canvas/GuidesLayer.tsx +++ b/src/pages/editor/components/canvas/GuidesLayer.tsx @@ -1,6 +1,22 @@ +import { useEffect, useRef } from "react"; +import Konva from "konva"; import { Layer, Line } from "react-konva"; import type { Guide } from "./useSnap"; +/** پروداکشن: `sessionStorage.setItem('DEBUG_GUIDE_DRAG','1')` سپس رفرش */ +function guideDragLog(stage: string, detail: Record): void { + if (typeof window === "undefined") return; + const dev = import.meta.env.DEV; + let enabled = dev; + try { + if (!enabled) enabled = window.sessionStorage.getItem("DEBUG_GUIDE_DRAG") === "1"; + } catch { + /* private mode */ + } + if (!enabled) return; + console.info("[guide-drag]", new Date().toISOString(), stage, detail); +} + type GuidesLayerProps = { guides: Guide[]; stageWidth: number; @@ -9,7 +25,6 @@ type GuidesLayerProps = { selectedGuideId: string | null; draftGuide: { orientation: Guide["orientation"]; position: number } | null; onGuideSelect: (id: string) => void; - onGuideDragStart: (id: string, orientation: Guide["orientation"]) => void; onGuideDragEnd: (id: string, position: number, shouldRemove: boolean) => void; }; @@ -21,23 +36,155 @@ const GuidesLayer = ({ selectedGuideId, draftGuide, onGuideSelect, - onGuideDragStart, onGuideDragEnd, }: GuidesLayerProps) => { + const abortActiveSessionRef = useRef<(() => void) | null>(null); + + useEffect( + () => () => { + abortActiveSessionRef.current?.(); + abortActiveSessionRef.current = null; + }, + [], + ); + const finalizeGuideDrag = ( guideId: string, orientation: Guide["orientation"], x: number, y: number, + reason?: string, ) => { const nextPosition = orientation === "vertical" ? x : y; const shouldRemove = orientation === "vertical" ? nextPosition < 0 || nextPosition > stageWidth : nextPosition < 0 || nextPosition > stageHeight; + guideDragLog("finalize", { + guideId, + orientation, + x, + y, + nextPosition, + shouldRemove, + reason, + }); onGuideDragEnd(guideId, nextPosition, shouldRemove); }; + const attachPointerGuideDrag = ( + guide: Guide, + e: Konva.KonvaEventObject, + ) => { + const evt = e.evt; + if (evt.pointerType === "mouse" && evt.button !== 0) return; + + const node = e.target as Konva.Line; + const stage = node.getStage(); + if (!stage) { + guideDragLog("abort-no-stage", { guideId: guide.id }); + return; + } + + const container = stage.container(); + + abortActiveSessionRef.current?.(); + abortActiveSessionRef.current = null; + + let settled = false; + const settle = (reason: string) => { + if (settled) return; + settled = true; + guideDragLog("settle", { guideId: guide.id, pointerId: evt.pointerId, reason }); + + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerEnd); + window.removeEventListener("pointercancel", onPointerEnd); + window.removeEventListener("blur", onBlur); + document.removeEventListener("visibilitychange", onVisibilityHidden); + + try { + if (typeof container.releasePointerCapture === "function") { + container.releasePointerCapture(evt.pointerId); + } + } catch { + /* ignore */ + } + + abortActiveSessionRef.current = null; + + const x = node.x(); + const y = node.y(); + const nextPosition = guide.orientation === "vertical" ? x : y; + const guidesWillRemove = + guide.orientation === "vertical" + ? nextPosition < 0 || nextPosition > stageWidth + : nextPosition < 0 || nextPosition > stageHeight; + + if (!guidesWillRemove) { + onGuideSelect(guide.id); + } + + finalizeGuideDrag(guide.id, guide.orientation, x, y, reason); + }; + + const onPointerMove = (moveEvt: PointerEvent) => { + if (moveEvt.pointerId !== evt.pointerId) return; + moveEvt.preventDefault(); + const pp = stage.getPointerPosition(); + if (!pp) { + guideDragLog("move-no-pointer-pos", {}); + return; + } + const x = pp.x / stage.scaleX(); + const y = pp.y / stage.scaleY(); + + const isVertical = guide.orientation === "vertical"; + if (isVertical) { + node.x(x); + node.y(0); + } else { + node.x(0); + node.y(y); + } + node.getLayer()?.batchDraw(); + }; + + const onPointerEnd = (endEvt: PointerEvent) => { + if (endEvt.pointerId !== evt.pointerId) return; + settle(endEvt.type === "pointercancel" ? "pointercancel" : "pointerup"); + }; + + const onBlur = () => settle("window-blur"); + + const onVisibilityHidden = () => { + if (document.visibilityState === "hidden") settle("document-hidden"); + }; + + abortActiveSessionRef.current = () => settle("session-aborted"); + + window.addEventListener("pointermove", onPointerMove, { passive: false }); + window.addEventListener("pointerup", onPointerEnd); + window.addEventListener("pointercancel", onPointerEnd); + window.addEventListener("blur", onBlur); + document.addEventListener("visibilitychange", onVisibilityHidden); + + guideDragLog("pointerdown-attached", { + guideId: guide.id, + pointerId: evt.pointerId, + pointerType: evt.pointerType, + }); + + try { + container.setPointerCapture(evt.pointerId); + } catch (err) { + guideDragLog("set-pointer-capture-failed", { + pointerId: evt.pointerId, + message: String(err), + }); + } + }; + return ( {guides.map((guide) => { @@ -56,27 +203,12 @@ const GuidesLayer = ({ stroke={isSelected ? "#2563eb" : isActive ? "#1d4ed8" : "#60a5fa"} strokeWidth={isSelected ? 2.5 : isActive ? 2 : 1} dash={[6, 6]} - hitStrokeWidth={10} - draggable - dragBoundFunc={(pos) => - isVertical ? { x: pos.x, y: 0 } : { x: 0, y: pos.y } - } - onMouseDown={() => onGuideSelect(guide.id)} - onDragStart={() => onGuideDragStart(guide.id, guide.orientation)} - onDragMove={(e) => { - const nativeEvent = e.evt as MouseEvent | PointerEvent | TouchEvent; - // Safari can miss dragend/mouseup; if no pressed pointer exists, end drag immediately. - if ("buttons" in nativeEvent && nativeEvent.buttons === 0) { - const node = e.target; - if (node.isDragging()) { - node.stopDrag(); - } - finalizeGuideDrag(guide.id, guide.orientation, node.x(), node.y()); - } - }} - onDragEnd={(e) => { - const node = e.target; - finalizeGuideDrag(guide.id, guide.orientation, node.x(), node.y()); + hitStrokeWidth={12} + listening + draggable={false} + onPointerDown={(e) => { + e.cancelBubble = true; + attachPointerGuideDrag(guide, e); }} /> ); @@ -85,6 +217,7 @@ const GuidesLayer = ({ allObjectIdsToGroup.has(obj.id) && obj.type !== "group", ); diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts index 9c46931..7d08678 100644 --- a/src/pages/editor/store/editorStore.ts +++ b/src/pages/editor/store/editorStore.ts @@ -121,7 +121,7 @@ export const useEditorStore = create((set, get) => { const sourceIds = new Set(sourceObjects.map((obj) => obj.id)); const idMap = new Map(); sourceObjects.forEach((obj) => { - idMap.set(obj.id, `${obj.id}-${crypto.randomUUID()}`); + idMap.set(obj.id, createScopedId(obj.id)); }); return sourceObjects.map((objectToClone) => ({ @@ -136,13 +136,13 @@ export const useEditorStore = create((set, get) => { ...(objectToClone.tableData && { tableData: { ...objectToClone.tableData, - id: `table-${crypto.randomUUID()}`, + id: createScopedId("table"), cells: Object.fromEntries( Object.entries(objectToClone.tableData.cells).map(([key, cell]) => [ key, { ...cell, - id: `cell-${crypto.randomUUID()}`, + id: createScopedId("cell"), }, ]), ), @@ -384,7 +384,7 @@ export const useEditorStore = create((set, get) => { editingCell: null, setEditingCell: (cell) => set({ editingCell: cell }), addTable: (x, y, rows = 4, cols = 5) => { - const tableId = `table-${crypto.randomUUID()}`; + const tableId = createScopedId("table"); const cellWidth = 100; const cellHeight = 50; const tableData: TableData = { @@ -626,7 +626,7 @@ export const useEditorStore = create((set, get) => { const duplicatedObjects = pageToDuplicate.objects.map((obj) => ({ ...obj, - id: `${obj.id}-${crypto.randomUUID()}`, + id: createScopedId(obj.id), })); const newPage: Page = {