feat(review): group-aware comment targeting — resolve clicks to stable children

New reviewTargeting.ts provides resolveReviewTargetAtPoint() which:
- descends into groups to find the deepest commentable child under the click
- walks children in z-order (front-to-back) for correct visual stacking
- handles nested groups recursively
- returns null if no valid child exists under the point

Editor.tsx review click handler now delegates to the resolver instead of
raw queryRegion + topmost-z pick. Defensive guard rejects group objectIds
before draft creation.

Existing group-anchored threads continue to render and open normally.
This commit is contained in:
Hiren Kangad
2026-03-12 22:09:12 +05:30
parent 1134ddb5c9
commit ecd4408d8a
2 changed files with 157 additions and 13 deletions
+14 -13
View File
@@ -34,6 +34,7 @@ import { UploadManager } from '../stores/uploadManager';
import { InboxZone } from '../canvas/InboxZone';
import { getItemWorldBounds } from '../canvas/SceneManager';
import { getPointAnchorWorld } from '../canvas/reviewAnchors';
import { resolveReviewTargetAtPoint } from '../canvas/reviewTargeting';
import type { TextObject } from '../canvas/scene-format';
import { VideoSprite } from '../canvas/sprites/VideoSprite';
import * as ops from '../canvas/operations';
@@ -291,21 +292,21 @@ export default function Editor({ isPublicView }: EditorProps) {
// 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));
const target = resolveReviewTargetAtPoint({
scene,
worldX: worldPos.x,
worldY: worldPos.y,
clickRadius,
});
// Defensive guard: never attach a comment to a group
if (target && target.objectType !== 'group') {
setDraftPin({
objectId: item.id,
pinX,
pinY,
worldX: worldPos.x,
worldY: worldPos.y,
objectId: target.objectId,
pinX: target.pinX,
pinY: target.pinY,
worldX: target.worldX,
worldY: target.worldY,
});
setFocusedThreadId(null);
return;