From 981312520d60744e316ca36c9fba62c294716ce2 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 7 Jul 2026 12:21:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20replace=20canvas=20pr?= =?UTF-8?q?ojectId=20prop=20drilling=20with=20zustand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- .../EditorAgentConversationPanelView.test.tsx | 14 ++++++-------- .../EditorAgentConversationPanelView.tsx | 5 +++-- .../ImageCanvasEditorShellView.test.tsx | 2 -- .../image-editor/ImageCanvasEditorView.tsx | 9 +++++++-- .../image-editor/ImageCanvasStageView.tsx | 4 ---- .../ImageCanvasTaskSidebarView.test.tsx | 8 ++------ .../image-editor/ImageCanvasTaskSidebarView.tsx | 4 ++-- .../image-editor/ImageCanvasTopbarView.test.tsx | 5 ++--- .../image-editor/ImageCanvasTopbarView.tsx | 4 ++-- .../image-editor/useImageCanvasContextStore.ts | 14 ++++++++++++++ 11 files changed, 40 insertions(+), 32 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasContextStore.ts diff --git a/package.json b/package.json index 87c2d3280..0136a88a0 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,8 @@ "react-native-safe-area-context": "^5.8.0", "react-native-webview": "^13.16.1", "three": "^0.184.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "zustand": "^5.0.14" }, "devDependencies": { "@colbymchenry/codegraph": "^0.8.0", diff --git a/src/components/image-editor/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx index c6b7dc8b1..25108f430 100644 --- a/src/components/image-editor/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx @@ -8,9 +8,10 @@ import { waitFor, within, } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView'; +import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; import type { EditorAgentConversationClient } from './useEditorAgentConversation'; function createClient(): EditorAgentConversationClient { @@ -85,12 +86,15 @@ function createClient(): EditorAgentConversationClient { } describe('EditorAgentConversationPanelView', () => { + beforeEach(() => { + useImageCanvasContextStore.getState().setProjectId('project-1'); + }); + it('manages conversations, attachments and message sending inside the panel', async () => { const client = createClient(); render( { render( { const { rerender } = render( { rerender( { rerender( { render(
{ const client = createClient(); render( void; layers?: CanvasLayer[]; @@ -404,7 +403,6 @@ function AttachmentPickerModal({ } export function EditorAgentConversationPanelView({ - projectId, open, onToggleOpen, layers = [], @@ -418,6 +416,9 @@ export function EditorAgentConversationPanelView({ setHasConversationMounted(true); } }, [open]); + const projectId = useImageCanvasContextStore( + (state) => state.projectId, + ); const effectiveProjectId = hasConversationMounted ? projectId : null; const { conversations, diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index dc5fa045a..bbfb494b6 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -93,7 +93,6 @@ function createSidebarProps(): ImageCanvasSidebarViewProps { function createTopbarProps(): ImageCanvasTopbarViewProps { return { - projectId: 'project-1', projectTitle: '默认项目', projectRenameValue: '默认项目', isRenamingProject: false, @@ -119,7 +118,6 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { function createStageProps(): ImageCanvasStageViewProps { return { - projectId: 'project-1', canvasViewportRef: createRef(), specToolWrapRef: createRef(), musicToolWrapRef: createRef(), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 504334bf5..b15077d26 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -81,6 +81,7 @@ import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface'; import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; +import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasStageController } from './useImageCanvasStageController'; import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; @@ -1015,6 +1016,12 @@ export function ImageCanvasEditorView({ openEditorLoginModal, onProjectAccessLost, }); + const setEditorProjectContextId = useImageCanvasContextStore( + (state) => state.setProjectId, + ); + useEffect(() => { + setEditorProjectContextId(projectId); + }, [projectId, setEditorProjectContextId]); const applyGeneratedProjectSnapshot = useCallback( (project: EditorProjectSnapshot) => { applyProjectSnapshot(project); @@ -1890,7 +1897,6 @@ export function ImageCanvasEditorView({ getCanvasPointFromClient, }; const topbarProps = { - projectId, projectTitle, projectRenameValue, isRenamingProject, @@ -1919,7 +1925,6 @@ export function ImageCanvasEditorView({ }, }; const stageProps = { - projectId, canvasViewportRef, specToolWrapRef, musicToolWrapRef, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 68ba896b0..3867fb717 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -41,7 +41,6 @@ import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExt import { ImageCanvasWorldView } from './ImageCanvasWorldView'; export type ImageCanvasStageViewProps = { - projectId?: string | null; canvasViewportRef: RefObject; specToolWrapRef: RefObject; musicToolWrapRef: RefObject; @@ -192,7 +191,6 @@ export type ImageCanvasStageViewProps = { }; export function ImageCanvasStageView({ - projectId, canvasViewportRef, specToolWrapRef, musicToolWrapRef, @@ -463,7 +461,6 @@ export function ImageCanvasStageView({ /> ({ listExternalGenerationTasks: vi.fn().mockResolvedValue({ @@ -22,6 +23,7 @@ vi.mock('../../services/external-generation', () => ({ const listExternalGenerationTasksMock = vi.mocked(listExternalGenerationTasks); beforeEach(() => { + useImageCanvasContextStore.getState().setProjectId('project-1'); listExternalGenerationTasksMock.mockClear(); listExternalGenerationTasksMock.mockResolvedValue({ overview: { @@ -115,7 +117,6 @@ describe('ImageCanvasTaskSidebarView', () => { render(
{ render( { render( { const { rerender } = render( { ]; rerender( { render( void; @@ -252,12 +252,12 @@ function trimStoredExternalTasks( } export function ImageCanvasTaskSidebarView({ - projectId, refreshKey = 0, open, onToggleOpen, onFocusExternalTask, }: ImageCanvasTaskSidebarViewProps) { + const projectId = useImageCanvasContextStore((state) => state.projectId); const normalizedProjectId = projectId?.trim() ?? ''; const [activeTab, setActiveTab] = useState('active'); const [now, setNow] = useState(() => Date.now()); diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index b9f2c584b..ba0c69689 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { ImageCanvasTopbarView } from './ImageCanvasTopbarView'; +import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; function createLayer(overrides: Partial = {}): CanvasLayer { const id = overrides.id ?? 'layer-a'; @@ -30,8 +31,8 @@ function renderTopbar( Parameters[0] > = {}, ) { + useImageCanvasContextStore.getState().setProjectId('project-a'); const props: Parameters[0] = { - projectId: 'project-a', projectTitle: '默认项目', projectRenameValue: '默认项目', isRenamingProject: false, @@ -144,7 +145,6 @@ describe('ImageCanvasTopbarView', () => { const exportCanvasAssets = vi.fn(); const { rerender } = render( { rerender( state.projectId); const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, ); diff --git a/src/components/image-editor/useImageCanvasContextStore.ts b/src/components/image-editor/useImageCanvasContextStore.ts new file mode 100644 index 000000000..0869656f5 --- /dev/null +++ b/src/components/image-editor/useImageCanvasContextStore.ts @@ -0,0 +1,14 @@ +import { create } from 'zustand'; + +type ImageCanvasContextState = { + projectId: string | null; + setProjectId: (projectId?: string | null) => void; +}; + +export const useImageCanvasContextStore = + create((set) => ({ + projectId: null, + setProjectId: (projectId) => { + set({ projectId: projectId?.trim() || null }); + }, + })); -- 2.52.0 From aca6cfda9a6d1d623857b6aaa19daa101a23f724 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 7 Jul 2026 12:21:51 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9C=A8=E5=AF=B9=E8=AF=9D=E6=A1=86=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E5=9B=BE=E7=89=87=E4=BD=9C=E4=B8=BA=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditorAgentConversationPanelView.tsx | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/components/image-editor/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversationPanelView.tsx index bbe08ea64..665162fc8 100644 --- a/src/components/image-editor/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversationPanelView.tsx @@ -11,6 +11,7 @@ import { X, } from 'lucide-react'; import { + type ClipboardEvent as ReactClipboardEvent, type FormEvent, type WheelEvent as ReactWheelEvent, useEffect, @@ -30,7 +31,11 @@ import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { UnifiedModal } from '../common/UnifiedModal'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; +import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient'; +import { createEditorProjectResource } from '../../services/image-editor/editorProjectClient'; import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes'; +import { probeImageFileDimensions } from './ImageCanvasFileModel'; +import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; import { type EditorAgentConversationClient, useEditorAgentConversation, @@ -449,6 +454,7 @@ export function EditorAgentConversationPanelView({ const [attachmentPickerTab, setAttachmentPickerTab] = useState('canvas'); const [attachmentError, setAttachmentError] = useState(null); + const [isPastingAttachment, setIsPastingAttachment] = useState(false); const [draftAttachmentKeys, setDraftAttachmentKeys] = useState>( () => new Set(), ); @@ -511,6 +517,9 @@ export function EditorAgentConversationPanelView({ stopCurrentTurn(); return; } + if (isPastingAttachment) { + return; + } const text = draftText.trim(); if (!text && !attachments.length) { return; @@ -525,6 +534,113 @@ export function EditorAgentConversationPanelView({ ); }); }; + const appendAttachments = (nextAttachments: EditorAgentAttachmentRef[]) => { + function mergeAttachments( + currentAttachments: EditorAgentAttachmentRef[], + nextAttachments: EditorAgentAttachmentRef[], + ) { + const merged = [...currentAttachments]; + const existingKeys = new Set(currentAttachments.map(attachmentKey)); + nextAttachments.forEach((attachment) => { + const key = attachmentKey(attachment); + if (!existingKeys.has(key)) { + existingKeys.add(key); + merged.push(attachment); + } + }); + return merged; + } + const mergedAttachments = mergeAttachments(attachments, nextAttachments); + if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) { + setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`); + return false; + } + setAttachments(mergedAttachments); + setAttachmentError(null); + return true; + }; + + const createPastedAgentImageAttachment = async ( + file: File, + ): Promise => { + if (!projectId?.trim()) { + throw new Error('缺少画布项目'); + } + const [upload, dimensions] = await Promise.all([ + uploadEditorMediaAssetFile(file, 'image', { + pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`], + entityId: projectId, + metadata: { + source: 'agent-input-paste', + }, + }), + probeImageFileDimensions(file), + ]); + const width = dimensions?.width ?? 1; + const height = dimensions?.height ?? 1; + const resource = await createEditorProjectResource(projectId, { + imageSrc: upload.src, + objectKey: upload.objectKey, + assetObjectId: upload.assetObjectId, + width, + height, + sourceType: 'uploaded', + }); + return { + source: 'canvas_resource', + referenceId: resource.resourceId, + objectKey: resource.objectKey ?? upload.objectKey, + imageSrc: resource.imageSrc, + thumbnailSrc: null, + label: resource.label ?? '粘贴图片', + width: resource.width, + height: resource.height, + }; + }; + function extractClipboardImageFiles( + clipboardData: DataTransfer | null, + ): File[] { + if (!clipboardData) { + return []; + } + const fileItems = Array.from(clipboardData.files ?? []).filter((file) => + file.type.startsWith('image/'), + ); + return [...fileItems]; + } + + const handleInputPaste = (event: ReactClipboardEvent) => { + const imageFiles = extractClipboardImageFiles(event.clipboardData); + if (!imageFiles.length) { + return; + } + + if (isPastingAttachment) { + return; + } + + event.preventDefault(); + if (attachments.length >= EDITOR_AGENT_MAX_ATTACHMENTS) { + setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`); + return; + } + + // TODO: deduplicate those existing assets + setIsPastingAttachment(true); + setAttachmentError('图片上传中'); + void Promise.all( + imageFiles.map((file) => createPastedAgentImageAttachment(file)), + ) + .then((pastedAttachments) => { + appendAttachments(pastedAttachments); + }) + .catch(() => { + setAttachmentError('图片粘贴失败,请重试'); + }) + .finally(() => { + setIsPastingAttachment(false); + }); + }; const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => { const key = attachmentKey(targetAttachment); @@ -655,6 +771,11 @@ export function EditorAgentConversationPanelView({ {errorMessage}
) : null} + {attachmentError ? ( +
+ {attachmentError} +
+ ) : null}
setDraftText(event.currentTarget.value)} + onPaste={handleInputPaste} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); -- 2.52.0 From f166c28bc92dcb87095637d5c4b5ff1893b0014a Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 7 Jul 2026 13:18:35 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=B5=8B=E8=AF=95:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=E5=8F=8A?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=88=9B=E5=BB=BA=E7=9A=84=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditorAgentConversationPanelView.test.tsx | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/components/image-editor/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx index 25108f430..30bf11244 100644 --- a/src/components/image-editor/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx @@ -14,6 +14,34 @@ import { EditorAgentConversationPanelView } from './EditorAgentConversationPanel import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; import type { EditorAgentConversationClient } from './useEditorAgentConversation'; +const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); +const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); +const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/image-editor/editorProjectClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/image-editor/editorProjectClient') + >('../../services/image-editor/editorProjectClient'); + return { + ...actual, + createEditorProjectResource: createEditorProjectResourceMock, + }; +}); + +vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, +})); + +vi.mock('./ImageCanvasFileModel', async () => { + const actual = await vi.importActual( + './ImageCanvasFileModel', + ); + return { + ...actual, + probeImageFileDimensions: probeImageFileDimensionsMock, + }; +}); + function createClient(): EditorAgentConversationClient { return { listConversations: vi.fn().mockResolvedValue([ @@ -88,6 +116,24 @@ function createClient(): EditorAgentConversationClient { describe('EditorAgentConversationPanelView', () => { beforeEach(() => { useImageCanvasContextStore.getState().setProjectId('project-1'); + uploadEditorMediaAssetFileMock.mockReset(); + uploadEditorMediaAssetFileMock.mockResolvedValue({ + src: '/generated/pasted.png', + objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', + assetObjectId: 'asset-object-pasted', + legacyPublicPath: '/generated/pasted.png', + }); + probeImageFileDimensionsMock.mockReset(); + probeImageFileDimensionsMock.mockResolvedValue({ width: 320, height: 240 }); + createEditorProjectResourceMock.mockReset(); + createEditorProjectResourceMock.mockResolvedValue({ + resourceId: 'resource-pasted', + imageSrc: '/generated/pasted.png', + objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', + label: '粘贴图片', + width: 320, + height: 240, + }); }); it('manages conversations, attachments and message sending inside the panel', async () => { @@ -195,6 +241,79 @@ describe('EditorAgentConversationPanelView', () => { ); }); + it('uploads pasted images as canvas attachments before sending', async () => { + const client = createClient(); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + + const pastedImage = new File(['pasted-image'], 'pasted.png', { + type: 'image/png', + }); + const input = screen.getByLabelText('发送给画布 Agent'); + fireEvent.paste(input, { + clipboardData: { + files: [pastedImage], + }, + }); + + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + pastedImage, + 'image', + expect.objectContaining({ + entityId: 'project-1', + metadata: { source: 'agent-input-paste' }, + }), + ); + }); + expect(probeImageFileDimensionsMock).toHaveBeenCalledWith(pastedImage); + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'project-1', + expect.objectContaining({ + imageSrc: '/generated/pasted.png', + objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', + assetObjectId: 'asset-object-pasted', + width: 320, + height: 240, + sourceType: 'uploaded', + }), + ); + await waitFor(() => { + expect(screen.getByText('粘贴图片')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + expect(client.streamMessage).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ + text: '', + attachments: [ + expect.objectContaining({ + source: 'canvas_resource', + referenceId: 'resource-pasted', + imageSrc: '/generated/pasted.png', + width: 320, + height: 240, + }), + ], + }), + expect.any(Object), + ); + }); + }); + it('sends selected attachments even when the text input is empty', async () => { const client = createClient(); -- 2.52.0