Merge remote-tracking branch 'web/master' into feat/pixel_art2
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
+20
-5
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
-4
@@ -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 &&
|
||||
|
||||
+56
-22
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+41
-13
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user