fix: anchor local edit prompts to image node
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-20 17:45:53 +08:00
parent a93daefe54
commit d6ab02eb45

View File

@ -1869,7 +1869,6 @@ export function ImageNode({
const [boxCutoutDragEnd, setBoxCutoutDragEnd] =
useState<BoxCutoutPoint | null>(null);
const [localEditMode, setLocalEditMode] = useState(false);
const [localEditViewportRevision, setLocalEditViewportRevision] = useState(0);
const [localEditRegions, setLocalEditRegions] = useState<LocalEditRegion[]>(
() => getLocalEditRegionsFromMetadata(data.metadata)
);
@ -2048,7 +2047,6 @@ export function ImageNode({
>({});
const localEditPromptDraftRefs = useRef<Record<string, string>>({});
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
const localEditViewportFrameRef = useRef<number | null>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
@ -2417,17 +2415,11 @@ export function ImageNode({
style: getNormalizedRectOverlayStyle(region.rect, boxCutoutMetrics),
}));
}, [boxCutoutMetrics, localEditRegions]);
const localEditPromptPortalOverlays = useMemo(() => {
if (
!localEditMode ||
!boxCutoutMetrics ||
typeof window === "undefined" ||
!containerRef.current
) {
const localEditPromptInlineOverlays = useMemo(() => {
if (!localEditMode || !boxCutoutMetrics) {
return [];
}
const nodeRect = containerRef.current.getBoundingClientRect();
return localEditRegionOverlays.map(({ region, style }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
@ -2446,11 +2438,9 @@ export function ImageNode({
region,
isEditing: editingLocalEditRegionId === region.id || !region.prompt.trim(),
style: {
left: nodeRect.left + left * nodeContentScale,
top: nodeRect.top + Math.max(4, promptTop) * nodeContentScale,
left,
top: Math.max(4, promptTop),
width: promptWidth,
transform: `scale(${nodeContentScale})`,
transformOrigin: "top left",
} satisfies React.CSSProperties,
};
});
@ -2459,8 +2449,6 @@ export function ImageNode({
editingLocalEditRegionId,
localEditMode,
localEditRegionOverlays,
localEditViewportRevision,
nodeContentScale,
]);
const currentPickerClickStyle = useMemo(() => {
if (!boxCutoutMetrics || !currentClick) return null;
@ -2545,44 +2533,6 @@ export function ImageNode({
localEditRegionsRef.current = localEditRegions;
}, [localEditRegions]);
useEffect(() => {
if (!localEditMode) return;
const scheduleLocalEditViewportRefresh = () => {
setLocalEditViewportRevision((revision) => revision + 1);
if (localEditViewportFrameRef.current !== null) {
window.cancelAnimationFrame(localEditViewportFrameRef.current);
}
localEditViewportFrameRef.current = window.requestAnimationFrame(() => {
localEditViewportFrameRef.current = null;
setLocalEditViewportRevision((revision) => revision + 1);
});
};
let previousViewport = useCanvasStore.getState().viewport;
const unsubscribe = useCanvasStore.subscribe((state) => {
const nextViewport = state.viewport;
if (
nextViewport.x === previousViewport.x &&
nextViewport.y === previousViewport.y &&
nextViewport.zoom === previousViewport.zoom
) {
return;
}
previousViewport = nextViewport;
scheduleLocalEditViewportRefresh();
});
return () => {
unsubscribe();
if (localEditViewportFrameRef.current !== null) {
window.cancelAnimationFrame(localEditViewportFrameRef.current);
localEditViewportFrameRef.current = null;
}
};
}, [localEditMode]);
const commitLocalEditRegions = useCallback(
(regions: LocalEditRegion[]) => {
const nextRegions = reindexLocalEditRegions(
@ -7596,7 +7546,8 @@ export function ImageNode({
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-none border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
"relative h-full w-full rounded-none border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
localEditMode ? "overflow-visible" : "overflow-hidden",
isSelected
? "border-[#5b76fe] ring-2 ring-[#5b76fe]/14 shadow-[0_0_0_1px_rgba(91,118,254,0.14),0_20px_38px_rgba(91,118,254,0.12)]"
: "hover:border-[#cfd9ea]"
@ -7992,6 +7943,275 @@ export function ImageNode({
</div>
);
})}
{localEditPromptInlineOverlays.map(({ region, isEditing, style }) => (
<div
className="nodrag nopan nowheel pointer-events-auto absolute z-[170] flex flex-col items-stretch gap-1.5"
data-canvas-no-drag="true"
data-local-edit-control="true"
key={`local-edit-prompt-${region.id}`}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={style}
>
<div className="flex items-center gap-1.5">
{isEditing ? (
<input
autoFocus={editingLocalEditRegionId === region.id}
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
data-canvas-no-drag="true"
data-local-edit-control="true"
defaultValue={
localEditPromptDraftRefs.current[region.id] ??
region.prompt
}
onBlur={(event) => {
const nextPrompt = event.currentTarget.value;
localEditPromptDraftRefs.current[region.id] =
nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
if (isLocalEditControlTarget(event.relatedTarget)) {
return;
}
window.setTimeout(() => {
if (
isLocalEditControlTarget(document.activeElement)
) {
return;
}
setEditingLocalEditRegionId((current) => {
if (current === region.id) {
focusLocalEditPromptInput(region.id);
}
return current;
});
}, 80);
}}
onClick={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onFocus={(event) => {
event.stopPropagation();
setEditingLocalEditRegionId((current) =>
current === region.id ? current : region.id
);
}}
onInput={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onChange={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onKeyDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onKeyUp={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onPointerDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
/>
) : (
<button
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={(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"
>
{region.index}. {region.prompt}
</button>
)}
<input
accept="image/jpeg,image/png,image/webp"
className="hidden"
data-canvas-no-drag="true"
data-local-edit-control="true"
id={`local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => {
void handleLocalEditReferenceUpload(
region.id,
event.currentTarget.files
);
event.currentTarget.value = "";
}}
type="file"
/>
<button
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-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
data-canvas-no-drag="true"
data-local-edit-control="true"
disabled={
uploadingLocalEditReferenceRegionId === region.id ||
(region.referenceImages?.length || 0) >=
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
}
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
document
.getElementById(
`local-edit-reference-upload-${region.id}`
)
?.click();
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
title="添加区域参考图"
type="button"
>
{uploadingLocalEditReferenceRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
</button>
<button
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={(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"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{region.referenceImages?.length ? (
<div className="flex max-w-full items-center gap-1 overflow-hidden rounded-2xl border border-slate-200 bg-white/92 px-1.5 py-1 shadow-[0_8px_20px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-slate-950/80">
<span className="shrink-0 px-1 text-[10px] font-semibold text-slate-500 dark:text-white/52">
参考
</span>
{region.referenceImages.map((reference) => (
<div
className="group relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-slate-100 dark:border-white/12 dark:bg-slate-800"
key={reference.id}
title={reference.name || "区域参考图"}
>
<img
alt={reference.name || "区域参考图"}
className="h-full w-full object-cover"
draggable={false}
src={reference.url}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/52 text-white group-hover:flex"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
removeLocalEditReferenceImage(
region.id,
reference.id
);
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
removeLocalEditReferenceImage(
region.id,
reference.id
);
}}
title="移除参考图"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
</div>
))}
{localEditDraftRectStyle ? (
<div
className="pointer-events-none absolute z-[162] rounded-[10px] border-[3px] border-emerald-400 bg-emerald-300/10 shadow-[0_0_0_1px_rgba(255,255,255,0.9),0_0_22px_rgba(16,185,129,0.35)]"
@ -8552,270 +8772,6 @@ export function ImageNode({
</DialogContent>
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{localEditMode &&
typeof document !== "undefined" &&
localEditPromptPortalOverlays.map(({ region, isEditing, style }) =>
createPortal(
<div
className="nodrag nopan nowheel fixed z-[10000] flex flex-col items-stretch gap-1.5"
data-canvas-no-drag="true"
data-local-edit-control="true"
key={region.id}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={style}
>
<div className="flex items-center gap-1.5">
{isEditing ? (
<input
autoFocus={editingLocalEditRegionId === region.id}
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
data-canvas-no-drag="true"
data-local-edit-control="true"
defaultValue={
localEditPromptDraftRefs.current[region.id] ?? region.prompt
}
onBlur={(event) => {
const nextPrompt = event.currentTarget.value;
localEditPromptDraftRefs.current[region.id] = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
if (isLocalEditControlTarget(event.relatedTarget)) {
return;
}
window.setTimeout(() => {
if (isLocalEditControlTarget(document.activeElement)) {
return;
}
setEditingLocalEditRegionId((current) => {
if (current === region.id) {
focusLocalEditPromptInput(region.id);
}
return current;
});
}, 80);
}}
onClick={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onFocus={(event) => {
event.stopPropagation();
setEditingLocalEditRegionId((current) =>
current === region.id ? current : region.id
);
}}
onInput={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onChange={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onKeyDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onKeyUp={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onPointerDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
/>
) : (
<button
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={(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"
>
{region.index}. {region.prompt}
</button>
)}
<input
accept="image/jpeg,image/png,image/webp"
className="hidden"
data-canvas-no-drag="true"
data-local-edit-control="true"
id={`local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => {
void handleLocalEditReferenceUpload(
region.id,
event.currentTarget.files
);
event.currentTarget.value = "";
}}
type="file"
/>
<button
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-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
document
.getElementById(`local-edit-reference-upload-${region.id}`)
?.click();
}}
disabled={
uploadingLocalEditReferenceRegionId === region.id ||
(region.referenceImages?.length || 0) >=
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
title="添加区域参考图"
type="button"
>
{uploadingLocalEditReferenceRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
</button>
<button
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={(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"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{region.referenceImages?.length ? (
<div className="flex max-w-full items-center gap-1 overflow-hidden rounded-2xl border border-slate-200 bg-white/92 px-1.5 py-1 shadow-[0_8px_20px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-slate-950/80">
<span className="shrink-0 px-1 text-[10px] font-semibold text-slate-500 dark:text-white/52">
参考
</span>
{region.referenceImages.map((reference) => (
<div
className="group relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-slate-100 dark:border-white/12 dark:bg-slate-800"
key={reference.id}
title={reference.name || "区域参考图"}
>
<img
alt={reference.name || "区域参考图"}
className="h-full w-full object-cover"
draggable={false}
src={reference.url}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/52 text-white group-hover:flex"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
removeLocalEditReferenceImage(region.id, reference.id);
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
removeLocalEditReferenceImage(region.id, reference.id);
}}
title="移除参考图"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
</div>,
document.body
)
)}
{localEditMode &&
!isMultiSelected &&
data.src &&