From 06153cc179b250cdfa7ae19f20d3b5d6bf1befbb Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 21:20:12 +0800 Subject: [PATCH] fix: enable local edit region controls --- components/canvas/nodes/ImageNode.tsx | 268 +++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 8 deletions(-) diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index c60cfc963..f2a8257ed 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -227,6 +227,14 @@ type CropInteractionState = { startRect: NormalizedCropRect; }; +type LocalEditRegionInteractionState = { + handle: CropInteractionHandle; + pointerId: number; + regionId: string; + startPoint: BoxCutoutPoint; + startRect: NormalizedCropRect; +}; + type AnglePreviewDragState = { pointerId: number; startX: number; @@ -1825,6 +1833,8 @@ export function ImageNode({ useState(null); const [localEditDragEnd, setLocalEditDragEnd] = useState(null); + const [localEditRegionInteraction, setLocalEditRegionInteraction] = + useState(null); const [editingLocalEditRegionId, setEditingLocalEditRegionId] = useState(null); const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false); @@ -1988,6 +1998,7 @@ export function ImageNode({ Record >({}); const localEditPromptDraftRefs = useRef>({}); + const localEditRegionsRef = useRef(localEditRegions); const updateNode = useCanvasStore((state) => state.updateNode); const deleteNode = useCanvasStore((state) => state.deleteNode); @@ -2454,6 +2465,7 @@ export function ImageNode({ setIsLocalEditDrawing(false); setLocalEditDragStart(null); setLocalEditDragEnd(null); + setLocalEditRegionInteraction(null); setEditingLocalEditRegionId(null); }, []); @@ -2473,11 +2485,16 @@ export function ImageNode({ focusLocalEditPromptInput(editingLocalEditRegionId); }, [editingLocalEditRegionId, focusLocalEditPromptInput, localEditMode]); + useEffect(() => { + localEditRegionsRef.current = localEditRegions; + }, [localEditRegions]); + const commitLocalEditRegions = useCallback( (regions: LocalEditRegion[]) => { const nextRegions = reindexLocalEditRegions( regions.slice(0, LOCAL_EDIT_MAX_REGIONS) ); + localEditRegionsRef.current = nextRegions; setLocalEditRegions(nextRegions); localEditPromptDraftRefs.current = Object.fromEntries( nextRegions.map((region) => [region.id, region.prompt]) @@ -3432,6 +3449,150 @@ export function ImageNode({ [localEditMode] ); + const updateLocalEditRegionRect = useCallback( + (regionId: string, rect: NormalizedCropRect, shouldCommit = false) => { + const nextRegions = reindexLocalEditRegions( + localEditRegionsRef.current.map((region) => + region.id === regionId ? { ...region, rect } : region + ) + ); + localEditRegionsRef.current = nextRegions; + setLocalEditRegions(nextRegions); + if (shouldCommit) { + commitLocalEditRegions(nextRegions); + } + }, + [commitLocalEditRegions] + ); + + const handleLocalEditRegionPointerDown = useCallback( + (event: React.PointerEvent, region: LocalEditRegion) => { + if (!localEditMode || isProcessing || isSubmittingLocalEdit) return; + + event.preventDefault(); + event.stopPropagation(); + + const pointerResult = getBoxCutoutPointerPoint( + event.clientX, + event.clientY + ); + if (!pointerResult?.insideImage) return; + + const handleElement = (event.target as HTMLElement | null)?.closest( + "[data-local-edit-region-handle]" + ) as HTMLElement | null; + const handle = + (handleElement?.dataset.localEditRegionHandle as + | CropInteractionHandle + | undefined) || "move"; + + setIsLocalEditDrawing(false); + setLocalEditDragStart(null); + setLocalEditDragEnd(null); + setEditingLocalEditRegionId(null); + setLocalEditRegionInteraction({ + handle, + pointerId: event.pointerId, + regionId: region.id, + startPoint: pointerResult.point, + startRect: region.rect, + }); + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + [ + getBoxCutoutPointerPoint, + isProcessing, + isSubmittingLocalEdit, + localEditMode, + ] + ); + + const handleLocalEditRegionPointerMove = useCallback( + (event: React.PointerEvent, regionId: string) => { + if ( + !localEditMode || + !localEditRegionInteraction || + localEditRegionInteraction.regionId !== regionId || + localEditRegionInteraction.pointerId !== event.pointerId + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const pointerResult = getBoxCutoutPointerPoint( + event.clientX, + event.clientY + ); + if (!pointerResult) return; + + const minWidth = Math.min( + LOCAL_EDIT_MIN_EDGE_PX / Math.max(pointerResult.metrics.naturalWidth, 1), + 1 + ); + const minHeight = Math.min( + LOCAL_EDIT_MIN_EDGE_PX / Math.max(pointerResult.metrics.naturalHeight, 1), + 1 + ); + const nextRect = updateCropRectWithPointer({ + aspectRatio: null, + handle: localEditRegionInteraction.handle, + minHeight, + minWidth, + pointer: pointerResult.point, + startPoint: localEditRegionInteraction.startPoint, + startRect: localEditRegionInteraction.startRect, + }); + + updateLocalEditRegionRect(regionId, nextRect); + }, + [ + getBoxCutoutPointerPoint, + localEditMode, + localEditRegionInteraction, + updateLocalEditRegionRect, + ] + ); + + const handleLocalEditRegionPointerUp = useCallback( + (event: React.PointerEvent, regionId: string) => { + if ( + !localEditRegionInteraction || + localEditRegionInteraction.regionId !== regionId || + localEditRegionInteraction.pointerId !== event.pointerId + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + setLocalEditRegionInteraction(null); + commitLocalEditRegions(localEditRegionsRef.current); + event.currentTarget.releasePointerCapture?.(event.pointerId); + }, + [commitLocalEditRegions, localEditRegionInteraction] + ); + + const handleLocalEditRegionPointerCancel = useCallback( + (event: React.PointerEvent, regionId: string) => { + if ( + !localEditRegionInteraction || + localEditRegionInteraction.regionId !== regionId || + localEditRegionInteraction.pointerId !== event.pointerId + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + setLocalEditRegionInteraction(null); + commitLocalEditRegions(localEditRegionsRef.current); + event.currentTarget.releasePointerCapture?.(event.pointerId); + }, + [commitLocalEditRegions, localEditRegionInteraction] + ); + const persistLocalEditRegionPrompt = useCallback( (regionId: string, prompt: string) => { localEditPromptDraftRefs.current[regionId] = prompt; @@ -3448,13 +3609,13 @@ export function ImageNode({ (regionId: string) => { delete localEditPromptDraftRefs.current[regionId]; commitLocalEditRegions( - localEditRegions.filter((region) => region.id !== regionId) + localEditRegionsRef.current.filter((region) => region.id !== regionId) ); if (editingLocalEditRegionId === regionId) { setEditingLocalEditRegionId(null); } }, - [commitLocalEditRegions, editingLocalEditRegionId, localEditRegions] + [commitLocalEditRegions, editingLocalEditRegionId] ); const clearLocalEditRegions = useCallback(() => { @@ -7563,8 +7724,31 @@ export function ImageNode({ return (
{ + event.preventDefault(); + event.stopPropagation(); + }} + onPointerCancel={(event) => + handleLocalEditRegionPointerCancel(event, region.id) + } + onPointerDown={(event) => + handleLocalEditRegionPointerDown(event, region) + } + onPointerMove={(event) => + handleLocalEditRegionPointerMove(event, region.id) + } + onPointerUp={(event) => + handleLocalEditRegionPointerUp(event, region.id) + } style={{ left, top, @@ -7579,6 +7763,40 @@ export function ImageNode({ > {region.index}
+ {( + [ + { + handle: "nw", + className: "-left-2 -top-2 cursor-nwse-resize", + }, + { + handle: "ne", + className: "-right-2 -top-2 cursor-nesw-resize", + }, + { + handle: "sw", + className: "-left-2 -bottom-2 cursor-nesw-resize", + }, + { + handle: "se", + className: "-right-2 -bottom-2 cursor-nwse-resize", + }, + ] as const + ).map((item) => ( + )}