feat: move box cutout entry to canvas toolbar
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-03-07 10:31:37 +08:00
parent 0bdc16492d
commit e8b71ddbe7
4 changed files with 282 additions and 103 deletions

View File

@ -25,6 +25,7 @@ import {
Box,
Layers,
Crosshair,
Scan,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -120,6 +121,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const updateNode = useCanvasStore((state) => state.updateNode);
const setGenerating = useCanvasStore((state) => state.setGenerating);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction);
// Local state
const [isLoading, setIsLoading] = useState(true);
@ -818,6 +820,29 @@ export default function CanvasEditorPage({ params }: PageProps) {
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!selectedImageNode}
onClick={() => {
if (!selectedImageNode) {
toast.info("请先选中一张图片");
return;
}
requestImageNodeAction(selectedImageNode.id, "box-cutout");
}}
className="h-9 w-9 rounded-xl"
>
<Scan className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{selectedImageNode ? "框选抠图" : "先选中图片后框选抠图"}
</TooltipContent>
</Tooltip>
{siteSettingsData?.realtimeCanvasEnabled === true &&
siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
<Tooltip>

View File

@ -324,6 +324,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [isCompareMode, setIsCompareMode] = useState(false);
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({ width: 0, height: 0 });
const [boxCutoutMode, setBoxCutoutMode] = useState(false);
const [isBoxCutoutDrawing, setIsBoxCutoutDrawing] = useState(false);
const [boxCutoutSelection, setBoxCutoutSelection] = useState<NormalizedCropRect | null>(null);
@ -337,12 +338,15 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const displayImageRef = useRef<HTMLImageElement>(null);
const compareFrameRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const boxCutoutToolbarRef = useRef<HTMLDivElement>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
const addPendingChatAttachment = useCanvasStore((state) => state.addPendingChatAttachment);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
const imageNodeActionRequest = useCanvasStore((state) => state.imageNodeActionRequest);
const clearImageNodeActionRequest = useCanvasStore((state) => state.clearImageNodeActionRequest);
const allNodes = useCanvasStore((state) => state.nodes);
// 🆕 物品拾取相关状态
@ -749,7 +753,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
isMultiSelected ||
!data.src ||
isEditingTitle ||
sceneFusionDialogOpen
sceneFusionDialogOpen ||
promptDialogOpen ||
notePanelOpen ||
boxCutoutMode
) {
return;
}
@ -785,7 +792,60 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
resizeObserver?.disconnect();
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, [data.src, isEditingTitle, isMultiSelected, isSelected, sceneFusionDialogOpen]);
}, [
boxCutoutMode,
data.src,
isEditingTitle,
isMultiSelected,
isSelected,
notePanelOpen,
promptDialogOpen,
sceneFusionDialogOpen,
]);
useEffect(() => {
if (
typeof window === "undefined" ||
!isSelected ||
isMultiSelected ||
!data.src ||
!boxCutoutMode
) {
return;
}
const toolbarEl = boxCutoutToolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const rect = toolbarEl.getBoundingClientRect();
const next = { width: rect.width, height: rect.height };
setBoxCutoutToolbarSize((prev) =>
Math.abs(prev.width - next.width) < 0.5 && Math.abs(prev.height - next.height) < 0.5
? prev
: next
);
};
const scheduleMeasure = () => {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(measure);
};
scheduleMeasure();
window.addEventListener("resize", scheduleMeasure);
const resizeObserver =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleMeasure) : null;
resizeObserver?.observe(toolbarEl);
return () => {
window.removeEventListener("resize", scheduleMeasure);
resizeObserver?.disconnect();
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, [boxCutoutMode, data.src, isMultiSelected, isSelected]);
// 🆕 处理图片点击标注
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
@ -886,6 +946,23 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
[data.src, isProcessing, isThisNodePicking, refreshBoxCutoutMetrics, setPickerMode]
);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
imageNodeActionRequest,
node.id,
]);
const handleBoxCutoutPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode || isProcessing) return;
@ -1322,42 +1399,74 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}
}, [addPendingChatAttachment, animateAddToChat, data.src, displayTitle, isAddingToChat, isProcessing]);
const getFloatingViewportBounds = (toolbarHeight: number) => {
const viewportPadding = 12;
let unsafeBottomTop = window.innerHeight;
const bottomOverlaySelectors = ['[data-canvas-floating-toolbar="true"]'];
for (const selector of bottomOverlaySelectors) {
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
for (const element of elements) {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") continue;
const overlayRect = element.getBoundingClientRect();
if (overlayRect.width <= 0 || overlayRect.height <= 0) continue;
if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight) continue;
if (overlayRect.top < window.innerHeight * 0.55) continue;
unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top);
}
}
const safeBottom = Math.max(
viewportPadding,
Math.min(window.innerHeight - viewportPadding, unsafeBottomTop - viewportPadding)
);
const stageElement = document.querySelector('[data-canvas-stage="true"]') as HTMLElement | null;
const panelElement = document.querySelector('[data-canvas-right-panel="true"]') as HTMLElement | null;
let safeLeft = viewportPadding;
let safeRight = window.innerWidth - viewportPadding;
if (stageElement) {
const stageRect = stageElement.getBoundingClientRect();
if (stageRect.width > 0) {
safeLeft = Math.max(safeLeft, stageRect.left + viewportPadding);
safeRight = Math.min(safeRight, stageRect.right - viewportPadding);
}
}
if (panelElement) {
const panelRect = panelElement.getBoundingClientRect();
if (panelRect.width > 0 && panelRect.left < window.innerWidth) {
safeRight = Math.min(safeRight, panelRect.left - viewportPadding);
}
}
if (safeRight <= safeLeft + 8) {
safeLeft = viewportPadding;
safeRight = window.innerWidth - viewportPadding;
}
return {
viewportPadding,
safeBottom,
safeLeft,
safeRight,
maxTop: Math.max(viewportPadding, safeBottom - toolbarHeight),
};
};
// 获取工具栏位置 - 在渲染时直接计算
const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const viewportPadding = 12;
const toolbarScale = 0.8;
const toolbarWidth = Math.max(280, toolbarSize.width * toolbarScale || 980);
const toolbarHeight = Math.max(42, toolbarSize.height * toolbarScale || 48);
const titleSafeHeight = 34;
const gap = 12;
let unsafeBottomTop = window.innerHeight;
const bottomOverlaySelectors = [
'[data-canvas-floating-toolbar="true"]',
];
for (const selector of bottomOverlaySelectors) {
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
for (const element of elements) {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") continue;
const overlayRect = element.getBoundingClientRect();
if (overlayRect.width <= 0 || overlayRect.height <= 0) continue;
if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight) continue;
if (overlayRect.top < window.innerHeight * 0.55) continue;
unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top);
}
}
const safeBottom = Math.max(
viewportPadding,
Math.min(window.innerHeight - viewportPadding, unsafeBottomTop - viewportPadding)
);
const maxTop = Math.max(viewportPadding, safeBottom - toolbarHeight);
const { viewportPadding, maxTop, safeLeft, safeRight } = getFloatingViewportBounds(toolbarHeight);
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
if (top < viewportPadding) {
@ -1365,29 +1474,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const stageElement = document.querySelector('[data-canvas-stage="true"]') as HTMLElement | null;
const panelElement = document.querySelector('[data-canvas-right-panel="true"]') as HTMLElement | null;
let safeLeft = viewportPadding;
let safeRight = window.innerWidth - viewportPadding;
if (stageElement) {
const stageRect = stageElement.getBoundingClientRect();
if (stageRect.width > 0) {
safeLeft = Math.max(safeLeft, stageRect.left + viewportPadding);
safeRight = Math.min(safeRight, stageRect.right - viewportPadding);
}
}
if (panelElement) {
const panelRect = panelElement.getBoundingClientRect();
if (panelRect.width > 0 && panelRect.left < window.innerWidth) {
safeRight = Math.min(safeRight, panelRect.left - viewportPadding);
}
}
if (safeRight <= safeLeft + 8) {
safeLeft = viewportPadding;
safeRight = window.innerWidth - viewportPadding;
}
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
const halfToolbar = effectiveToolbarWidth / 2;
@ -1396,10 +1482,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = (safeLeft + safeRight) / 2;
} else {
left = Math.min(
Math.max(left, safeLeft + halfToolbar),
safeRight - halfToolbar
);
left = Math.min(Math.max(left, safeLeft + halfToolbar), safeRight - halfToolbar);
}
return {
@ -1411,6 +1494,42 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
return { top: 0, left: 0, maxWidth: 480 };
};
const getBoxCutoutToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarScale = 0.8;
const toolbarWidth = Math.max(240, boxCutoutToolbarSize.width * toolbarScale || 460);
const toolbarHeight = Math.max(42, boxCutoutToolbarSize.height * toolbarScale || 48);
const gap = 14;
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(toolbarHeight);
let top = rect.bottom + gap;
if (top + toolbarHeight > safeBottom) {
top = rect.top - toolbarHeight - gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
const halfToolbar = effectiveToolbarWidth / 2;
let left = rect.left + rect.width / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = (safeLeft + safeRight) / 2;
} else {
left = Math.min(Math.max(left, safeLeft + halfToolbar), safeRight - halfToolbar);
}
return {
top,
left,
maxWidth: maxToolbarWidth,
};
}
return { top: 0, left: 0, maxWidth: 460 };
};
const getTextPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
@ -2223,6 +2342,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
};
const toolbarPosition = getToolbarPosition();
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
const boxCutoutHintText = boxCutoutSelection
? "确认后将仅抠出框选区域"
: isBoxCutoutDrawing
? "松开完成框选"
: "在图片中拖拽矩形框";
return (
<>
@ -2416,42 +2541,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
) : (
<div className="pointer-events-none absolute inset-0 z-[141] bg-slate-950/18" />
)}
<div className="pointer-events-none absolute left-2 top-2 z-[150] rounded-md bg-slate-950/74 px-2 py-1 text-[11px] text-white shadow">
{boxCutoutSelection
? "已框选,可确认抠图"
: isBoxCutoutDrawing
? "松开完成框选"
: "拖拽框选要抠出的元素"}
</div>
<div
className="absolute bottom-2 left-1/2 z-[150] flex -translate-x-1/2 items-center gap-2 rounded-full bg-white px-3 py-1.5 shadow-lg"
data-canvas-no-drag="true"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
>
<Scan className="h-4 w-4 text-[#27a8ff]" />
<span className="text-xs font-medium text-slate-700">
{boxCutoutSelection ? "确认后将仅抠出框选区域" : "在图片中拖拽矩形框"}
</span>
{boxCutoutSelection ? (
<Button size="sm" className="h-7 rounded-full px-3" onClick={() => void handleConfirmBoxCutout()}>
<Check className="mr-1 h-3.5 w-3.5" />
确认抠图
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={() => resetBoxCutoutState()}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
@ -2783,16 +2872,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
抠图
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={(event) => handleEnterBoxCutoutMode(event)}
>
<Scan className="mr-1.5 h-4 w-4" />
框选抠图
</Button>
<Button
variant="ghost"
disabled={isProcessing}
@ -2888,6 +2967,57 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
document.body
)}
{isSelected && !isMultiSelected && data.src && boxCutoutMode && typeof document !== "undefined" && createPortal(
<div
ref={boxCutoutToolbarRef}
className="fixed z-[9999] flex items-center rounded-[24px] border border-slate-200 bg-white/95 px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] backdrop-blur-md dark:border-slate-700 dark:bg-slate-900/95 dark:text-gray-100"
style={{
top: boxCutoutToolbarPosition.top,
left: boxCutoutToolbarPosition.left,
transform: "translateX(-50%) scale(0.8)",
transformOrigin: "top center",
pointerEvents: "auto",
maxWidth: `${Math.max(180, Math.floor(boxCutoutToolbarPosition.maxWidth))}px`,
overflowX: "auto",
scrollbarWidth: "none",
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xl bg-slate-100 px-3 text-[13px] font-medium text-slate-700 dark:bg-white/10 dark:text-slate-100">
<Scan className="h-4 w-4 text-[#27a8ff]" />
<span className="whitespace-nowrap">{boxCutoutHintText}</span>
</div>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<Button
variant="ghost"
disabled={isProcessing || !boxCutoutSelection}
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200 dark:disabled:bg-slate-700 dark:disabled:text-slate-400"
onClick={() => void handleConfirmBoxCutout()}
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
<span className="whitespace-nowrap">确认抠图</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => resetBoxCutoutState()}
>
<X className="h-4 w-4" />
</Button>
</div>,
document.body
)}
{/* 图片备注对话框 */}
<Dialog open={notePanelOpen} onOpenChange={setNotePanelOpen}>
<DialogContent

View File

@ -53,6 +53,14 @@ export interface PickedObject {
};
}
export type ImageNodeActionType = 'box-cutout';
export interface ImageNodeActionRequest {
nodeId: string;
action: ImageNodeActionType;
requestId: string;
}
// Store 状态接口
interface CanvasStore {
// 项目信息
@ -83,6 +91,7 @@ interface CanvasStore {
isPickerLoading: boolean;
pickedObject: PickedObject | null;
pendingPickedObjects: PickedObject[];
imageNodeActionRequest: ImageNodeActionRequest | null;
// 🆕 新增节点(用于自动聚焦)
lastAddedNodeId: string | null;
@ -151,6 +160,8 @@ interface CanvasStore {
setPickedObject: (obj: PickedObject | null) => void;
addPendingPickedObject: (obj: PickedObject) => void;
clearPendingPickedObjects: () => void;
requestImageNodeAction: (nodeId: string, action: ImageNodeActionType) => void;
clearImageNodeActionRequest: () => void;
// 🆕 自动聚焦控制
clearLastAddedNodeId: () => void;
@ -180,6 +191,7 @@ export const useCanvasStore = create<CanvasStore>()(
isPickerLoading: false,
pickedObject: null,
pendingPickedObjects: [],
imageNodeActionRequest: null,
lastAddedNodeId: null,
// 项目操作
@ -533,6 +545,18 @@ export const useCanvasStore = create<CanvasStore>()(
state.pendingPickedObjects = [];
}),
requestImageNodeAction: (nodeId, action) => set((state) => {
state.imageNodeActionRequest = {
nodeId,
action,
requestId: generateId(),
};
}),
clearImageNodeActionRequest: () => set((state) => {
state.imageNodeActionRequest = null;
}),
clearLastAddedNodeId: () => set((state) => {
state.lastAddedNodeId = null;
}),

File diff suppressed because one or more lines are too long