fix: enable local edit region controls
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-19 21:20:12 +08:00
parent f87669c15a
commit 06153cc179

View File

@ -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<BoxCutoutPoint | null>(null);
const [localEditDragEnd, setLocalEditDragEnd] =
useState<BoxCutoutPoint | null>(null);
const [localEditRegionInteraction, setLocalEditRegionInteraction] =
useState<LocalEditRegionInteractionState | null>(null);
const [editingLocalEditRegionId, setEditingLocalEditRegionId] =
useState<string | null>(null);
const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false);
@ -1988,6 +1998,7 @@ export function ImageNode({
Record<string, HTMLInputElement | null>
>({});
const localEditPromptDraftRefs = useRef<Record<string, string>>({});
const localEditRegionsRef = useRef<LocalEditRegion[]>(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<HTMLDivElement>, 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<HTMLDivElement>, 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<HTMLDivElement>, 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<HTMLDivElement>, 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 (
<div
className="pointer-events-none absolute z-[160] rounded-[10px] border-[3px] bg-transparent shadow-[0_0_0_1px_rgba(255,255,255,0.92),0_0_22px_rgba(0,0,0,0.2)]"
className={cn(
"nodrag nopan nowheel pointer-events-auto absolute z-[160] rounded-[10px] border-[3px] bg-transparent shadow-[0_0_0_1px_rgba(255,255,255,0.92),0_0_22px_rgba(0,0,0,0.2)]",
localEditRegionInteraction?.regionId === region.id
? "cursor-grabbing"
: "cursor-grab"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
key={region.id}
onClick={(event) => {
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}
</div>
{(
[
{
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) => (
<button
aria-label={`调整区域 ${region.index}`}
className={cn(
"nodrag nopan nowheel absolute h-4 w-4 rounded-full border-2 border-white shadow-[0_4px_12px_rgba(0,0,0,0.22)]",
item.className
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
data-local-edit-region-handle={item.handle}
key={item.handle}
style={{ backgroundColor: region.color }}
type="button"
/>
))}
</div>
);
})}
@ -8148,7 +8366,7 @@ export function ImageNode({
localEditPromptPortalOverlays.map(({ region, isEditing, style }) =>
createPortal(
<div
className="fixed z-[10000] flex items-center gap-1.5"
className="nodrag nopan nowheel fixed z-[10000] flex items-center gap-1.5"
data-canvas-no-drag="true"
data-local-edit-control="true"
key={region.id}
@ -8256,9 +8474,26 @@ export function ImageNode({
/>
) : (
<button
className="h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900"
className="nodrag nopan nowheel h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={() => setEditingLocalEditRegionId(region.id)}
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
title={region.prompt}
type="button"
>
@ -8266,9 +8501,26 @@ export function ImageNode({
</button>
)}
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]"
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={() => deleteLocalEditRegion(region.id)}
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}}
title="删除区域"
type="button"
>