feat: move box cutout entry to canvas toolbar
This commit is contained in:
parent
0bdc16492d
commit
e8b71ddbe7
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user