From 08fe062b92c188153c0eb331807b76081e55be5f Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Thu, 12 Mar 2026 18:43:17 +0530 Subject: [PATCH] feat(review): add review-mode pointer handlers for pin click + draft placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pointerdown records position, pointermove detects drag (>5px) - pointerup fires review logic only if no drag occurred - Hit-test priority: existing pin → scene object → empty canvas - 10px screen-space click radius for forgiving object detection - Permission gating: viewer role cannot place draft pins --- frontend/src/pages/Editor.tsx | 86 +++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index ee9a602..416b3b3 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -208,6 +208,92 @@ export default function Editor({ isPublicView }: EditorProps) { pinOverlay.refresh(); }, [pinOverlay]); + // Review-mode pointer handling: pin click + draft pin placement + useEffect(() => { + const vp = canvasRef.current?.getViewport(); + if (!vp || !pinOverlay) return; + + let downX = 0; + let downY = 0; + let dragOccurred = false; + + const onPointerDown = (e: any) => { + const screen = e.global || e.data?.global; + if (!screen) return; + downX = screen.x; + downY = screen.y; + dragOccurred = false; + }; + + const onPointerMove = (e: any) => { + if (dragOccurred) return; + const screen = e.global || e.data?.global; + if (!screen) return; + const dx = screen.x - downX; + const dy = screen.y - downY; + if (dx * dx + dy * dy > 25) { // 5px threshold + dragOccurred = true; + } + }; + + const onPointerUp = (e: any) => { + if (dragOccurred) return; + if (!reviewMode) return; + + const screen = e.global || e.data?.global; + if (!screen) return; + const worldPos = vp.toWorld(screen.x, screen.y); + + // Priority 1: existing pin + const threadId = pinOverlay.getThreadIdAtPoint(worldPos.x, worldPos.y); + if (threadId) { + setFocusedThreadId(threadId); + setDraftPin(null); + expandSeqRef.current += 1; + setExpandRequest({ threadId, seq: expandSeqRef.current }); + return; + } + + // Priority 2: scene object (place draft pin) — only for editor/owner + const scene = canvasRef.current?.getScene(); + if (scene && userRole !== 'viewer') { + // Use 10px screen-space radius converted to world space for forgiving click detection + const clickRadius = 10 / vp.scale.x; + const hitItems = scene.queryRegion(worldPos.x - clickRadius, worldPos.y - clickRadius, clickRadius * 2, clickRadius * 2); + // Take the topmost (highest z) item + if (hitItems.length > 0) { + const item = hitItems.sort((a: any, b: any) => (b.data.z || 0) - (a.data.z || 0))[0]; + const bounds = getItemWorldBounds(item); + const pinX = Math.max(0, Math.min(1, (worldPos.x - bounds.x) / bounds.w)); + const pinY = Math.max(0, Math.min(1, (worldPos.y - bounds.y) / bounds.h)); + setDraftPin({ + objectId: item.id, + pinX, + pinY, + worldX: worldPos.x, + worldY: worldPos.y, + }); + setFocusedThreadId(null); + return; + } + } + + // Priority 3: empty canvas — clear draft and focus + setDraftPin(null); + setFocusedThreadId(null); + }; + + vp.on('pointerdown', onPointerDown); + vp.on('pointermove', onPointerMove); + vp.on('pointerup', onPointerUp); + + return () => { + vp.off('pointerdown', onPointerDown); + vp.off('pointermove', onPointerMove); + vp.off('pointerup', onPointerUp); + }; + }, [reviewMode, pinOverlay, userRole]); + // Tool activation useEffect(() => { const viewport = canvasRef.current?.getViewport();