fix: portal local edit prompt inputs
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 15:49:32 +08:00
parent 1dd1251880
commit 7421e158d1

View File

@ -2352,6 +2352,46 @@ export function ImageNode({
style: getNormalizedRectOverlayStyle(region.rect, boxCutoutMetrics),
}));
}, [boxCutoutMetrics, localEditRegions]);
const localEditPromptPortalOverlays = useMemo(() => {
if (
!localEditMode ||
!boxCutoutMetrics ||
typeof window === "undefined" ||
!containerRef.current
) {
return [];
}
const nodeRect = containerRef.current.getBoundingClientRect();
return localEditRegionOverlays.map(({ region, style }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const imageBottom = boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight;
const placeAbove = top + height + 58 > imageBottom;
const promptTop = placeAbove ? top - 46 : top + height + 8;
const promptWidth = Math.min(Math.max(width, 176), 300);
return {
region,
isEditing: editingLocalEditRegionId === region.id || !region.prompt.trim(),
style: {
left: nodeRect.left + left * nodeContentScale,
top: nodeRect.top + Math.max(4, promptTop) * nodeContentScale,
width: promptWidth,
transform: `scale(${nodeContentScale})`,
transformOrigin: "top left",
} satisfies React.CSSProperties,
};
});
}, [
boxCutoutMetrics,
editingLocalEditRegionId,
localEditMode,
localEditRegionOverlays,
nodeContentScale,
]);
const currentPickerClickStyle = useMemo(() => {
if (!boxCutoutMetrics || !currentClick) return null;
@ -7513,130 +7553,26 @@ export function ImageNode({
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const imageBottom = boxCutoutMetrics
? boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight
: node.size.height;
const placeAbove = top + height + 58 > imageBottom;
const promptTop = placeAbove ? top - 46 : top + height + 8;
const promptWidth = Math.min(Math.max(width, 176), 300);
const isEditing =
editingLocalEditRegionId === region.id || !region.prompt.trim();
return (
<Fragment key={region.id}>
<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)]"
key={region.id}
style={{
left,
top,
width: Math.max(width, 1),
height: Math.max(height, 1),
borderColor: region.color,
}}
>
<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)]"
style={{
left,
top,
width: Math.max(width, 1),
height: Math.max(height, 1),
borderColor: region.color,
}}
className="-left-3 -top-3 absolute flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[12px] font-black text-white shadow-[0_8px_18px_rgba(0,0,0,0.22)]"
style={{ backgroundColor: region.color }}
>
<div
className="-left-3 -top-3 absolute flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[12px] font-black text-white shadow-[0_8px_18px_rgba(0,0,0,0.22)]"
style={{ backgroundColor: region.color }}
>
{region.index}
</div>
{region.index}
</div>
<div
className="absolute z-[175] flex items-center gap-1.5"
data-canvas-no-drag="true"
onClick={(event) => event.stopPropagation()}
onClickCapture={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onMouseDownCapture={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => event.stopPropagation()}
style={{
left,
top: Math.max(4, promptTop),
width: promptWidth,
}}
>
{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"
onBlur={(event) => {
const relatedTarget =
event.relatedTarget instanceof HTMLElement
? event.relatedTarget
: null;
const nextPrompt = event.currentTarget.value.trim();
if (
!nextPrompt &&
editingLocalEditRegionId === region.id &&
!relatedTarget?.closest("[data-local-edit-control='true']")
) {
focusLocalEditPromptInput(region.id);
}
}}
onChange={(event) =>
updateLocalEditRegionPrompt(region.id, event.target.value)
}
onClick={(event) => {
event.stopPropagation();
focusLocalEditPromptInput(region.id);
}}
onFocus={() => setEditingLocalEditRegionId(region.id)}
onKeyDown={(event) => {
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.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onMouseDownCapture={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => {
event.stopPropagation();
focusLocalEditPromptInput(region.id);
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
value={region.prompt}
/>
) : (
<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"
onClick={() => setEditingLocalEditRegionId(region.id)}
title={region.prompt}
type="button"
>
{region.index}. {region.prompt}
</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)]"
data-local-edit-control="true"
onClick={() => deleteLocalEditRegion(region.id)}
title="删除区域"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</Fragment>
</div>
);
})}
{localEditDraftRectStyle ? (
@ -8200,6 +8136,113 @@ export function ImageNode({
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{localEditMode &&
typeof document !== "undefined" &&
localEditPromptPortalOverlays.map(({ region, isEditing, style }) =>
createPortal(
<div
className="fixed z-[10000] flex items-center gap-1.5"
data-canvas-no-drag="true"
data-local-edit-control="true"
key={region.id}
onClick={(event) => event.stopPropagation()}
onClickCapture={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onMouseDownCapture={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => event.stopPropagation()}
style={style}
>
{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"
onBlur={(event) => {
const relatedTarget =
event.relatedTarget instanceof HTMLElement
? event.relatedTarget
: null;
const nextPrompt = event.currentTarget.value;
if (nextPrompt.trim()) {
persistLocalEditRegionPrompt(region.id, nextPrompt);
setEditingLocalEditRegionId(null);
return;
}
if (
editingLocalEditRegionId === region.id &&
!relatedTarget?.closest("[data-local-edit-control='true']")
) {
focusLocalEditPromptInput(region.id);
}
}}
onChange={(event) =>
updateLocalEditRegionPrompt(region.id, event.target.value)
}
onClick={(event) => {
event.stopPropagation();
focusLocalEditPromptInput(region.id);
}}
onFocus={() => setEditingLocalEditRegionId(region.id)}
onKeyDown={(event) => {
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.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onMouseDownCapture={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => {
event.stopPropagation();
focusLocalEditPromptInput(region.id);
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
value={region.prompt}
/>
) : (
<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"
data-local-edit-control="true"
onClick={() => setEditingLocalEditRegionId(region.id)}
title={region.prompt}
type="button"
>
{region.index}. {region.prompt}
</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)]"
data-local-edit-control="true"
onClick={() => deleteLocalEditRegion(region.id)}
title="删除区域"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>,
document.body
)
)}
{isSelected &&
!isMultiSelected &&
data.src &&