Merge remote-tracking branch 'web/master' into feat/pixel_art2
Project CI / Frontend tests (pull_request) Failing after 22s
Project CI / Repository checks (pull_request) Failing after 51s
Project CI / Backend tests (pull_request) Successful in 4m9s
Project CI / Native shell tests (pull_request) Successful in 12m34s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-07-31 11:37:57 +00:00
61 changed files with 2878 additions and 551 deletions
@@ -3,11 +3,12 @@
import {
act,
fireEvent,
render,
render as testingLibraryRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import type { ReactElement, ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
@@ -17,6 +18,7 @@ import type {
} from '@/packages/shared/src/contracts';
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx';
@@ -24,6 +26,21 @@ import { EditorAgentConversationPanelView } from './EditorAgentConversationPanel
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn());
const focusResourceMock = vi.fn();
const refreshCanvasMock = vi.fn();
function render(ui: ReactElement) {
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={refreshCanvasMock}
>
{children}
</ImageCanvasActionsProvider>
),
});
}
const ATTACHMENT_PROMPT = '请参考附件';
vi.mock('@/src/services/image-editor/editorProjectClient.ts', async () => {
@@ -191,6 +208,7 @@ function createPendingToolCallMessage(): EditorAgentMessage {
describe('EditorAgentConversationPanelView', () => {
beforeEach(() => {
focusResourceMock.mockReset();
useImageCanvasContextStore.getState().setProjectId('project-1');
uploadEditorMediaAssetFileMock.mockReset();
uploadEditorMediaAssetFileMock.mockResolvedValue({
@@ -1809,14 +1827,11 @@ describe('EditorAgentConversationPanelView', () => {
resolveConfirmation = resolve;
}),
);
const onCanvasRefreshRequested = vi.fn();
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
onCanvasRefreshRequested={onCanvasRefreshRequested}
/>,
);
@@ -1858,7 +1873,7 @@ describe('EditorAgentConversationPanelView', () => {
'internal completed tool output that must stay hidden',
),
).toBeNull();
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
expect(refreshCanvasMock).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
});
@@ -42,7 +42,6 @@ type EditorAgentConversationPanelViewProps = {
onToggleOpen: () => void;
layers?: CanvasLayer[];
assets?: EditorAsset[];
onCanvasRefreshRequested?: () => void;
// TODO refactor: move the task list update seperate
onConfirmSent?: () => void;
client?: EditorAgentConversationClient;
@@ -57,7 +56,6 @@ export function EditorAgentConversationPanelView({
onToggleOpen,
layers = [],
assets = [],
onCanvasRefreshRequested,
onConfirmSent,
client,
}: EditorAgentConversationPanelViewProps) {
@@ -93,7 +91,6 @@ export function EditorAgentConversationPanelView({
} = useEditorAgentConversation({
projectId: effectiveProjectId,
client,
onCanvasRefreshRequested,
onConfirmSent,
});
const [draftText, setDraftText] = useState('');
@@ -308,7 +305,6 @@ export function EditorAgentConversationPanelView({
onReferenceImage={referenceContextAsset}
onJobCompleted={() => {
void refreshActiveConversation();
onCanvasRefreshRequested?.();
}}
/>
))}
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
import {
@@ -25,24 +26,32 @@ vi.mock('@/src/services/clipboard.ts', () => ({
copyTextToClipboard: vi.fn(),
}));
const focusResourceMock = vi.fn();
function renderMessage(
message: EditorAgentMessage,
onReferenceImage?: Parameters<typeof MessageBubble>[0]['onReferenceImage'],
) {
return render(
<MessageBubble
message={message}
busyAction={null}
onConfirmToolCall={vi.fn()}
onCancelToolCall={vi.fn()}
onReferenceImage={onReferenceImage}
/>,
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={vi.fn()}
>
<MessageBubble
message={message}
busyAction={null}
onConfirmToolCall={vi.fn()}
onCancelToolCall={vi.fn()}
onReferenceImage={onReferenceImage}
/>
</ImageCanvasActionsProvider>,
);
}
describe('MessageBubble', () => {
beforeEach(() => {
vi.clearAllMocks();
focusResourceMock.mockReturnValue({ successed: true });
vi.mocked(copyTextToClipboard).mockResolvedValue(true);
});
@@ -124,7 +133,10 @@ describe('MessageBubble', () => {
it('closes the previous menu before another right click opens a new one', () => {
render(
<>
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={vi.fn()}
>
<MessageBubble
message={{
id: 40,
@@ -151,7 +163,7 @@ describe('MessageBubble', () => {
onConfirmToolCall={vi.fn()}
onCancelToolCall={vi.fn()}
/>
</>,
</ImageCanvasActionsProvider>,
);
const messages = screen.getAllByLabelText('Agent消息');
@@ -369,11 +381,99 @@ describe('MessageBubble', () => {
);
fireEvent.contextMenu(imageCards[1]!, { clientX: 50, clientY: 60 });
expect(screen.queryByRole('menuitem', { name: '在画布中定位' })).toBeNull();
expect(screen.getByRole('menuitem', { name: '引用' })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: '复制图片' })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: '下载图片' })).toBeTruthy();
});
it('focuses generated images on click and all generated media from their right-click menus', async () => {
const { container } = renderMessage({
id: 9,
role: 'system',
text: 'internal tool result',
attachments: [],
toolCall: {
toolName: 'generate_media',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 1 },
},
images: [
{
resourceId: ' resource-image ',
imageSrc: '/generated-editor-images/image.png',
},
],
videos: [
{
resourceId: 'resource-video',
videoSrc: '/generated-editor-videos/video.mp4',
},
],
audios: [
{
resourceId: 'resource-audio',
audioSrc: '/generated-editor-audios/audio.mp3',
},
],
},
createdAt: '2026-07-20T00:00:00Z',
});
await waitFor(() => expect(container.querySelector('video')).toBeTruthy());
const imageCard = container.querySelector('.grid.grid-cols-3 > div');
const video = container.querySelector('video');
const audio = container.querySelector('audio');
fireEvent.click(imageCard!);
fireEvent.click(video!);
fireEvent.click(audio!);
expect(focusResourceMock).toHaveBeenCalledTimes(1);
expect(focusResourceMock).toHaveBeenCalledWith('resource-image');
const targets = [
[imageCard, 'resource-image'],
[video!.parentElement, 'resource-video'],
[audio!.parentElement, 'resource-audio'],
] as const;
for (const [target, resourceId] of targets) {
fireEvent.contextMenu(target!, { clientX: 30, clientY: 40 });
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(focusResourceMock).toHaveBeenLastCalledWith(resourceId);
await waitFor(() =>
expect(
screen.queryByRole('menu', { name: '消息素材右键菜单' }),
).toBeNull(),
);
}
expect(focusResourceMock).toHaveBeenCalledTimes(4);
focusResourceMock.mockReturnValueOnce({
successed: false,
reason: 'not-found-on-canva',
});
fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 });
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(focusResourceMock).toHaveBeenLastCalledWith('resource-image');
expect(
screen.getByRole('menuitem', { name: '画布上不存在' }),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Escape' });
focusResourceMock.mockReturnValueOnce({
successed: false,
reason: 'other',
});
fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 });
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(screen.getByRole('menuitem', { name: '失败' })).toBeTruthy();
});
it('exports right-click images through the native HostBridge', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
@@ -9,6 +9,7 @@ import {
} from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx';
import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx';
import { useImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import { MessageBubbleRightClickMenu } from './MessageBubbleRightClickMenu.tsx';
import { useRightClickMenu } from './useRightClickMenu.ts';
@@ -73,12 +74,16 @@ export function MessageBubble({
onJobCompleted,
onReferenceImage,
}: MessageBubbleProps) {
const { focusResource } = useImageCanvasActions();
const {
rightClickMenu,
openRightClickMenu,
closeRightClickMenu,
runRightClickAction,
} = useRightClickMenu({ onReferenceImage });
} = useRightClickMenu({
onReferenceImage,
onFocusResource: focusResource,
});
const systemErrorText =
message.role === 'system' &&
!message.toolCall &&
@@ -1,6 +1,8 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { ImageCanvasActionResult } from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import {
contextAssetMediaSrc,
type EditorAgentContextAsset,
@@ -14,7 +16,7 @@ type MessageBubbleRightClickMenuProps = {
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
result: ImageCanvasActionResult | null;
onAction: (action: EditorAgentRightClickAction) => void;
onClose: () => void;
};
@@ -32,33 +34,48 @@ function actionLabel({
idleLabel: string;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
result: ImageCanvasActionResult | null;
}) {
if (pendingAction === action) {
return action === EditorAgentRightClickAction.DownloadAsset
? '下载中'
: action === EditorAgentRightClickAction.ReferenceImage
? '引用中'
: '复制中';
switch (action) {
case EditorAgentRightClickAction.FocusCanvas:
return '定位中';
case EditorAgentRightClickAction.DownloadAsset:
return '下载中';
case EditorAgentRightClickAction.ReferenceImage:
return '引用中';
case EditorAgentRightClickAction.CopyText:
case EditorAgentRightClickAction.CopyImage:
return '复制中';
}
}
if (resultAction !== action) {
const actionResult = resultAction === action ? result : null;
if (!actionResult) {
return idleLabel;
}
if (result === 'success') {
return action === EditorAgentRightClickAction.DownloadAsset
? '已下载'
: action === EditorAgentRightClickAction.ReferenceImage
? '已引用'
: '已复制';
switch (action) {
case EditorAgentRightClickAction.FocusCanvas:
if (actionResult.successed) return '已定位';
switch (actionResult.reason) {
case 'not-found-on-canva':
return '画布上不存在';
case 'other':
default:
return '失败';
}
case EditorAgentRightClickAction.DownloadAsset:
if (actionResult.successed) return '已下载';
return '下载失败';
case EditorAgentRightClickAction.ReferenceImage:
if (actionResult.successed) return '已引用';
return '引用失败';
case EditorAgentRightClickAction.CopyText:
case EditorAgentRightClickAction.CopyImage:
if (actionResult.successed) return '已复制';
return '复制失败';
}
if (result === 'error') {
return action === EditorAgentRightClickAction.DownloadAsset
? '下载失败'
: action === EditorAgentRightClickAction.ReferenceImage
? '引用失败'
: '复制失败';
}
return idleLabel;
}
function assetDownloadLabel(asset: EditorAgentContextAsset) {
@@ -175,6 +192,23 @@ export function MessageBubbleRightClickMenu({
</button>
) : (
<>
{target.asset.kind === 'generated_media' &&
target.asset.resourceId?.trim() ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.FocusCanvas)}
>
{actionLabel({
action: EditorAgentRightClickAction.FocusCanvas,
idleLabel: '在画布中定位',
pendingAction,
resultAction,
result,
})}
</button>
) : null}
{target.asset.mediaType === 'image' ? (
<>
{contextAssetMediaSrc(target.asset).trim() ? (
@@ -1,13 +1,23 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import type { ReactElement, ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorAgentToolCall } from '@/packages/shared/src/contracts';
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
import ToolCallView from './ToolCallView.tsx';
const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn());
const focusResourceMock = vi.fn();
const refreshCanvasMock = vi.fn();
vi.mock('@/src/services/external-generation', () => ({
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
@@ -54,9 +64,24 @@ function createCompletedJobResponse(jobId: string) {
};
}
function renderWithCanvasActions(ui: ReactElement) {
return render(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={refreshCanvasMock}
>
{children}
</ImageCanvasActionsProvider>
),
});
}
describe('ToolCallView', () => {
beforeEach(() => {
getExternalGenerationJobStatusMock.mockReset();
focusResourceMock.mockReset();
refreshCanvasMock.mockReset();
});
it.each([
@@ -65,14 +90,13 @@ describe('ToolCallView', () => {
] as const)(
'keeps the server %s state when the pending response arrives late',
async (status, statusLabel, error) => {
const pendingResponse = createDeferred<
ReturnType<typeof createCompletedJobResponse>
>();
const pendingResponse =
createDeferred<ReturnType<typeof createCompletedJobResponse>>();
getExternalGenerationJobStatusMock.mockReturnValueOnce(
pendingResponse.promise,
);
const onJobCompleted = vi.fn();
const { rerender } = render(
const { rerender } = renderWithCanvasActions(
<ToolCallView
toolCall={createToolCall()}
onJobCompleted={onJobCompleted}
@@ -101,21 +125,20 @@ describe('ToolCallView', () => {
expect(screen.getByText(statusLabel)).toBeTruthy();
expect(screen.queryByText('已完成')).toBeNull();
expect(onJobCompleted).not.toHaveBeenCalled();
expect(refreshCanvasMock).not.toHaveBeenCalled();
},
);
it('ignores job A after switching to job B and completes job B once', async () => {
const jobAResponse = createDeferred<
ReturnType<typeof createCompletedJobResponse>
>();
const jobBResponse = createDeferred<
ReturnType<typeof createCompletedJobResponse>
>();
const jobAResponse =
createDeferred<ReturnType<typeof createCompletedJobResponse>>();
const jobBResponse =
createDeferred<ReturnType<typeof createCompletedJobResponse>>();
getExternalGenerationJobStatusMock.mockImplementation((jobId: string) =>
jobId === 'job-a' ? jobAResponse.promise : jobBResponse.promise,
);
const onJobCompleted = vi.fn();
const { rerender } = render(
const { rerender } = renderWithCanvasActions(
<ToolCallView
toolCall={createToolCall()}
onJobCompleted={onJobCompleted}
@@ -150,5 +173,93 @@ describe('ToolCallView', () => {
expect(await screen.findByText('已完成')).toBeTruthy();
expect(onJobCompleted).toHaveBeenCalledTimes(1);
expect(refreshCanvasMock).toHaveBeenCalledTimes(1);
});
it('focuses image clicks only and exposes canvas resources to the right-click menu', () => {
const onRightClickMenu = vi.fn();
const { container } = renderWithCanvasActions(
<ToolCallView
toolCall={createToolCall({
status: 'completed',
externalJobId: null,
images: [
{
resourceId: ' resource-image ',
imageSrc: 'data:image/png;base64,aW1hZ2U=',
},
{
imageSrc: 'data:image/png;base64,bGVnYWN5',
},
],
videos: [
{
resourceId: 'resource-video',
videoSrc: 'data:video/mp4;base64,dmlkZW8=',
},
],
audios: [
{
resourceId: 'resource-audio',
audioSrc: 'data:audio/wav;base64,YXVkaW8=',
},
],
})}
onRightClickMenu={onRightClickMenu}
/>,
);
const imageCards = container.querySelectorAll('.grid.grid-cols-3 > div');
const video = container.querySelector('video');
const audio = container.querySelector('audio');
expect(imageCards).toHaveLength(2);
expect(video).toBeTruthy();
expect(audio).toBeTruthy();
expect(imageCards[0]!.getAttribute('role')).toBeNull();
expect(imageCards[0]!.getAttribute('tabindex')).toBeNull();
fireEvent.click(imageCards[0]!);
fireEvent.keyDown(imageCards[0]!, { key: 'Enter' });
fireEvent.keyDown(imageCards[0]!, { key: ' ', code: 'Space' });
fireEvent.click(imageCards[1]!);
fireEvent.click(video!);
fireEvent.click(audio!);
expect(focusResourceMock).toHaveBeenCalledTimes(1);
expect(focusResourceMock).toHaveBeenCalledWith('resource-image');
expect(
screen.queryByRole('button', {
name: '在画布中定位Agent生成图片-1',
}),
).toBeNull();
expect(video!.getAttribute('aria-label')).toBe('Agent生成视频-1');
expect(audio!.getAttribute('aria-label')).toBe('Agent生成音频-1');
fireEvent.contextMenu(imageCards[0]!);
fireEvent.contextMenu(video!.parentElement!);
fireEvent.contextMenu(audio!.parentElement!);
fireEvent.contextMenu(imageCards[1]!);
expect(onRightClickMenu.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({
kind: 'generated_media',
mediaType: 'image',
resourceId: 'resource-image',
}),
expect.objectContaining({
kind: 'generated_media',
mediaType: 'video',
resourceId: 'resource-video',
}),
expect.objectContaining({
kind: 'generated_media',
mediaType: 'audio',
resourceId: 'resource-audio',
}),
expect.objectContaining({
kind: 'generated_media',
mediaType: 'image',
resourceId: null,
}),
]);
});
});
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
import type { EditorAgentToolCall } from '@/packages/shared/src/contracts';
import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts';
import { useImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import { ResolvedAssetAudio } from '@/src/components/ResolvedAssetAudio.tsx';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx';
@@ -10,6 +11,57 @@ import { getExternalGenerationJobStatus } from '@/src/services/external-generati
import type { RightClickMenuHandler } from './common.ts';
type ToolCallDisplayStatus = 'pending' | 'completed' | 'failed' | 'cancelled';
function getInitialDisplayStatus(
status: EditorAgentToolCall['status'],
): ToolCallDisplayStatus {
switch (status) {
case 'completed':
case 'failed':
case 'cancelled':
return status;
case 'not_completed':
return 'pending';
}
}
function getStatusPresentation(
displayStatus: ToolCallDisplayStatus,
hasJobId: boolean,
) {
switch (displayStatus) {
case 'completed':
return {
label: '已完成',
icon: <Check className="h-3.5 w-3.5" aria-hidden="true" />,
};
case 'failed':
return {
label: '失败',
icon: <X className="h-3.5 w-3.5" aria-hidden="true" />,
};
case 'cancelled':
return {
label: '已取消',
icon: <X className="h-3.5 w-3.5" aria-hidden="true" />,
};
case 'pending':
if (hasJobId) {
return {
label: '执行中',
icon: (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
),
};
}
return {
label: '待确认',
icon: <ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />,
};
}
}
function ToolCallView({
toolCall,
onJobCompleted,
@@ -19,27 +71,26 @@ function ToolCallView({
onJobCompleted?: () => void;
onRightClickMenu?: RightClickMenuHandler;
}) {
const { focusResource, refreshCanvas } = useImageCanvasActions();
const videos = toolCall.videos ?? [];
const audios = toolCall.audios ?? [];
const jobId = toolCall.externalJobId?.trim() || null;
const initialDisplayStatus =
toolCall.status === 'completed'
? 'completed'
: toolCall.status === 'failed'
? 'failed'
: toolCall.status === 'cancelled'
? 'cancelled'
: 'pending';
const initialDisplayStatus = getInitialDisplayStatus(toolCall.status);
const initialDisplayError = toolCall.error ?? null;
const shouldPoll = toolCall.status === 'not_completed' && Boolean(jobId);
const [displayStatus, setDisplayStatus] = useState(initialDisplayStatus);
const [displayError, setDisplayError] =
useState<string | null>(initialDisplayError);
const [displayError, setDisplayError] = useState<string | null>(
initialDisplayError,
);
const terminalNotifiedRef = useRef(false);
const onJobCompletedRef = useRef(onJobCompleted);
const refreshCanvasRef = useRef(refreshCanvas);
useEffect(() => {
onJobCompletedRef.current = onJobCompleted;
}, [onJobCompleted]);
useEffect(() => {
refreshCanvasRef.current = refreshCanvas;
}, [refreshCanvas]);
useEffect(() => {
setDisplayStatus(initialDisplayStatus);
setDisplayError(initialDisplayError);
@@ -59,6 +110,7 @@ function ToolCallView({
setDisplayError(response.job.error ?? null);
if (!terminalNotifiedRef.current) {
terminalNotifiedRef.current = true;
refreshCanvasRef.current();
onJobCompletedRef.current?.();
}
return;
@@ -76,137 +128,139 @@ function ToolCallView({
// 轮询只允许新的 job/status source 重置;其余值通过当前 source 对应的闭包读取。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
const isCancelled = displayStatus === 'cancelled';
const isCompleted = displayStatus === 'completed';
const isFailed = displayStatus === 'failed';
const isExecuting = displayStatus === 'pending' && Boolean(jobId);
const statusLabel = isCompleted
? '已完成'
: isFailed
? '失败'
: isCancelled
? '已取消'
: isExecuting
? '执行中'
: '待确认';
const statusPresentation = getStatusPresentation(
displayStatus,
Boolean(jobId),
);
return (
<div className="rounded-lg border border-slate-200 bg-white/80 p-2 text-xs text-slate-600">
<div className="flex items-center gap-2">
{isExecuting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : isCompleted ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : isCancelled || isFailed ? (
<X className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
{statusPresentation.icon}
<span>{editorAgentToolLabel(toolCall.toolName)}</span>
<span className="ml-auto text-slate-400">{statusLabel}</span>
<span className="ml-auto text-slate-400">
{statusPresentation.label}
</span>
</div>
{displayError ? (
<div className="mt-1 text-red-600">{displayError}</div>
) : null}
{toolCall.images.length ? (
<div className="mt-2 grid grid-cols-3 gap-2">
{toolCall.images.map((image, index) => (
<div
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'image',
mediaSrc: image.imageSrc,
objectKey: image.objectKey,
suggestedFileName: `Agent生成图片-${index + 1}`,
})
: undefined
}
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
objectKey={image.objectKey}
refreshKey={image.resourceId ?? toolCall.toolName}
alt=""
className="h-20 w-full object-cover"
/>
</div>
))}
{toolCall.images.map((image, index) => {
const resourceId = image.resourceId?.trim() || null;
return (
<div
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onClick={
resourceId ? () => focusResource(resourceId) : undefined
}
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'image',
mediaSrc: image.imageSrc,
objectKey: image.objectKey,
resourceId,
suggestedFileName: `Agent生成图片-${index + 1}`,
})
: undefined
}
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
objectKey={image.objectKey}
refreshKey={image.resourceId ?? toolCall.toolName}
alt=""
className="h-20 w-full object-cover"
/>
</div>
);
})}
</div>
) : null}
{videos.length ? (
<div className="mt-2 grid grid-cols-1 gap-2">
{videos.map((video, index) => (
<div
key={`${toolCall.toolName}-${video.resourceId ?? video.objectKey ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'video',
mediaSrc: video.videoSrc,
objectKey: video.objectKey,
suggestedFileName: `Agent生成视频-${index + 1}`,
})
: undefined
}
>
<ResolvedAssetVideo
src={video.videoSrc}
objectKey={video.objectKey}
refreshKey={
video.resourceId ?? video.objectKey ?? toolCall.toolName
{videos.map((video, index) => {
const resourceId = video.resourceId?.trim() || null;
return (
<div
key={`${toolCall.toolName}-${video.resourceId ?? video.objectKey ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'video',
mediaSrc: video.videoSrc,
objectKey: video.objectKey,
resourceId,
suggestedFileName: `Agent生成视频-${index + 1}`,
})
: undefined
}
poster={video.thumbnailSrc ?? undefined}
controls
playsInline
preload="metadata"
className="max-h-56 w-full bg-black object-contain"
/>
</div>
))}
>
<ResolvedAssetVideo
src={video.videoSrc}
objectKey={video.objectKey}
refreshKey={
video.resourceId ?? video.objectKey ?? toolCall.toolName
}
poster={video.thumbnailSrc ?? undefined}
controls
playsInline
preload="metadata"
className="max-h-56 w-full bg-black object-contain"
aria-label={`Agent生成视频-${index + 1}`}
/>
</div>
);
})}
</div>
) : null}
{audios.length ? (
<div className="mt-2 space-y-2">
{audios.map((audio, index) => (
<div
key={`${toolCall.toolName}-${audio.resourceId ?? audio.objectKey ?? index}`}
className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'audio',
mediaSrc: audio.audioSrc,
objectKey: audio.objectKey,
suggestedFileName: `Agent生成音频-${index + 1}`,
})
: undefined
}
>
<Volume2
className="h-4 w-4 shrink-0 text-slate-500"
aria-hidden="true"
/>
<ResolvedAssetAudio
src={audio.audioSrc}
objectKey={audio.objectKey}
refreshKey={
audio.resourceId ?? audio.objectKey ?? toolCall.toolName
{audios.map((audio, index) => {
const resourceId = audio.resourceId?.trim() || null;
return (
<div
key={`${toolCall.toolName}-${audio.resourceId ?? audio.objectKey ?? index}`}
className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'audio',
mediaSrc: audio.audioSrc,
objectKey: audio.objectKey,
resourceId,
suggestedFileName: `Agent生成音频-${index + 1}`,
})
: undefined
}
controls
preload="metadata"
className="min-w-0 flex-1"
/>
</div>
))}
>
<Volume2
className="h-4 w-4 shrink-0 text-slate-500"
aria-hidden="true"
/>
<ResolvedAssetAudio
src={audio.audioSrc}
objectKey={audio.objectKey}
refreshKey={
audio.resourceId ?? audio.objectKey ?? toolCall.toolName
}
controls
preload="metadata"
className="min-w-0 flex-1"
aria-label={`Agent生成音频-${index + 1}`}
/>
</div>
);
})}
</div>
) : null}
</div>
@@ -6,6 +6,7 @@ export enum EditorAgentRightClickAction {
CopyText = 'copy_text',
CopyImage = 'copy_image',
ReferenceImage = 'reference_image',
FocusCanvas = 'focus_canvas',
DownloadAsset = 'download_asset',
}
@@ -20,6 +21,7 @@ export type EditorAgentContextAsset =
mediaType: 'image' | 'video' | 'audio';
mediaSrc: string;
objectKey?: string | null;
resourceId?: string | null;
suggestedFileName: string;
};
@@ -1,6 +1,13 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import {
act,
renderHook as testingLibraryRenderHook,
type RenderHookOptions,
type RenderHookResult,
waitFor,
} from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -9,12 +16,41 @@ import {
type EditorAgentMessage,
type EditorAgentMessageResponse,
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
import { ImageCanvasActionsProvider } from '../ImageCanvasActionsProvider.tsx';
import {
EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS,
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation.ts';
const focusResourceMock = vi.fn();
const refreshCanvasMock = vi.fn();
function ImageCanvasActionsTestWrapper({
children,
}: {
children: ReactNode;
}) {
return (
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={refreshCanvasMock}
>
{children}
</ImageCanvasActionsProvider>
);
}
function renderHook<Result, Props>(
callback: (initialProps: Props) => Result,
options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props> {
return testingLibraryRenderHook(callback, {
...options,
wrapper: ImageCanvasActionsTestWrapper,
});
}
function createEditImageDisplayArgs(prompt: string) {
return {
stringArgs: [{ name: 'prompt', label: '修改要求', value: prompt }],
@@ -124,12 +160,10 @@ describe('useEditorAgentConversation', () => {
it('loads conversations and applies delta messages', async () => {
const client = createClient();
const onCanvasRefreshRequested = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
}),
);
@@ -156,7 +190,7 @@ describe('useEditorAgentConversation', () => {
expect(result.current.activeConversation?.title).toBe(
'把这个角色改成像素风',
);
expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1);
expect(refreshCanvasMock).toHaveBeenCalledTimes(1);
expect(result.current.messages.map((message) => message.text)).toEqual([
'把这个角色改成像素风',
'我来处理',
@@ -595,7 +629,6 @@ describe('useEditorAgentConversation', () => {
it('does not apply a completed message response after switching conversations', async () => {
const client = createClient();
const onCanvasRefreshRequested = vi.fn();
let resolveSend!: (response: EditorAgentMessageResponse) => void;
vi.mocked(client.sendMessage).mockImplementation(
() =>
@@ -607,7 +640,6 @@ describe('useEditorAgentConversation', () => {
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
}),
);
@@ -682,7 +714,7 @@ describe('useEditorAgentConversation', () => {
expect(result.current.activeConversationId).toBe('conversation-2');
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.text).toBe('第二个会话原有消息');
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
expect(refreshCanvasMock).not.toHaveBeenCalled();
expect(
result.current.conversations.find(
(conversation) => conversation.conversationId === 'conversation-1',
@@ -918,13 +950,11 @@ describe('useEditorAgentConversation', () => {
createdAt: '2026-07-03T00:00:00.000Z',
};
vi.mocked(client.confirmToolCall).mockResolvedValue(undefined);
const onCanvasRefreshRequested = vi.fn();
const onConfirmSent = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
onConfirmSent,
}),
);
@@ -968,7 +998,7 @@ describe('useEditorAgentConversation', () => {
expect(result.current.messages[1]?.toolCall?.externalJobId).toBe(
'task-edit-1',
);
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
expect(refreshCanvasMock).not.toHaveBeenCalled();
expect(onConfirmSent).toHaveBeenCalledTimes(1);
});
@@ -1096,12 +1126,10 @@ describe('useEditorAgentConversation', () => {
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
});
const onCanvasRefreshRequested = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
}),
);
@@ -1122,7 +1150,7 @@ describe('useEditorAgentConversation', () => {
);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled');
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
expect(refreshCanvasMock).not.toHaveBeenCalled();
});
it('rethrows fetch errors and rolls back the optimistic message', async () => {
@@ -9,6 +9,7 @@ import type {
EditorAgentMessageRequest,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import { useImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import {
cancelEditorAgentToolCall,
@@ -47,7 +48,6 @@ export type EditorAgentConversationClient = {
type UseEditorAgentConversationOptions = {
projectId?: string | null;
client?: EditorAgentConversationClient;
onCanvasRefreshRequested?: () => void;
onConfirmSent?: () => void;
};
@@ -135,9 +135,9 @@ function upsertConversationSummary(
export function useEditorAgentConversation({
projectId,
client = defaultEditorAgentConversationClient,
onCanvasRefreshRequested,
onConfirmSent,
}: UseEditorAgentConversationOptions) {
const { refreshCanvas } = useImageCanvasActions();
const normalizedProjectId = projectId?.trim() ?? '';
const [conversations, setConversations] = useState<
EditorAgentConversationSummary[]
@@ -424,10 +424,10 @@ export function useEditorAgentConversation({
);
})
) {
onCanvasRefreshRequested?.();
refreshCanvas();
}
},
[onCanvasRefreshRequested],
[refreshCanvas],
);
const applyDeltaMessages = useCallback(
@@ -4,6 +4,10 @@ import {
useState,
} from 'react';
import type {
ImageCanvasActionResult,
ImageCanvasActions,
} from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
import {
@@ -29,7 +33,7 @@ type RightClickMenuState = {
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
result: ImageCanvasActionResult | null;
};
function sanitizeDownloadName(value: string, extension: string) {
@@ -232,10 +236,34 @@ async function downloadAsset(asset: EditorAgentContextAsset) {
}
}
function focusAssetOnCanvas(
asset: EditorAgentContextAsset,
onFocusResource?: ImageCanvasActions['focusResource'],
): ReturnType<ImageCanvasActions['focusResource']> {
if (asset.kind !== 'generated_media' || !onFocusResource) {
return { successed: false, reason: 'other' };
}
const resourceId = asset.resourceId?.trim();
if (!resourceId) {
return { successed: false, reason: 'other' };
}
try {
return onFocusResource(resourceId);
} catch {
return { successed: false, reason: 'other' };
}
}
function actionResultFromSuccess(successed: boolean): ImageCanvasActionResult {
return successed ? { successed: true } : { successed: false, reason: 'other' };
}
export function useRightClickMenu({
onReferenceImage,
onFocusResource,
}: {
onReferenceImage?: (asset: EditorAgentContextAsset) => boolean;
onFocusResource?: ImageCanvasActions['focusResource'];
} = {}) {
const [rightClickMenu, setRightClickMenu] =
useState<RightClickMenuState | null>(null);
@@ -276,37 +304,65 @@ export function useRightClickMenu({
}
: current,
);
const succeeded =
action === EditorAgentRightClickAction.CopyText &&
target.kind === 'text'
? await copyTextToClipboard(target.text)
: action === EditorAgentRightClickAction.CopyImage &&
target.kind === 'asset' &&
target.asset.mediaType === 'image'
? await copyAssetImage(target.asset)
: action === EditorAgentRightClickAction.ReferenceImage &&
target.kind === 'asset' &&
target.asset.mediaType === 'image' &&
contextAssetMediaSrc(target.asset).trim()
? (onReferenceImage?.(target.asset) ?? false)
: action === EditorAgentRightClickAction.DownloadAsset &&
target.kind === 'asset'
? await downloadAsset(target.asset)
: false;
setRightClickMenu((current) =>
current?.target === target && current.pendingAction === action
? succeeded
? null
: {
...current,
pendingAction: null,
resultAction: action,
result: 'error',
}
: current,
);
let actionResult: ImageCanvasActionResult = {
successed: false,
reason: 'other',
};
switch (action) {
case EditorAgentRightClickAction.CopyText:
if (target.kind === 'text') {
actionResult = actionResultFromSuccess(
await copyTextToClipboard(target.text),
);
}
break;
case EditorAgentRightClickAction.CopyImage:
if (target.kind === 'asset' && target.asset.mediaType === 'image') {
actionResult = actionResultFromSuccess(
await copyAssetImage(target.asset),
);
}
break;
case EditorAgentRightClickAction.ReferenceImage:
if (
target.kind === 'asset' &&
target.asset.mediaType === 'image' &&
contextAssetMediaSrc(target.asset).trim()
) {
actionResult = actionResultFromSuccess(
onReferenceImage?.(target.asset) ?? false,
);
}
break;
case EditorAgentRightClickAction.FocusCanvas:
if (target.kind === 'asset') {
actionResult = focusAssetOnCanvas(target.asset, onFocusResource);
}
break;
case EditorAgentRightClickAction.DownloadAsset:
if (target.kind === 'asset') {
actionResult = actionResultFromSuccess(
await downloadAsset(target.asset),
);
}
break;
}
setRightClickMenu((current) => {
if (current?.target !== target || current.pendingAction !== action) {
return current;
}
if (actionResult.successed) {
return null;
}
return {
...current,
pendingAction: null,
resultAction: action,
result: actionResult,
};
});
},
[onReferenceImage, rightClickMenu],
[onFocusResource, onReferenceImage, rightClickMenu],
);
return {
@@ -0,0 +1,43 @@
/* @vitest-environment jsdom */
import { act, renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
useImageCanvasActions,
} from './ImageCanvasActionsContext';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
describe('ImageCanvasActionsContext', () => {
it('exposes the editor-scoped canvas actions', () => {
const focusResource = vi.fn(() => ({ successed: true }));
const refreshCanvas = vi.fn();
const { result } = renderHook(useImageCanvasActions, {
wrapper: ({ children }: { children: ReactNode }) => (
<ImageCanvasActionsProvider
focusResource={focusResource}
refreshCanvas={refreshCanvas}
>
{children}
</ImageCanvasActionsProvider>
),
});
let focusResult: { successed: boolean } | undefined;
act(() => {
focusResult = result.current.focusResource('resource-a');
result.current.refreshCanvas();
});
expect(focusResult).toEqual({ successed: true });
expect(focusResource).toHaveBeenCalledWith('resource-a');
expect(refreshCanvas).toHaveBeenCalledTimes(1);
});
it('fails clearly when consumed outside the editor provider', () => {
expect(() => renderHook(useImageCanvasActions)).toThrow(
'useImageCanvasActions must be used within ImageCanvasActionsProvider',
);
});
});
@@ -0,0 +1,24 @@
import { createContext, useContext } from 'react';
export interface ImageCanvasActionResult {
successed: boolean;
reason?: 'not-found-on-canva' | 'other';
}
export type ImageCanvasActions = {
focusResource: (resourceId: string) => ImageCanvasActionResult;
refreshCanvas: () => void;
};
export const ImageCanvasActionsContext =
createContext<ImageCanvasActions | null>(null);
export function useImageCanvasActions() {
const actions = useContext(ImageCanvasActionsContext);
if (!actions) {
throw new Error(
'useImageCanvasActions must be used within ImageCanvasActionsProvider',
);
}
return actions;
}
@@ -0,0 +1,27 @@
import { type ReactNode, useMemo } from 'react';
import {
type ImageCanvasActions,
ImageCanvasActionsContext,
} from './ImageCanvasActionsContext';
export function ImageCanvasActionsProvider({
children,
focusResource,
refreshCanvas,
}: {
children: ReactNode;
focusResource: ImageCanvasActions['focusResource'];
refreshCanvas: ImageCanvasActions['refreshCanvas'];
}) {
const actions = useMemo<ImageCanvasActions>(
() => ({ focusResource, refreshCanvas }),
[focusResource, refreshCanvas],
);
return (
<ImageCanvasActionsContext.Provider value={actions}>
{children}
</ImageCanvasActionsContext.Provider>
);
}
@@ -1,9 +1,15 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef } from 'react';
import {
fireEvent,
render as testingLibraryRender,
screen,
within,
} from '@testing-library/react';
import { createRef, type ReactElement, type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import type { ImageCanvasMetadataModalViewProps } from './ImageCanvasMetadataModalView';
@@ -11,6 +17,19 @@ import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView';
import type { ImageCanvasStageViewProps } from './ImageCanvasStageView';
import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView';
function render(ui: ReactElement) {
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<ImageCanvasActionsProvider
focusResource={vi.fn()}
refreshCanvas={vi.fn()}
>
{children}
</ImageCanvasActionsProvider>
),
});
}
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
@@ -2545,9 +2545,15 @@ describe('ImageCanvasEditorView', () => {
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
});
it('renders Agent generation thumbnails as passive previews', async () => {
it('smoothly focuses an Agent generation resource without changing canvas selection or panels', async () => {
const rafCallbacks: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
enableEditorAgentSidebarForTest();
const detail = createEditorAgentDetailWithGeneration('resource-puzzle');
const detail = createEditorAgentDetailWithGeneration('resource-big-fish');
listEditorAgentConversationsMock.mockResolvedValueOnce([
createEditorAgentConversationSummary(),
]);
@@ -2559,10 +2565,44 @@ describe('ImageCanvasEditorView', () => {
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
const world = screen.getByTestId('image-canvas-world') as HTMLElement;
const puzzleLayer = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
const bigFishLayer = screen
.getByAltText('画布图片:大鱼素材')
.closest('button')!;
const initialTransform = world.style.transform;
expect(
within(messageLog).queryByRole('button', { name: '生成结果' }),
).toBeNull();
puzzleLayer.classList.contains('image-canvas-editor__layer--selected'),
).toBe(true);
expect(
bigFishLayer.classList.contains('image-canvas-editor__layer--selected'),
).toBe(false);
fireEvent.contextMenu(
within(messageLog).getByRole('presentation'),
{ clientX: 30, clientY: 40 },
);
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(world.style.transform).toBe(initialTransform);
act(() => rafCallbacks.shift()?.(0));
act(() => rafCallbacks.shift()?.(210));
expect(world.style.transform).not.toBe(initialTransform);
expect(world.style.transform).not.toBe(
'translate(-840px, -246.5px) scale(1)',
);
act(() => rafCallbacks.shift()?.(420));
expect(world.style.transform).toBe('translate(-840px, -246.5px) scale(1)');
expect(screen.getByLabelText('发送给画布 Agent')).toBeTruthy();
expect(
puzzleLayer.classList.contains('image-canvas-editor__layer--selected'),
).toBe(true);
expect(
bigFishLayer.classList.contains('image-canvas-editor__layer--selected'),
).toBe(false);
expect(within(messageLog).queryByText('gpt-image-2')).toBeNull();
expect(loadEditorProjectMock).not.toHaveBeenCalled();
});
@@ -32,6 +32,8 @@ import {
PlatformRechargePaymentResultDialog,
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
import type { ImageCanvasActionResult } from './ImageCanvasActionsContext';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
import {
canvasAssetKindOrNull,
DEFAULT_CANVAS_BACKGROUND_COLOR,
@@ -453,6 +455,7 @@ export function ImageCanvasEditorView({
const {
viewport,
setViewport,
animateViewportTo,
canvasSize,
minimapModel,
updateScaleFromCenter,
@@ -875,7 +878,7 @@ export function ImageCanvasEditorView({
const focusCanvasBounds = useCallback(
(
bounds: { x: number; y: number; width: number; height: number },
options: { reserveDialogSpace?: boolean } = {},
options: { reserveDialogSpace?: boolean; animate?: boolean } = {},
) => {
const nextViewport = fitViewportToBounds({
bounds,
@@ -890,9 +893,31 @@ export function ImageCanvasEditorView({
},
padding: TASK_FOCUS_PADDING,
});
if (options.animate) {
animateViewportTo(nextViewport);
return;
}
setViewport(nextViewport);
},
[canvasSize, setViewport],
[animateViewportTo, canvasSize, setViewport],
);
const focusCanvasResource = useCallback(
(resourceId: string): ImageCanvasActionResult => {
const normalizedResourceId = resourceId.trim();
if (!normalizedResourceId) {
return { successed: false, reason: 'other' };
}
// 同一 resourceId 对应多个画布图层时,定位顺序中的首个图层,保持单目标聚焦行为。
const targetLayer = layersRef.current.find(
(layer) => layer.resourceId === normalizedResourceId,
);
if (!targetLayer) {
return { successed: false, reason: 'not-found-on-canva' };
}
focusCanvasBounds(targetLayer, { animate: true });
return { successed: true };
},
[focusCanvasBounds],
);
const focusCanvasLayerById = useCallback(
(layerId: string) => {
@@ -1195,7 +1220,7 @@ export function ImageCanvasEditorView({
},
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
);
const handleEditorAgentCanvasRefreshRequested = useCallback(() => {
const refreshCanvas = useCallback(() => {
if (!projectId) {
return;
}
@@ -1470,10 +1495,6 @@ export function ImageCanvasEditorView({
const handleEditorAgentConfirmSent = useCallback(() => {
generationSurface.refreshTaskList();
}, [generationSurface]);
const handleEditorAgentJobCompleted = useCallback(() => {
generationSurface.refreshTaskList();
handleEditorAgentCanvasRefreshRequested();
}, [generationSurface, handleEditorAgentCanvasRefreshRequested]);
const showGenerationWarning = generationSurface.showGenerationWarning;
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
@@ -1487,12 +1508,8 @@ export function ImageCanvasEditorView({
showGenerationWarning(warning);
}
refreshEditorWalletState();
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[
applyGeneratedProjectSnapshot,
projectId,
refreshEditorWalletState,
showGenerationWarning,
@@ -2404,7 +2421,6 @@ export function ImageCanvasEditorView({
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onExternalTasksCompleted: handleExternalGenerationTasksCompleted,
onEditorAgentCanvasRefreshRequested: handleEditorAgentJobCompleted,
onEditorAgentConfirmSent: handleEditorAgentConfirmSent,
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
@@ -2470,7 +2486,10 @@ export function ImageCanvasEditorView({
};
return (
<>
<ImageCanvasActionsProvider
focusResource={focusCanvasResource}
refreshCanvas={refreshCanvas}
>
<ImageCanvasEditorShellView
editorRootRef={editorRootRef}
uploadInputRef={uploadInputRef}
@@ -2544,7 +2563,7 @@ export function ImageCanvasEditorView({
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null}
</>
</ImageCanvasActionsProvider>
);
}
@@ -135,7 +135,6 @@ export type ImageCanvasStageViewProps = {
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void;
onEditorAgentCanvasRefreshRequested?: () => void;
onEditorAgentConfirmSent?: () => void;
onToggleTaskSidebar: () => void;
onToggleAgentConversation: () => void;
@@ -274,7 +273,6 @@ export function ImageCanvasStageView({
onActivateGenerationDialog,
onFocusExternalTask,
onExternalTasksCompleted,
onEditorAgentCanvasRefreshRequested,
onEditorAgentConfirmSent,
onToggleTaskSidebar,
onToggleAgentConversation,
@@ -520,7 +518,6 @@ export function ImageCanvasStageView({
onToggleOpen={onToggleAgentConversation}
layers={layers}
assets={editorAgentAssets}
onCanvasRefreshRequested={onEditorAgentCanvasRefreshRequested}
onConfirmSent={onEditorAgentConfirmSent}
/>
) : null}
@@ -3,17 +3,35 @@
import {
act,
fireEvent,
render,
render as testingLibraryRender,
screen,
waitFor,
} from '@testing-library/react';
import type { ReactElement, ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider.tsx';
import { ImageCanvasTaskSidebarView } from './ImageCanvasTaskSidebarView';
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
const focusResourceMock = vi.fn();
const refreshCanvasMock = vi.fn();
function render(ui: ReactElement) {
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<ImageCanvasActionsProvider
focusResource={focusResourceMock}
refreshCanvas={refreshCanvasMock}
>
{children}
</ImageCanvasActionsProvider>
),
});
}
vi.mock('../../services/external-generation', () => ({
listExternalGenerationTasks: vi.fn().mockResolvedValue({
overview: {
@@ -30,6 +48,8 @@ const listExternalGenerationTasksMock = vi.mocked(listExternalGenerationTasks);
beforeEach(() => {
useImageCanvasContextStore.getState().setProjectId('project-1');
focusResourceMock.mockReset();
refreshCanvasMock.mockReset();
listExternalGenerationTasksMock.mockClear();
listExternalGenerationTasksMock.mockResolvedValue({
overview: {
@@ -448,6 +468,7 @@ describe('ImageCanvasTaskSidebarView', () => {
}),
]);
expect(onExternalTasksCompleted).toHaveBeenCalledTimes(1);
expect(refreshCanvasMock).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
@@ -17,6 +17,7 @@ import {
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { useImageCanvasActions } from './ImageCanvasActionsContext.ts';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTaskStatus } from './ImageCanvasEditorTypes';
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
@@ -280,6 +281,7 @@ export function ImageCanvasTaskSidebarView({
onFocusExternalTask,
onExternalTasksCompleted,
}: ImageCanvasTaskSidebarViewProps) {
const { refreshCanvas } = useImageCanvasActions();
const projectId = useImageCanvasContextStore((state) => state.projectId);
const normalizedProjectId = projectId?.trim() ?? '';
const [activeTab, setActiveTab] = useState<TaskSidebarTab>('active');
@@ -334,9 +336,10 @@ export function ImageCanvasTaskSidebarView({
for (const task of newlyCompletedTasks) {
completedRefreshNotifiedTaskIdsRef.current.add(task.jobId);
}
refreshCanvas();
onExternalTasksCompleted?.(newlyCompletedTasks);
},
[onExternalTasksCompleted],
[onExternalTasksCompleted, refreshCanvas],
);
useEffect(() => {
@@ -116,6 +116,7 @@ function expectViewport(viewport: CanvasViewport, expected: CanvasViewport) {
describe('useImageCanvasViewportControls', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.body.replaceChildren();
});
@@ -158,6 +159,91 @@ describe('useImageCanvasViewportControls', () => {
expect(captureCanvasHistory).toHaveBeenCalledTimes(2);
});
it('animates, retargets from the current frame and lets manual input cancel the transition', () => {
const rafCallbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
nextFrameId += 1;
rafCallbacks.set(nextFrameId, callback);
return nextFrameId;
});
const cancelAnimationFrameSpy = vi
.spyOn(window, 'cancelAnimationFrame')
.mockImplementation((frameId) => {
rafCallbacks.delete(frameId);
});
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: false })) as unknown as typeof window.matchMedia,
);
const { result, viewportElement, captureCanvasHistory } =
renderViewportControls();
const targetViewport = { x: 120, y: -80, scale: 1.25 };
const runNextFrame = (timestamp: number) => {
const entry = rafCallbacks.entries().next().value as
[number, FrameRequestCallback] | undefined;
expect(entry).toBeTruthy();
if (!entry) {
return;
}
rafCallbacks.delete(entry[0]);
entry[1](timestamp);
};
act(() => {
result.current.animateViewportTo(targetViewport);
});
expectViewport(result.current.viewport, {
x: -260,
y: 70,
scale: 0.5,
});
act(() => runNextFrame(0));
act(() => runNextFrame(210));
expect(result.current.viewport.x).toBeGreaterThan(-260);
expect(result.current.viewport.x).toBeLessThan(120);
expect(result.current.viewport.scale).toBeGreaterThan(0.5);
expect(result.current.viewport.scale).toBeLessThan(1.25);
const retargetedViewport = { x: 420, y: 260, scale: 1.6 };
act(() => {
result.current.animateViewportTo(retargetedViewport);
});
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
act(() => runNextFrame(300));
act(() => runNextFrame(720));
expectViewport(result.current.viewport, retargetedViewport);
expect(captureCanvasHistory).not.toHaveBeenCalled();
act(() => {
result.current.animateViewportTo({ x: 600, y: 400, scale: 2 });
});
act(() => runNextFrame(800));
act(() => {
fireEvent.wheel(viewportElement, { deltaY: 40 });
});
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
expect(rafCallbacks.size).toBe(0);
});
it('finishes viewport animation immediately when reduced motion is requested', () => {
const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame');
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: true })) as unknown as typeof window.matchMedia,
);
const { result } = renderViewportControls();
const targetViewport = { x: 20, y: 30, scale: 0.8 };
act(() => {
result.current.animateViewportTo(targetViewport);
});
expectViewport(result.current.viewport, targetViewport);
expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
});
it('keeps ordinary mouse wheel vertical and maps Shift wheel to horizontal', () => {
const { result, viewportElement } = renderViewportControls();
@@ -1,5 +1,7 @@
import {
type Dispatch,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
@@ -7,9 +9,7 @@ import {
useState,
} from 'react';
import {
DEFAULT_CANVAS_SIZE,
} from './ImageCanvasEditorModel';
import { DEFAULT_CANVAS_SIZE } from './ImageCanvasEditorModel';
import type {
CanvasLayer,
CanvasViewport,
@@ -34,12 +34,35 @@ export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = {
scale: 0.5,
};
export const IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS = 420;
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
}
function interpolateViewport(
start: CanvasViewport,
target: CanvasViewport,
progress: number,
): CanvasViewport {
return {
x: start.x + (target.x - start.x) * progress,
y: start.y + (target.y - start.y) * progress,
scale: start.scale + (target.scale - start.scale) * progress,
};
}
function prefersReducedCanvasMotion() {
return (
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
function isCanvasWheelInteractionTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest(
'input, textarea, select, [contenteditable="true"]',
) !== null
target.closest('input, textarea, select, [contenteditable="true"]') !== null
);
}
@@ -54,9 +77,11 @@ export function useImageCanvasViewportControls({
layers,
captureCanvasHistory,
}: UseImageCanvasViewportControlsOptions) {
const [viewport, setViewport] = useState<CanvasViewport>(
const [viewport, setViewportState] = useState<CanvasViewport>(
DEFAULT_IMAGE_CANVAS_VIEWPORT,
);
const viewportRef = useRef(viewport);
const viewportAnimationFrameRef = useRef<number | null>(null);
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
const pendingMinimapDragRef = useRef<{
dragState: Extract<DragState, { kind: 'minimap' }>;
@@ -65,6 +90,64 @@ export function useImageCanvasViewportControls({
} | null>(null);
const minimapDragFrameRef = useRef<number | null>(null);
const cancelViewportAnimation = useCallback(() => {
if (viewportAnimationFrameRef.current === null) {
return;
}
window.cancelAnimationFrame(viewportAnimationFrameRef.current);
viewportAnimationFrameRef.current = null;
}, []);
const setViewport = useCallback<Dispatch<SetStateAction<CanvasViewport>>>(
(nextViewport) => {
cancelViewportAnimation();
const resolvedViewport =
typeof nextViewport === 'function'
? nextViewport(viewportRef.current)
: nextViewport;
viewportRef.current = resolvedViewport;
setViewportState(resolvedViewport);
},
[cancelViewportAnimation],
);
const animateViewportTo = useCallback(
(targetViewport: CanvasViewport) => {
cancelViewportAnimation();
if (prefersReducedCanvasMotion()) {
viewportRef.current = targetViewport;
setViewportState(targetViewport);
return;
}
const startViewport = viewportRef.current;
let startedAt: number | null = null;
const step = (timestamp: number) => {
startedAt ??= timestamp;
const elapsedMs = Math.max(0, timestamp - startedAt);
const progress = Math.min(
1,
elapsedMs / IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS,
);
const nextViewport = interpolateViewport(
startViewport,
targetViewport,
easeOutCubic(progress),
);
viewportRef.current = nextViewport;
setViewportState(nextViewport);
if (progress >= 1) {
viewportAnimationFrameRef.current = null;
return;
}
viewportAnimationFrameRef.current = window.requestAnimationFrame(step);
};
viewportAnimationFrameRef.current = window.requestAnimationFrame(step);
},
[cancelViewportAnimation],
);
const minimapModel = useMemo(
() => createMinimapModel({ layers, viewport, canvasSize }),
[canvasSize, layers, viewport],
@@ -122,7 +205,13 @@ export function useImageCanvasViewportControls({
}),
);
},
[canvasSize.height, canvasSize.width, canvasViewportRef, captureCanvasHistory],
[
canvasSize.height,
canvasSize.width,
canvasViewportRef,
captureCanvasHistory,
setViewport,
],
);
const fitLayers = useCallback(
@@ -143,7 +232,7 @@ export function useImageCanvasViewportControls({
}
setViewport(nextViewport);
},
[captureCanvasHistory, canvasSize, layers],
[captureCanvasHistory, canvasSize, layers, setViewport],
);
const resolveCanvasPoint = useCallback(
@@ -200,7 +289,7 @@ export function useImageCanvasViewportControls({
}),
);
},
[canvasSize, minimapModel],
[canvasSize, minimapModel, setViewport],
);
const flushMinimapViewportDrag = useCallback(() => {
@@ -219,7 +308,7 @@ export function useImageCanvasViewportControls({
y: pendingDrag.clientY,
}),
);
}, []);
}, [setViewport]);
const updateViewportFromMinimapDrag = useCallback(
(
@@ -246,18 +335,19 @@ export function useImageCanvasViewportControls({
);
});
},
[],
[setViewport],
);
useEffect(
() => () => {
cancelViewportAnimation();
if (minimapDragFrameRef.current !== null) {
window.cancelAnimationFrame(minimapDragFrameRef.current);
minimapDragFrameRef.current = null;
}
pendingMinimapDragRef.current = null;
},
[],
[cancelViewportAnimation],
);
const handleNativeWheel = useCallback(
@@ -302,7 +392,7 @@ export function useImageCanvasViewportControls({
}),
);
},
[canvasViewportRef],
[canvasViewportRef, setViewport],
);
useEffect(() => {
@@ -321,6 +411,7 @@ export function useImageCanvasViewportControls({
return {
viewport,
setViewport,
animateViewportTo,
canvasSize,
minimapModel,
updateScaleFromCenter,