Browse Source

保存到素材库 使用画布级弹窗来处理

feature/create-task
Luckyu_js 6 days ago
parent
commit
affc2cd277
  1. 2
      src/components/AssetLibraryFolderPickerModal.tsx
  2. 6
      src/components/AssetLibraryManagerModal.tsx
  3. 4
      src/components/AssetLibraryUploadModal.tsx
  4. 36
      src/components/CanvasOutlineDrawer.tsx
  5. 2
      src/components/HomeProjectsContent.tsx
  6. 4
      src/components/SaveToAssetLibraryModal.tsx
  7. 4
      src/components/WorkflowCanvas.tsx
  8. 48
      src/components/__tests__/CanvasOutlineDrawer.test.tsx
  9. 2
      src/components/canvas/ActivePanoramaHost.tsx
  10. 22
      src/components/canvas/SaveToAssetLibraryHost.tsx
  11. 10
      src/components/nodes/AudioInputNode.tsx
  12. 10
      src/components/nodes/GenerateAudioNode.tsx
  13. 9
      src/components/nodes/GenerateImageNode.tsx
  14. 9
      src/components/nodes/GenerateVideoNode.tsx
  15. 9
      src/components/nodes/ImageInputNode.tsx
  16. 9
      src/components/nodes/VideoInputNode.tsx
  17. 15
      src/hooks/useCanvasContextMenuActions.tsx
  18. 15
      src/store/nodeToolStore.ts

2
src/components/AssetLibraryFolderPickerModal.tsx

@ -91,7 +91,7 @@ export function AssetLibraryFolderPickerModal({
}}
className={[
"nodrag nopan nowheel",
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-xl [&_.ant-modal-content]:!border [&_.ant-modal-content]:!border-[var(--border-subtle)] [&_.ant-modal-content]:!bg-[var(--surface-2)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-container]:!overflow-hidden [&_.ant-modal-container]:!rounded-xl [&_.ant-modal-container]:!border [&_.ant-modal-container]:!border-[var(--border-subtle)] [&_.ant-modal-container]:!bg-[var(--surface-2)] [&_.ant-modal-container]:!p-0 [&_.ant-modal-container]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-header]:!mb-0 [&_.ant-modal-header]:!border-b [&_.ant-modal-header]:!border-[var(--border-subtle)] [&_.ant-modal-header]:!bg-[var(--surface-2)] [&_.ant-modal-header]:!px-5 [&_.ant-modal-header]:!py-4",
"[&_.ant-modal-body]:!px-5 [&_.ant-modal-body]:!py-4",
"[&_.ant-modal-footer]:!mt-0 [&_.ant-modal-footer]:!border-t [&_.ant-modal-footer]:!border-[var(--border-subtle)] [&_.ant-modal-footer]:!px-5 [&_.ant-modal-footer]:!py-3",

6
src/components/AssetLibraryManagerModal.tsx

@ -960,7 +960,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
onCancel={onClose}
className={[
"nodrag nopan nowheel !overflow-hidden !rounded-xl",
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-xl [&_.ant-modal-content]:!border-0 [&_.ant-modal-content]:!bg-[var(--surface-2)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-container]:!overflow-hidden [&_.ant-modal-container]:!rounded-xl [&_.ant-modal-container]:!border-0 [&_.ant-modal-container]:!bg-[var(--surface-2)] [&_.ant-modal-container]:!p-0 [&_.ant-modal-container]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-body]:!p-0",
].join(" ")}
>
@ -1119,7 +1119,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
footer={null}
className={[
"nodrag nopan nowheel",
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-lg [&_.ant-modal-content]:!border-0 [&_.ant-modal-content]:!bg-[var(--surface-1)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-container]:!overflow-hidden [&_.ant-modal-container]:!rounded-lg [&_.ant-modal-container]:!border-0 [&_.ant-modal-container]:!bg-[var(--surface-1)] [&_.ant-modal-container]:!p-0 [&_.ant-modal-container]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-body]:!p-0",
].join(" ")}
>
@ -1183,7 +1183,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
footer={null}
className={[
"nodrag nopan nowheel",
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-lg [&_.ant-modal-content]:!border-0 [&_.ant-modal-content]:!bg-[var(--surface-1)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-container]:!overflow-hidden [&_.ant-modal-container]:!rounded-lg [&_.ant-modal-container]:!border-0 [&_.ant-modal-container]:!bg-[var(--surface-1)] [&_.ant-modal-container]:!p-0 [&_.ant-modal-container]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-body]:!p-0",
].join(" ")}
>

4
src/components/AssetLibraryUploadModal.tsx

@ -366,7 +366,7 @@ export function AssetLibraryUploadModal({
onCancel={submitting ? undefined : onClose}
className={[
"nodrag nopan nowheel",
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-lg [&_.ant-modal-content]:!border-0 [&_.ant-modal-content]:!bg-[var(--surface-1)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-container]:!overflow-hidden [&_.ant-modal-container]:!rounded-lg [&_.ant-modal-container]:!border-0 [&_.ant-modal-container]:!bg-[var(--surface-1)] [&_.ant-modal-container]:!p-0 [&_.ant-modal-container]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-body]:!p-0",
].join(" ")}
>
@ -400,7 +400,7 @@ export function AssetLibraryUploadModal({
treeExpandedKeys={expandedFolderIds}
onTreeExpand={handleTreeExpand}
className="w-full [&_.ant-select-selector]:!border-[var(--border-subtle)] [&_.ant-select-selector]:!bg-[var(--surface-2)] [&_.ant-select-selection-item]:!text-[var(--text-primary)] [&_.ant-select-selection-placeholder]:!text-[var(--text-muted)]"
popupClassName="nodrag nopan nowheel"
classNames={{ popup: { root: "nodrag nopan nowheel" } }}
/>
)}
</div>

36
src/components/CanvasOutlineDrawer.tsx

@ -33,6 +33,8 @@ type OutlineGroup = {
children: WorkflowNode[];
};
const CANVAS_OUTLINE_IMAGE_PROCESS = "imageMogr2/thumbnail/120x/format/webp/quality/20";
const NODE_TITLE_KEYS: Record<NodeType, TranslationKey> = {
group: "canvasOutline.group",
imageInput: "node.imageInput",
@ -80,6 +82,27 @@ function getNodeDisplayTitle(node: WorkflowNode, t: (key: TranslationKey, values
return key ? t(key) : node.type || t("canvasOutline.nodeFallback");
}
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function appendTencentImageProcess(url: string, process: string): string {
if (!isHttpUrl(url)) return url;
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}${process}`;
}
export function buildCanvasOutlinePreviewUrl(url: string, mediaType: "image" | "video" = "image"): string {
if (!isHttpUrl(url)) return url;
if (mediaType === "video") {
const snapshotProcess = `ci-process=snapshot&time=1&format=jpg&${CANVAS_OUTLINE_IMAGE_PROCESS}`;
return appendTencentImageProcess(url, snapshotProcess);
}
return appendTencentImageProcess(url, CANVAS_OUTLINE_IMAGE_PROCESS);
}
function getNodePreviewUrl(node: WorkflowNode): string | null {
const data = node.data as Record<string, unknown>;
const candidates = [
@ -93,7 +116,18 @@ function getNodePreviewUrl(node: WorkflowNode): string | null {
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
return buildCanvasOutlinePreviewUrl(candidate);
}
}
const videoCandidates = [
data.video,
data.outputVideo,
];
for (const candidate of videoCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
return buildCanvasOutlinePreviewUrl(candidate, "video");
}
}

2
src/components/HomeProjectsContent.tsx

@ -367,7 +367,7 @@ function ProjectActionModals({
onConfirmDelete: () => void;
}) {
const modalClassName =
"[&_.ant-modal-content]:!rounded-[9px] [&_.ant-modal-content]:!p-[30px] [&_.ant-modal-header]:!mb-0 [&_.ant-modal-body]:!pt-2 [&_.ant-modal-footer]:!mt-6 [&_.ant-modal-footer]:!flex [&_.ant-modal-footer]:!justify-end [&_.ant-modal-footer]:!gap-2.5 [&_.ant-modal-footer_.ant-btn+_.ant-btn]:!ml-0 [&_.ant-modal-close]:!right-2.5 [&_.ant-modal-close]:!top-2.5 [&_.ant-modal-close]:!h-5 [&_.ant-modal-close]:!w-5 [&_.ant-modal-close]:!text-[#999] hover:[&_.ant-modal-close]:!text-[#666]";
"[&_.ant-modal-container]:!rounded-[9px] [&_.ant-modal-container]:!p-[30px] [&_.ant-modal-header]:!mb-0 [&_.ant-modal-body]:!pt-2 [&_.ant-modal-footer]:!mt-6 [&_.ant-modal-footer]:!flex [&_.ant-modal-footer]:!justify-end [&_.ant-modal-footer]:!gap-2.5 [&_.ant-modal-footer_.ant-btn+_.ant-btn]:!ml-0 [&_.ant-modal-close]:!right-2.5 [&_.ant-modal-close]:!top-2.5 [&_.ant-modal-close]:!h-5 [&_.ant-modal-close]:!w-5 [&_.ant-modal-close]:!text-[#999] hover:[&_.ant-modal-close]:!text-[#666]";
const modalTitleClassName = "text-[24px] font-medium leading-[22px] text-[#333]";
const cancelButtonClassName =
"!h-[37px] !w-[109px] !rounded-[24px] !border-0 !bg-[#f0f0f0] !p-0 !text-[16px] !font-medium !text-[#666] !shadow-none hover:!bg-[#e6e6e6] hover:!text-[#666]";

4
src/components/SaveToAssetLibraryModal.tsx

@ -197,7 +197,7 @@ export function SaveToAssetLibraryModal({
onCancel={submitting ? undefined : onClose}
className={[
"nodrag nopan nowheel",
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-lg [&_.ant-modal-content]:!border-0 [&_.ant-modal-content]:!bg-[var(--surface-1)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-container]:!overflow-hidden [&_.ant-modal-container]:!rounded-lg [&_.ant-modal-container]:!border-0 [&_.ant-modal-container]:!bg-[var(--surface-1)] [&_.ant-modal-container]:!p-0 [&_.ant-modal-container]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-body]:!p-0",
].join(" ")}
>
@ -230,7 +230,7 @@ export function SaveToAssetLibraryModal({
treeExpandedKeys={expandedFolderIds}
onTreeExpand={handleTreeExpand}
className="w-full [&_.ant-select-selector]:!border-[var(--border-subtle)] [&_.ant-select-selector]:!bg-[var(--surface-2)] [&_.ant-select-selection-item]:!text-[var(--text-primary)] [&_.ant-select-selection-placeholder]:!text-[var(--text-muted)]"
popupClassName="nodrag nopan nowheel"
classNames={{ popup: { root: "nodrag nopan nowheel" } }}
/>
)}
</div>

4
src/components/WorkflowCanvas.tsx

@ -43,6 +43,7 @@ import { ActiveImageEditHost } from "./canvas/ActiveImageEditHost";
import { ActiveVideoEditHost } from "./canvas/ActiveVideoEditHost";
import { ActiveNodeComposerHost } from "./canvas/ActiveNodeComposerHost";
import { ActivePanoramaHost } from "./canvas/ActivePanoramaHost";
import { SaveToAssetLibraryHost } from "./canvas/SaveToAssetLibraryHost";
import { NodeToolSelectionController } from "./canvas/NodeToolSelectionController";
import { logger } from "@/utils/logger";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
@ -2248,7 +2249,6 @@ export function WorkflowCanvas() {
const {
menu: contextMenuElement,
uploadInput: contextMenuUploadInput,
saveToAssetModal,
handleKeyboardPaste,
} = useCanvasContextMenuActions({
contextMenu,
@ -3160,6 +3160,7 @@ export function WorkflowCanvas() {
<ActivePanoramaHost />
<ActiveVideoEditHost />
<ActiveNodeComposerHost />
<SaveToAssetLibraryHost />
<NodeToolSelectionController />
</ViewportPortal>
</ReactFlow>
@ -3267,7 +3268,6 @@ export function WorkflowCanvas() {
{contextMenuUploadInput}
{contextMenuElement}
{saveToAssetModal}
{/* Multi-select toolbar */}
<MultiSelectToolbar bounds={multiSelectToolbarBounds} />

48
src/components/__tests__/CanvasOutlineDrawer.test.tsx

@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { CanvasOutlineDrawer } from "@/components/CanvasOutlineDrawer";
import {
CanvasOutlineDrawer,
buildCanvasOutlinePreviewUrl,
} from "@/components/CanvasOutlineDrawer";
const translations: Record<string, string> = {
"canvasOutline.title": "Canvas outline",
@ -15,6 +18,7 @@ const translations: Record<string, string> = {
"canvasOutline.expandGroup": "Expand group",
"canvasOutline.collapseGroup": "Collapse group",
"node.imageInput": "Image Input",
"node.videoInput": "Video Input",
"node.prompt": "Prompt",
};
@ -46,7 +50,19 @@ const createState = () => ({
id: "image-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: { customTitle: "Hero image" },
data: {
customTitle: "Hero image",
image: "https://statictest.popi.art/media/2026/0714/11631.png",
},
},
{
id: "video-1",
type: "videoInput",
position: { x: 20, y: 0 },
data: {
customTitle: "Opening video",
video: "https://statictest.popi.art/media/2026/0714/11666.mp4",
},
},
{
id: "group-node-group-1",
@ -86,9 +102,10 @@ describe("CanvasOutlineDrawer", () => {
render(<CanvasOutlineDrawer open onClose={vi.fn()} />);
expect(screen.getByText("Hero image")).toBeInTheDocument();
expect(screen.getByText("Opening video")).toBeInTheDocument();
expect(screen.getByText("Storyboard")).toBeInTheDocument();
expect(screen.getByText("Prompt")).toBeInTheDocument();
expect(screen.getByText("2 nodes")).toBeInTheDocument();
expect(screen.getByText("3 nodes")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("Hero image"));
@ -104,4 +121,29 @@ describe("CanvasOutlineDrawer", () => {
expect(mockSelectSingleNode).toHaveBeenCalledWith("group-node-group-1");
expect(mockSetNavigationTarget).toHaveBeenCalledWith("group-node-group-1");
});
it("uses compressed Tencent Cloud previews only inside the canvas outline", () => {
render(<CanvasOutlineDrawer open onClose={vi.fn()} />);
const imagePreview = screen.getByTitle("Hero image").querySelector("img");
const videoPreview = screen.getByTitle("Opening video").querySelector("img");
expect(imagePreview).toHaveAttribute(
"src",
"https://statictest.popi.art/media/2026/0714/11631.png?imageMogr2/thumbnail/120x/format/webp/quality/20"
);
expect(videoPreview).toHaveAttribute(
"src",
"https://statictest.popi.art/media/2026/0714/11666.mp4?ci-process=snapshot&time=1&format=jpg&imageMogr2/thumbnail/120x/format/webp/quality/20"
);
});
});
describe("buildCanvasOutlinePreviewUrl", () => {
it("preserves existing query strings and leaves local urls unchanged", () => {
expect(buildCanvasOutlinePreviewUrl("https://example.com/a.png?token=1")).toBe(
"https://example.com/a.png?token=1&imageMogr2/thumbnail/120x/format/webp/quality/20"
);
expect(buildCanvasOutlinePreviewUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc");
});
});

2
src/components/canvas/ActivePanoramaHost.tsx

@ -231,7 +231,7 @@ export function ActivePanoramaHost() {
body: { padding: 0 },
}}
rootClassName="[&_.ant-modal-wrap]:!p-0 [&_.ant-modal-container]:!p-0 [&_.ant-modal]:!p-0 [&_.ant-modal]:!top-0 [&_.ant-modal]:!m-0"
className="panorama-enlarge-modal [&_.ant-modal-content]:!bg-black [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!rounded-none [&_.ant-modal-content]:!shadow-none"
className="panorama-enlarge-modal [&_.ant-modal-container]:!bg-black [&_.ant-modal-container]:!p-0 [&_.ant-modal-container]:!rounded-none [&_.ant-modal-container]:!shadow-none"
>
{/* 全屏纯看图:内层直接给视口高度,canvas 铺满,不依赖 antd 高度链。 */}
<div className="relative h-screen w-full overflow-hidden bg-black">

22
src/components/canvas/SaveToAssetLibraryHost.tsx

@ -0,0 +1,22 @@
"use client";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { useNodeToolStore } from "@/store/nodeToolStore";
/**
* 宿
* nodeToolStore.openSaveToAsset(url)
* Modal
*/
export function SaveToAssetLibraryHost() {
const mediaUrl = useNodeToolStore((state) => state.saveToAssetMediaUrl);
const closeSaveToAsset = useNodeToolStore((state) => state.closeSaveToAsset);
return (
<SaveToAssetLibraryModal
open={Boolean(mediaUrl)}
mediaUrl={mediaUrl}
onClose={closeSaveToAsset}
/>
);
}

10
src/components/nodes/AudioInputNode.tsx

@ -17,7 +17,7 @@ import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { useNodeToolStore } from "@/store/nodeToolStore";
type AudioInputNodeType = Node<AudioInputNodeData, "audioInput">;
const AUDIO_INPUT_DIMENSIONS = defaultNodeDimensions.audioInput;
@ -47,7 +47,6 @@ export function AudioInputNodeView({
const isUploading = nodeData.uploadStatus === "loading";
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
// Convert base64 data URL to Blob for the hook
useEffect(() => {
@ -196,7 +195,7 @@ export function AudioInputNodeView({
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
if (nodeData.audioFile) setIsSaveToAssetLibraryOpen(true);
useNodeToolStore.getState().openSaveToAsset(nodeData.audioFile);
},
},
{
@ -364,11 +363,6 @@ export function AudioInputNodeView({
handleType="audio"
style={{ opacity: 0, zIndex: 10 }}
/>
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.audioFile || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
</BaseNode>
);
}

10
src/components/nodes/GenerateAudioNode.tsx

@ -31,7 +31,7 @@ import {
} from "@/utils/generationConfig";
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { useNodeToolStore } from "@/store/nodeToolStore";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
const AUDIO_COMPOSER_PARAMETER_NAMES = ["voiceId", "voice_id"];
@ -59,7 +59,6 @@ export function GenerateAudioNodeView({
const popiserverAudioModels = useModelStore((state) => state.byKind.audio);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isLoadingCarouselAudio, setIsLoadingCarouselAudio] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const defaultSelectedModel = useMemo<SelectedModel>(
() => nodeData.selectedModel ?? {
provider: POPI_PROVIDER_ID,
@ -370,7 +369,7 @@ export function GenerateAudioNodeView({
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
setIsSaveToAssetLibraryOpen(true);
useNodeToolStore.getState().openSaveToAsset(nodeData.outputAudio);
},
},
{
@ -591,11 +590,6 @@ export function GenerateAudioNodeView({
initialCapabilityFilter="audio"
/>
)}
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.outputAudio || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
{(!showInlineNodeSettings || !inlineParametersEnabled) && nodeConfig.selectedModel?.modelId && (
<div className="hidden">

9
src/components/nodes/GenerateImageNode.tsx

@ -28,7 +28,6 @@ import { formatUserFacingError } from "@/utils/userFacingErrors";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { createMediaEditActionDropdown, getMediaEditMenuLabel, type MediaEditMenuTool } from "@/components/media/MediaEditActionDropdown";
import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu";
import type { ImageEditTool } from "@/components/media/image-edit/types";
@ -131,7 +130,6 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle =
const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const [activeEditMenuTool, setActiveEditMenuTool] = useState<MediaEditMenuTool>("crop");
const [cropImageDimensions, setCropImageDimensions] = useState<{ width: number; height: number } | null>(null);
const { registerImage, registerMediaFrame } = useNodeImageRegistration(id);
@ -565,7 +563,7 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle =
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
setIsSaveToAssetLibraryOpen(true);
useNodeToolStore.getState().openSaveToAsset(nodeData.outputImage);
},
},
{
@ -833,11 +831,6 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle =
onClose={() => setIsPreviewOpen(false)}
/>
)}
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.outputImage || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
{/* Model browse dialog */}
{isBrowseDialogOpen && (

9
src/components/nodes/GenerateVideoNode.tsx

@ -28,7 +28,6 @@ import {
} from "@/utils/generationConfig";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { asMediaDimensions, getAutoMediaElementClassName, getAutoMediaFrameClassName } from "./mediaAutoSize";
import { CustomVideoPlayer } from "@/components/media/CustomVideoPlayer";
import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode";
@ -72,7 +71,6 @@ export function GenerateVideoNodeView({ id, data, selected, suppressEmptyStateDe
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isLoadingCarouselVideo, setIsLoadingCarouselVideo] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const [videoRetryNonce, setVideoRetryNonce] = useState(0);
const addNode = useWorkflowStore((state) => state.addNode);
@ -466,7 +464,7 @@ export function GenerateVideoNodeView({ id, data, selected, suppressEmptyStateDe
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
setIsSaveToAssetLibraryOpen(true);
useNodeToolStore.getState().openSaveToAsset(nodeData.outputVideo);
},
},
{
@ -754,11 +752,6 @@ export function GenerateVideoNodeView({ id, data, selected, suppressEmptyStateDe
onClose={() => setIsPreviewOpen(false)}
/>
)}
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.outputVideo || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
{/* Hidden ModelParameters - only for schema-loading side effect (dynamic handles). */}
{(!showInlineNodeSettings || !inlineParametersEnabled) && nodeConfig.selectedModel?.modelId && (

9
src/components/nodes/ImageInputNode.tsx

@ -20,7 +20,6 @@ import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { getPopiImageAssetPreview } from "@/lib/popiAssetApi";
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { createMediaEditActionDropdown, getMediaEditMenuLabel, type MediaEditMenuTool } from "@/components/media/MediaEditActionDropdown";
import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu";
import { GridToolIcon, MultiAngleToolIcon } from "@/components/media/MediaToolbarIcons";
@ -138,7 +137,6 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
const { registerImage, registerMediaFrame } = useNodeImageRegistration(id);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const [activeEditMenuTool, setActiveEditMenuTool] = useState<MediaEditMenuTool>("crop");
const [imageRetryNonce, setImageRetryNonce] = useState(0);
const [displayFallbackImage, setDisplayFallbackImage] = useState<string | null>(null);
@ -464,7 +462,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
if (nodeData.image) setIsSaveToAssetLibraryOpen(true);
useNodeToolStore.getState().openSaveToAsset(nodeData.image);
},
},
{
@ -679,11 +677,6 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
onSelect={handleSelectAsset}
/>
)}
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.image || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
</BaseNode>
);
}

9
src/components/nodes/VideoInputNode.tsx

@ -17,7 +17,6 @@ import { defaultNodeDimensions } from "@/constants/nodeDimensions";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { getPopiVideoAssetPreview } from "@/lib/popiAssetApi";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { getAutoMediaElementClassName, getAutoMediaFrameClassName } from "./mediaAutoSize";
import { CustomVideoPlayer } from "@/components/media/CustomVideoPlayer";
import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode";
@ -91,7 +90,6 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
const fileInputRef = useRef<HTMLInputElement>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const [videoRetryNonce, setVideoRetryNonce] = useState(0);
const isUploading = nodeData.uploadStatus === "loading";
const previewVideoPoster =
@ -353,7 +351,7 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
if (nodeData.video) setIsSaveToAssetLibraryOpen(true);
useNodeToolStore.getState().openSaveToAsset(nodeData.video);
},
},
{
@ -543,11 +541,6 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
onSelect={handleSelectAsset}
/>
)}
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.video || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
</BaseNode>
);
}

15
src/hooks/useCanvasContextMenuActions.tsx

@ -1,12 +1,12 @@
"use client";
import { useCallback, useRef, useState, type ChangeEvent, type ReactNode } from "react";
import { useCallback, useRef, type ChangeEvent, type ReactNode } from "react";
import type { XYPosition } from "@xyflow/react";
import type { WorkflowEdge, WorkflowNode, WorkflowNodeData } from "@/types";
import { CanvasContextMenu, type CanvasContextMenuAction, type CanvasContextMenuState } from "@/components/CanvasContextMenu";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { useToast } from "@/components/Toast";
import { useI18n } from "@/i18n";
import { useNodeToolStore } from "@/store/nodeToolStore";
import { useWorkflowStore } from "@/store/workflowStore";
import { pasteClipboardAtPosition } from "@/utils/canvasClipboard";
import {
@ -29,7 +29,6 @@ interface UseCanvasContextMenuActionsOptions {
interface CanvasContextMenuActionBindings {
menu: ReactNode;
uploadInput: ReactNode;
saveToAssetModal: ReactNode;
handleKeyboardPaste: () => void;
}
@ -47,7 +46,6 @@ export function useCanvasContextMenuActions({
const { show: showToast } = useToast();
const uploadInputRef = useRef<HTMLInputElement>(null);
const uploadFlowPositionRef = useRef<XYPosition | null>(null);
const [assetLibraryMediaUrl, setAssetLibraryMediaUrl] = useState<string | null>(null);
const addNode = useWorkflowStore((state) => state.addNode);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
@ -133,7 +131,7 @@ export function useCanvasContextMenuActions({
return;
}
case "saveToAssets": {
if (contextMenu.targetMediaUrl) setAssetLibraryMediaUrl(contextMenu.targetMediaUrl);
useNodeToolStore.getState().openSaveToAsset(contextMenu.targetMediaUrl);
return;
}
case "copyNode":
@ -203,12 +201,5 @@ export function useCanvasContextMenuActions({
onClose={onCloseContextMenu}
/>
) : null,
saveToAssetModal: (
<SaveToAssetLibraryModal
open={Boolean(assetLibraryMediaUrl)}
mediaUrl={assetLibraryMediaUrl}
onClose={() => setAssetLibraryMediaUrl(null)}
/>
),
};
}

15
src/store/nodeToolStore.ts

@ -64,11 +64,19 @@ interface NodeToolState {
* useSelectedNodeCount/
*/
soleSelectedNodeId: string | null;
/**
* URLnull
* SaveToAssetLibraryHost
* <SaveToAssetLibraryModal>
*/
saveToAssetMediaUrl: string | null;
openTool: (nodeId: string, tool: NodeToolType) => void;
switchTool: (nodeId: string, tool: NodeToolType) => void;
setSplitGridSelection: (selection: SplitGridSelection | null) => void;
setSelectionTool: (nodeId: string | null, tool: SelectionToolType | null) => void;
setSoleSelectedNodeId: (nodeId: string | null) => void;
openSaveToAsset: (mediaUrl: string | null | undefined) => void;
closeSaveToAsset: () => void;
closeTool: () => void;
closeToolForNode: (nodeId: string) => void;
hideTool: () => void;
@ -111,6 +119,7 @@ export const useNodeToolStore = create<NodeToolState>()((set, get) => ({
selectionNodeId: null,
selectionTool: null,
soleSelectedNodeId: null,
saveToAssetMediaUrl: null,
openTool: (nodeId, tool) => set({ activeNodeId: nodeId, activeTool: tool, visible: true, splitGridSelection: null }),
switchTool: (nodeId, tool) => set({ activeNodeId: nodeId, activeTool: tool, visible: true, splitGridSelection: null }),
setSplitGridSelection: (selection) => set({ splitGridSelection: selection }),
@ -124,6 +133,12 @@ export const useNodeToolStore = create<NodeToolState>()((set, get) => ({
if (get().soleSelectedNodeId === nodeId) return;
set({ soleSelectedNodeId: nodeId });
},
// 保存到素材库:仅在有媒体 URL 时打开,避免空弹窗。
openSaveToAsset: (mediaUrl) => {
if (!mediaUrl) return;
set({ saveToAssetMediaUrl: mediaUrl });
},
closeSaveToAsset: () => set({ saveToAssetMediaUrl: null }),
// 关闭仅清理显式工具状态,保留 selection* —— 关掉 crop 等工具后,选区派生的
// composer 能凭 selectionTool 自动重新出现,无需反向重开逻辑。
closeTool: () => set({ activeNodeId: null, activeTool: null, visible: false, splitGridSelection: null }),

Loading…
Cancel
Save