合并画布 Agent 侧边栏聊天

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/spacetime-client/src/module_bindings.rs
This commit is contained in:
2026-07-04 18:39:40 +08:00
90 changed files with 8559 additions and 285 deletions
@@ -0,0 +1,411 @@
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
function createClient(): EditorAgentConversationClient {
return {
listConversations: vi.fn().mockResolvedValue([
{
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
},
]),
createConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
}),
getConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [
{
id: 'assistant-old',
role: 'assistant',
kind: 'chat',
text: '已经看到画布内容',
attachments: [],
generations: [],
status: 'completed',
createdAt: '2026-07-03T00:00:10.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
}),
deleteConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
}),
streamMessage: vi
.fn()
.mockImplementation(async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'stage',
data: { conversationId: _conversationId, stage: 'responding' },
});
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: _conversationId,
messageId: 'assistant-stream',
role: 'assistant',
kind: 'chat',
textDelta: '收到,我会参考这张图。',
},
});
options.onEvent?.({
event: 'done',
data: { conversationId: _conversationId, title: null },
});
}),
};
}
describe('EditorAgentConversationPanelView', () => {
it('manages conversations, attachments and message sending inside the panel', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-1',
resourceId: 'resource-1',
title: '角色图层',
src: '/generated/role.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
{
id: 'layer-local-only',
resourceId: '',
title: '未入库图层',
src: '/local-only.png',
x: 0,
y: 0,
width: 256,
height: 256,
originalWidth: 256,
originalHeight: 256,
zIndex: 2,
sourceType: 'uploaded',
},
]}
assets={[
{
id: 'asset-1',
label: '素材库角色',
src: '/assets/role.png',
width: 512,
height: 512,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
},
]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
expect(
within(attachmentDialog).queryByRole('checkbox', {
name: '选择画布图片 未入库图层',
}),
).toBeNull();
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 角色图层',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
expect(screen.getByText('角色图层')).toBeTruthy();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '参考附件做像素风' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy();
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-2',
expect.objectContaining({
text: '参考附件做像素风',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
it('sends selected attachments even when the text input is empty', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-1',
resourceId: 'resource-1',
title: '角色图层',
src: '/generated/role.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 角色图层',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
});
it('keeps streamed reply and generating state when the panel is collapsed and reopened', async () => {
const client = createClient();
let finishStream = () => {};
vi.mocked(client.streamMessage).mockImplementation(
async (conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId,
messageId: 'assistant-stream',
role: 'assistant',
kind: 'chat',
textDelta: '我来生成图片。',
},
});
options.onEvent?.({
event: 'stage',
data: { conversationId, stage: 'generating' },
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId,
messageId: 'assistant-stream',
toolCallId: 'tool-call-generating',
toolName: 'generate_image',
taskId: 'task-generating',
model: 'gpt-image-2',
},
});
await new Promise<void>((resolve) => {
finishStream = resolve;
});
},
);
const { rerender } = render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '生成一张图片' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(screen.getByText('我来生成图片。')).toBeTruthy();
});
expect(screen.getByText('生成中')).toBeTruthy();
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
rerender(
<EditorAgentConversationPanelView
projectId="project-1"
open={false}
onToggleOpen={vi.fn()}
client={client}
/>,
);
expect(screen.getByRole('button', { name: '打开画布 Agent' })).toBeTruthy();
rerender(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
expect(screen.getByText('我来生成图片。')).toBeTruthy();
expect(screen.getByText('生成中')).toBeTruthy();
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
await act(async () => {
finishStream();
});
});
it('keeps wheel scrolling inside the message history and input', async () => {
const client = createClient();
const parentWheel = vi.fn();
render(
<div onWheel={parentWheel}>
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>
</div>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const messageWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
fireEvent(
screen.getByRole('log', { name: '画布 Agent 消息流' }),
messageWheel,
);
expect(parentWheel).not.toHaveBeenCalled();
expect(messageWheel.defaultPrevented).toBe(false);
const inputWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
fireEvent(screen.getByLabelText('发送给画布 Agent'), inputWheel);
expect(parentWheel).not.toHaveBeenCalled();
expect(inputWheel.defaultPrevented).toBe(false);
});
it('confirms conversation deletion from an independent dialog', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '删除当前对话' }));
const confirmDialog = screen.getByRole('dialog', { name: '删除对话' });
fireEvent.click(
within(confirmDialog).getByRole('button', { name: '确认删除' }),
);
await waitFor(() => {
expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1');
});
});
});
File diff suppressed because it is too large Load Diff
@@ -100,7 +100,11 @@ export function ImageCanvasContextMenusView({
? [
'context',
contextMenu.kind,
contextMenu.kind === 'layer' ? contextMenu.layerId : 'blank',
contextMenu.kind === 'layer'
? contextMenu.layerId
: contextMenu.kind === 'generation-dialog'
? contextMenu.dialogId
: 'blank',
contextMenu.x,
contextMenu.y,
isImageSequenceLayer ? 'sequence' : 'single',
@@ -259,7 +263,11 @@ export function ImageCanvasContextMenusView({
className={contextMenuClassName}
role="menu"
aria-label={
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
contextMenu.kind === 'blank'
? '画布右键菜单'
: contextMenu.kind === 'generation-dialog'
? '生成器右键菜单'
: '图片功能面板'
}
style={{
left: currentMeasuredMenuLayout?.x ?? contextMenu.x,
@@ -308,6 +316,15 @@ export function ImageCanvasContextMenusView({
</button>
</>
) : contextMenu.kind === 'generation-dialog' ? (
<button
type="button"
role="menuitem"
className="image-canvas-editor__context-menu-danger"
onClick={onDeleteContextLayers}
>
</button>
) : (
<>
<button
@@ -48,6 +48,7 @@ export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
export const CONTEXT_MENU_SIZE = {
blank: { width: 188, height: 176 },
layer: { width: 188, height: 492 },
'generation-dialog': { width: 188, height: 64 },
} as const;
export const CANVAS_BACKGROUND_OPTIONS = [
{ label: '白色', value: '#ffffff' },
@@ -136,8 +136,10 @@ function createStageProps(): ImageCanvasStageViewProps {
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
editorAgentAssets: [],
taskListRefreshKey: 0,
isTaskSidebarOpen: false,
isAgentConversationOpen: false,
generateDialog: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
@@ -178,9 +180,11 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onGenerationFrameContextMenu: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onFocusExternalTask: vi.fn(),
onToggleTaskSidebar: vi.fn(),
onToggleAgentConversation: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenRedrawPanel: vi.fn(),
@@ -290,6 +290,13 @@ export type CanvasContextMenuState =
y: number;
layerId: string;
canvasPoint: { x: number; y: number };
}
| {
kind: 'generation-dialog';
x: number;
y: number;
dialogId: string;
canvasPoint: { x: number; y: number };
};
export type QuickEditPanelState = {
File diff suppressed because it is too large Load Diff
@@ -9,14 +9,16 @@ import {
useState,
} from 'react';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
loadEditorGenerationPricing,
} from '../../services/image-editor/editorProjectClient';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -48,6 +50,7 @@ import type {
EditorAsset,
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
@@ -55,11 +58,11 @@ import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
isCanvasGenerationComposerVisible,
resolveQuickEditFocusViewport,
} from './ImageCanvasOverlayModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
getCanvasGenerationSelectionId,
getSelectedGenerationDialogIds,
@@ -162,7 +165,9 @@ function openCanvasStartupTool(
return;
}
if (tool === 'publication-cover') {
generationSurface.openPublicationGenerationDialog('publication-cover-image');
generationSurface.openPublicationGenerationDialog(
'publication-cover-image',
);
return;
}
generationSurface.openGenerateDialog();
@@ -460,7 +465,7 @@ export function ImageCanvasEditorView() {
});
}, [authUi]);
useEffect(() => {
if (!authUi?.canAccessProtectedData || !authUi.user) {
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
setWalletBalanceLabel(null);
setIsWalletBalanceLoading(false);
return;
@@ -987,16 +992,16 @@ export function ImageCanvasEditorView() {
appendCanvasLayersWithResources,
applyProjectSnapshot,
} = useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
canvasGenerationDialogs,
viewport,
isViewportInteracting,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
});
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
canvasGenerationDialogs,
viewport,
isViewportInteracting,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
});
const applyGeneratedProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
applyProjectSnapshot(project);
@@ -1004,6 +1009,20 @@ export function ImageCanvasEditorView() {
},
[applyProjectSnapshot, refreshAssetLibrary],
);
const handleEditorAgentGenerationResult = useCallback(
(event: EditorAgentGenerationResultEvent) => {
const hasGeneratedResource = event.images.some((image) =>
image.resourceId?.trim(),
);
if (!projectId || !hasGeneratedResource) {
return;
}
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[applyGeneratedProjectSnapshot, projectId],
);
const persistUpdatedLayerResource = useCallback(
(layer: CanvasLayer) => {
if (!projectId) {
@@ -1135,6 +1154,44 @@ export function ImageCanvasEditorView() {
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
});
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(false);
const toggleAgentConversation = useCallback(() => {
const nextOpen = !isAgentConversationOpen;
if (nextOpen) {
setActiveSidebarPanel(null);
if (generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
}
setIsAgentConversationOpen(nextOpen);
}, [generationSurface, isAgentConversationOpen, setActiveSidebarPanel]);
const toggleTaskSidebar = useCallback(() => {
if (!generationSurface.isTaskSidebarOpen) {
setActiveSidebarPanel(null);
setIsAgentConversationOpen(false);
}
generationSurface.toggleTaskSidebar();
}, [generationSurface, setActiveSidebarPanel]);
const toggleCanvasSidebarPanel = useCallback(
(panel: SidebarPanel) => {
setIsAgentConversationOpen(false);
if (generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
toggleSidebarPanel(panel);
},
[generationSurface, toggleSidebarPanel],
);
useEffect(() => {
if (generationSurface.isTaskSidebarOpen && isAgentConversationOpen) {
setIsAgentConversationOpen(false);
}
}, [generationSurface.isTaskSidebarOpen, isAgentConversationOpen]);
useEffect(() => {
if (activeSidebarPanel && isAgentConversationOpen) {
setIsAgentConversationOpen(false);
}
}, [activeSidebarPanel, isAgentConversationOpen]);
const {
setQuickEditPanel,
setCropExpandPanel,
@@ -1241,11 +1298,14 @@ export function ImageCanvasEditorView() {
clearCanvasFocus,
handleCanvasContextMenu,
handleLayerContextMenu,
handleGenerationFrameContextMenu,
} = useImageCanvasStageController({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
imageContextMenu,
setImageContextMenu,
contextMenu,
@@ -1852,9 +1912,11 @@ export function ImageCanvasEditorView() {
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
editorAgentAssets: assets,
isToolbarGuideVisible,
taskListRefreshKey: generationSurface.taskListRefreshKey,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
isAgentConversationOpen,
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
@@ -1900,9 +1962,12 @@ export function ImageCanvasEditorView() {
},
onUpdateLayerAssetKind: updateLayerAssetKind,
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onGenerationFrameContextMenu: handleGenerationFrameContextMenu,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
onEditorAgentGenerationResult: handleEditorAgentGenerationResult,
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenRedrawPanel: openRedrawPanel,
@@ -1948,7 +2013,7 @@ export function ImageCanvasEditorView() {
onToggleBackgroundSettings: toggleBackgroundSettings,
onApplyCanvasBackgroundColor: applyCanvasBackgroundColor,
onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange,
onToggleSidebarPanel: toggleSidebarPanel,
onToggleSidebarPanel: toggleCanvasSidebarPanel,
onToggleMinimap: toggleMinimap,
onMinimapPointerDown: handleMinimapPointerDown,
onSwitchTool: switchTool,
@@ -3,6 +3,7 @@ import type {
CanvasContextMenuState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
export type CanvasLayerMoveMode = 'up' | 'down' | 'top' | 'bottom';
@@ -31,12 +32,14 @@ export function resolveContextTargetLayerIds(
menu: CanvasContextMenuState | null,
selectedLayerIds: string[],
) {
if (menu?.kind !== 'layer') {
if (!menu || menu.kind === 'blank') {
return [];
}
return selectedLayerIds.includes(menu.layerId)
? [...selectedLayerIds]
: [menu.layerId];
const targetId =
menu.kind === 'generation-dialog'
? getCanvasGenerationSelectionId(menu.dialogId)
: menu.layerId;
return selectedLayerIds.includes(targetId) ? [...selectedLayerIds] : [targetId];
}
export function getCanvasLayersByIds(
@@ -18,6 +18,7 @@ function renderPanelDock(
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
isAgentConversationOpen: false,
isMinimapOpen: false,
minimapModel: null,
onFitLayers: vi.fn(),
@@ -30,6 +31,7 @@ function renderPanelDock(
onApplyCanvasBackgroundColor: vi.fn(),
onCanvasBackgroundHexChange: vi.fn(),
onToggleSidebarPanel: vi.fn(),
onToggleAgentConversation: vi.fn(),
onToggleMinimap: vi.fn(),
onMinimapPointerDown: vi.fn(),
...overrides,
@@ -55,14 +57,19 @@ describe('ImageCanvasPanelDockView', () => {
const toolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
expect(toolbar.className).toContain(
'image-canvas-editor__panel-dock--sidebar-open',
);
expect(
within(toolbar).getByRole('button', { name: '打开素材' }).getAttribute(
'aria-pressed',
),
within(toolbar)
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
(
within(toolbar).getByRole('button', { name: '重做' }) as HTMLButtonElement
within(toolbar).getByRole('button', {
name: '重做',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
@@ -70,11 +77,17 @@ describe('ImageCanvasPanelDockView', () => {
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '撤销' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '打开图层' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '切换小地图' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '画布 Agent' }),
);
fireEvent.click(
within(toolbar).getByRole('button', { name: '切换小地图' }),
);
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
expect(props.onUndoCanvasChange).toHaveBeenCalledTimes(1);
expect(props.onToggleSidebarPanel).toHaveBeenCalledWith('layers');
expect(props.onToggleAgentConversation).toHaveBeenCalledTimes(1);
expect(props.onToggleMinimap).toHaveBeenCalledTimes(1);
});
@@ -110,7 +123,9 @@ describe('ImageCanvasPanelDockView', () => {
target: { value: '#abc' },
});
fireEvent.click(within(panel).getByRole('button', { name: '恢复默认' }));
fireEvent.click(within(panel).getByRole('button', { name: '关闭画布背景设置' }));
fireEvent.click(
within(panel).getByRole('button', { name: '关闭画布背景设置' }),
);
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea');
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
@@ -2,6 +2,7 @@ import {
ImagePlus,
Layers,
Map as MapIcon,
MessageCircle,
Redo2,
RotateCcw,
Undo2,
@@ -9,18 +10,21 @@ import {
} from 'lucide-react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
import {
canvasDisplayScaleToViewportScale,
CANVAS_BACKGROUND_OPTIONS,
canvasDisplayScaleToViewportScale,
DEFAULT_CANVAS_BACKGROUND_COLOR,
formatCanvasDisplayScalePercent,
} from './ImageCanvasEditorModel';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
type ImageCanvasPanelDockViewProps = {
viewport: CanvasViewport;
@@ -31,6 +35,7 @@ type ImageCanvasPanelDockViewProps = {
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
isAgentConversationOpen: boolean;
isMinimapOpen: boolean;
minimapModel: StageMinimapModel | null;
onFitLayers: () => void;
@@ -43,6 +48,7 @@ type ImageCanvasPanelDockViewProps = {
onApplyCanvasBackgroundColor: (color: string) => void;
onCanvasBackgroundHexChange: (value: string) => void;
onToggleSidebarPanel: (panel: SidebarPanel) => void;
onToggleAgentConversation: () => void;
onToggleMinimap: () => void;
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
};
@@ -56,6 +62,7 @@ export function ImageCanvasPanelDockView({
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
isAgentConversationOpen,
isMinimapOpen,
minimapModel,
onFitLayers,
@@ -68,6 +75,7 @@ export function ImageCanvasPanelDockView({
onApplyCanvasBackgroundColor,
onCanvasBackgroundHexChange,
onToggleSidebarPanel,
onToggleAgentConversation,
onToggleMinimap,
onMinimapPointerDown,
}: ImageCanvasPanelDockViewProps) {
@@ -82,7 +90,14 @@ export function ImageCanvasPanelDockView({
/>
<div
className="image-canvas-editor__panel-dock"
className={[
'image-canvas-editor__panel-dock',
activeSidebarPanel
? 'image-canvas-editor__panel-dock--sidebar-open'
: '',
]
.filter(Boolean)
.join(' ')}
role="toolbar"
aria-label="画布面板入口"
onPointerDown={(event) => event.stopPropagation()}
@@ -259,7 +274,9 @@ export function ImageCanvasPanelDockView({
type="button"
className="image-canvas-editor__background-reset"
onClick={() =>
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
onApplyCanvasBackgroundColor(
DEFAULT_CANVAS_BACKGROUND_COLOR,
)
}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
@@ -283,6 +300,13 @@ export function ImageCanvasPanelDockView({
pressed={activeSidebarPanel === 'layers'}
onClick={() => onToggleSidebarPanel('layers')}
/>
<EditorIconButton
label="画布 Agent"
title="画布 Agent"
icon={MessageCircle}
pressed={isAgentConversationOpen}
onClick={onToggleAgentConversation}
/>
<EditorIconButton
label="切换小地图"
title="小地图"
@@ -157,6 +157,7 @@ function SidebarTabsHarness() {
isZoomMenuOpen={false}
isBackgroundSettingsOpen={false}
activeSidebarPanel={chrome.activeSidebarPanel}
isAgentConversationOpen={false}
isMinimapOpen={false}
minimapModel={null}
onFitLayers={vi.fn()}
@@ -169,6 +170,7 @@ function SidebarTabsHarness() {
onApplyCanvasBackgroundColor={vi.fn()}
onCanvasBackgroundHexChange={vi.fn()}
onToggleSidebarPanel={chrome.toggleSidebarPanel}
onToggleAgentConversation={vi.fn()}
onToggleMinimap={vi.fn()}
onMinimapPointerDown={vi.fn()}
/>
@@ -258,8 +260,12 @@ describe('ImageCanvasSidebarView', () => {
target: { value: '火焰' },
});
expect(within(sidebar).getByRole('button', { name: '添加火焰按钮' })).toBeTruthy();
expect(within(sidebar).queryByRole('button', { name: '添加账号素材A' })).toBeNull();
expect(
within(sidebar).getByRole('button', { name: '添加火焰按钮' }),
).toBeTruthy();
expect(
within(sidebar).queryByRole('button', { name: '添加账号素材A' }),
).toBeNull();
});
it('renders the layer panel with grouped and locked layer details', () => {
@@ -320,6 +326,12 @@ describe('ImageCanvasSidebarView', () => {
it('keeps sidebar tab switching and repeated-click closing behavior', () => {
render(<SidebarTabsHarness />);
expect(
screen.queryByRole('complementary', { name: '图片资源栏' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy();
@@ -135,3 +135,22 @@ export function createLayerCanvasContextMenus({
},
};
}
export function createGenerationDialogCanvasContextMenu({
clientX,
clientY,
dialogId,
canvasPoint,
}: {
clientX: number;
clientY: number;
dialogId: string;
canvasPoint: { x: number; y: number };
}): CanvasContextMenuState {
return {
kind: 'generation-dialog',
dialogId,
...resolveContextMenuPosition(clientX, clientY, 'generation-dialog'),
canvasPoint,
};
}
@@ -7,9 +7,11 @@ import type {
RefObject,
} from 'react';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type {
CanvasAssetKind,
CanvasClipboard,
@@ -21,6 +23,7 @@ import type {
CanvasViewport,
CropExpandPanelState,
CropExpandResizeHandle,
EditorAsset,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
@@ -55,9 +58,11 @@ export type ImageCanvasStageViewProps = {
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
editorAgentAssets?: EditorAsset[];
isToolbarGuideVisible?: boolean;
taskListRefreshKey: number;
isTaskSidebarOpen: boolean;
isAgentConversationOpen: boolean;
generateDialog: GenerateDialogState | null;
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
@@ -114,9 +119,17 @@ export type ImageCanvasStageViewProps = {
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onGenerationFrameContextMenu: (
event: ReactMouseEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onEditorAgentGenerationResult?: (
event: EditorAgentGenerationResultEvent,
) => void;
onToggleTaskSidebar: () => void;
onToggleAgentConversation: () => void;
onCropExpandHandlePointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
handle: CropExpandResizeHandle,
@@ -195,9 +208,11 @@ export function ImageCanvasStageView({
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
editorAgentAssets = [],
isToolbarGuideVisible = false,
taskListRefreshKey,
isTaskSidebarOpen,
isAgentConversationOpen,
generateDialog,
cropExpandPanel,
cropExpandSourceLayer,
@@ -239,9 +254,12 @@ export function ImageCanvasStageView({
onOpenLayerMetadata,
onUpdateLayerAssetKind,
onGenerationFramePointerDown,
onGenerationFrameContextMenu,
onActivateGenerationDialog,
onFocusExternalTask,
onEditorAgentGenerationResult,
onToggleTaskSidebar,
onToggleAgentConversation,
onCropExpandHandlePointerDown,
onOpenQuickEditPanel,
onOpenRedrawPanel,
@@ -337,6 +355,7 @@ export function ImageCanvasStageView({
onOpenLayerMetadata={onOpenLayerMetadata}
onUpdateLayerAssetKind={onUpdateLayerAssetKind}
onGenerationFramePointerDown={onGenerationFramePointerDown}
onGenerationFrameContextMenu={onGenerationFrameContextMenu}
onActivateGenerationDialog={onActivateGenerationDialog}
onCropExpandHandlePointerDown={onCropExpandHandlePointerDown}
/>
@@ -422,6 +441,7 @@ export function ImageCanvasStageView({
isZoomMenuOpen={isZoomMenuOpen}
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
activeSidebarPanel={activeSidebarPanel}
isAgentConversationOpen={isAgentConversationOpen}
isMinimapOpen={isMinimapOpen}
minimapModel={minimapModel}
onFitLayers={onFitLayers}
@@ -434,6 +454,7 @@ export function ImageCanvasStageView({
onApplyCanvasBackgroundColor={onApplyCanvasBackgroundColor}
onCanvasBackgroundHexChange={onCanvasBackgroundHexChange}
onToggleSidebarPanel={onToggleSidebarPanel}
onToggleAgentConversation={onToggleAgentConversation}
onToggleMinimap={onToggleMinimap}
onMinimapPointerDown={onMinimapPointerDown}
/>
@@ -446,6 +467,15 @@ export function ImageCanvasStageView({
onFocusExternalTask={onFocusExternalTask}
/>
<EditorAgentConversationPanelView
projectId={projectId}
open={isAgentConversationOpen}
onToggleOpen={onToggleAgentConversation}
layers={layers}
assets={editorAgentAssets}
onGenerationResult={onEditorAgentGenerationResult}
/>
{isToolbarGuideVisible ? (
<div
className="image-canvas-editor__toolbar-guide"
@@ -85,6 +85,7 @@ function renderWorldView(
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onGenerationFrameContextMenu: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
...overrides,
@@ -274,6 +274,10 @@ export type ImageCanvasWorldViewProps = {
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onGenerationFrameContextMenu: (
event: ReactMouseEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onCropExpandHandlePointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
@@ -921,6 +925,7 @@ export function ImageCanvasWorldView({
onOpenLayerMetadata,
onUpdateLayerAssetKind,
onGenerationFramePointerDown,
onGenerationFrameContextMenu,
onActivateGenerationDialog,
onCropExpandHandlePointerDown,
}: ImageCanvasWorldViewProps) {
@@ -1259,6 +1264,9 @@ export function ImageCanvasWorldView({
onPointerDown={(event) =>
onGenerationFramePointerDown(event, dialog)
}
onContextMenu={(event) =>
onGenerationFrameContextMenu(event, dialog)
}
onDoubleClick={() => onActivateGenerationDialog(dialog)}
>
{showFocusedChrome ? (
@@ -0,0 +1,428 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
function createClient(): EditorAgentConversationClient {
return {
listConversations: vi.fn().mockResolvedValue([
{
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
},
]),
createConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
}),
getConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
}),
deleteConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
}),
streamMessage: vi
.fn()
.mockImplementation(async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'stage',
data: {
conversationId: 'conversation-1',
stage: 'thinking',
},
});
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
role: 'assistant',
kind: 'chat',
textDelta: '我来处理',
},
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
toolCallId: 'tool-call-1',
toolName: 'generate_image',
taskId: 'task-1',
model: 'gpt-image-2',
},
});
options.onEvent?.({
event: 'generation_result',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
toolCallId: 'tool-call-1',
toolName: 'generate_image',
model: null,
images: [
{
resourceId: 'resource-result-1',
imageSrc: '/result.png',
thumbnailSrc: '/result-thumb.png',
width: 1024,
height: 1024,
},
],
},
});
options.onEvent?.({
event: 'done',
data: {
conversationId: 'conversation-1',
title: '像素角色',
},
});
}),
};
}
describe('useEditorAgentConversation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads conversations and applies message stream events', async () => {
const client = createClient();
const onGenerationResult = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onGenerationResult,
}),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('把这个角色改成像素风');
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '把这个角色改成像素风',
attachments: [],
}),
expect.objectContaining({
onEvent: expect.any(Function),
signal: expect.any(AbortSignal),
}),
);
expect(result.current.stage).toBe('completed');
expect(result.current.isStreaming).toBe(false);
expect(onGenerationResult).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-1',
images: [
expect.objectContaining({
resourceId: 'resource-result-1',
}),
],
}),
);
expect(result.current.messages.map((message) => message.text)).toEqual([
'把这个角色改成像素风',
'我来处理',
]);
expect(result.current.messages[1]?.generations).toEqual([
expect.objectContaining({
toolCallId: 'tool-call-1',
taskId: 'task-1',
model: 'gpt-image-2',
status: 'completed',
images: [
expect.objectContaining({
resourceId: 'resource-result-1',
thumbnailSrc: '/result-thumb.png',
}),
],
}),
]);
expect(result.current.conversations[0]?.title).toBe('像素角色');
});
it('creates a conversation before sending when the project has no history', async () => {
const client = createClient();
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.isLoadingConversations).toBe(false);
});
await act(async () => {
await result.current.sendMessage('新建后发送');
});
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-2',
expect.objectContaining({ text: '新建后发送' }),
expect.any(Object),
);
});
it('allows sending an attachment-only message', async () => {
const client = createClient();
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('', [
{
source: 'canvas_resource',
referenceId: 'resource-1',
objectKey: 'generated-editor-assets/resource-1.png',
imageSrc: '/resource-1.png',
label: '参考图',
},
]);
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
it('keeps LLM planning failures as one failed assistant message', async () => {
const client = createClient();
vi.mocked(client.streamMessage).mockImplementation(
async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-planning-error',
role: 'assistant',
kind: 'error',
textDelta: '画布 Agent 的 LLM 未配置,无法处理这句话。',
},
});
options.onEvent?.({
event: 'stage',
data: {
conversationId: 'conversation-1',
stage: 'failed',
},
});
options.onEvent?.({
event: 'done',
data: {
conversationId: 'conversation-1',
title: null,
},
});
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('这是美术素材');
});
expect(result.current.stage).toBe('failed');
expect(result.current.errorMessage).toBe(
'画布 Agent 的 LLM 未配置,无法处理这句话。',
);
expect(
result.current.messages.filter((message) => message.kind === 'error'),
).toEqual([
expect.objectContaining({
id: 'assistant-planning-error',
text: '画布 Agent 的 LLM 未配置,无法处理这句话。',
status: 'failed',
}),
]);
});
it('shows a failed tool completion as a generation record', async () => {
const client = createClient();
vi.mocked(client.streamMessage).mockImplementation(
async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
role: 'assistant',
kind: 'chat',
textDelta: '我先尝试生成。',
},
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
toolCallId: 'tool-call-failed',
toolName: 'generate_image',
taskId: null,
model: 'gpt-image-2',
status: 'generating',
},
});
options.onEvent?.({
event: 'tool_completed',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
toolCallId: 'tool-call-failed',
toolName: 'generate_image',
taskId: null,
model: 'gpt-image-2',
status: 'failed',
error: '泥点余额不足',
},
});
options.onEvent?.({
event: 'error',
data: {
conversationId: 'conversation-1',
code: 'INSUFFICIENT_BALANCE',
message: '泥点余额不足',
recoverable: true,
},
});
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await expect(
result.current.sendMessage('生成一张图'),
).resolves.toBeUndefined();
});
const failedMessage = result.current.messages.find(
(message) => message.id === 'assistant-failed',
);
expect(failedMessage?.status).toBe('failed');
expect(failedMessage?.generations).toEqual([
expect.objectContaining({
toolCallId: 'tool-call-failed',
status: 'failed',
model: 'gpt-image-2',
error: '泥点余额不足',
}),
]);
expect(result.current.errorMessage).toBe('泥点余额不足');
});
it('aborts the active stream and marks streaming messages as stopped', async () => {
const client = createClient();
let streamSignal: AbortSignal | null = null;
vi.mocked(client.streamMessage).mockImplementation(
(_conversationId, _payload, options) =>
new Promise<void>((resolve) => {
streamSignal = options.signal ?? null;
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-streaming',
role: 'assistant',
kind: 'chat',
textDelta: '处理中',
},
});
streamSignal?.addEventListener('abort', () => resolve(), {
once: true,
});
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
void act(() => {
void result.current.sendMessage('请继续');
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.stopCurrentTurn();
});
await waitFor(() => {
expect(streamSignal?.aborted).toBe(true);
});
expect(result.current.messages.at(-1)?.status).toBe('stopped');
expect(result.current.isStreaming).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
@@ -108,6 +108,9 @@ describe('useImageCanvasEditorChrome', () => {
it('manages background colors and chrome panel toggles', () => {
const { result } = renderChrome();
expect(result.current.activeSidebarPanel).toBeNull();
expect(result.current.isMinimapOpen).toBe(false);
act(() => {
result.current.applyCanvasBackgroundColor('#abc');
});
@@ -137,7 +140,7 @@ describe('useImageCanvasEditorChrome', () => {
expect(result.current.activeSidebarPanel).toBe('layers');
expect(result.current.isZoomMenuOpen).toBe(true);
expect(result.current.isBackgroundSettingsOpen).toBe(true);
expect(result.current.isMinimapOpen).toBe(false);
expect(result.current.isMinimapOpen).toBe(true);
expect(result.current.activeTool).toBe('hand');
act(() => {
@@ -6,10 +6,7 @@ import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
normalizeCanvasBackgroundHex,
} from './ImageCanvasEditorModel';
import type {
CanvasTool,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import type { CanvasTool, SidebarPanel } from './ImageCanvasEditorTypes';
type UseImageCanvasEditorChromeOptions = {
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
@@ -33,12 +30,12 @@ export function useImageCanvasEditorChrome({
null,
);
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
useState<SidebarPanel | null>(null);
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false);
const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] =
useState(false);
const [isMinimapOpen, setIsMinimapOpen] = useState(true);
const [isMinimapOpen, setIsMinimapOpen] = useState(false);
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
@@ -496,21 +496,41 @@ export function useImageCanvasLayerCommands({
const deleteContextLayers = useCallback(() => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
const targetLayerIds = getSelectedLayerIds(targetIds);
const targetDialogIds = targetIds
.map(getCanvasGenerationDialogIdFromSelectionId)
.filter((dialogId): dialogId is string => Boolean(dialogId))
.filter((dialogId) =>
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
);
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds));
if (targetLayerIds.length) {
setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetLayerIds),
);
}
selectSingleLayer(null);
setHoveredLayerId(null);
setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
currentLayer && targetLayerIds.includes(currentLayer.id)
? null
: currentLayer,
);
targetLayerIds.forEach((targetId) => onDeleteLayerSideEffects(targetId));
targetDialogIds.forEach((targetId) =>
onDeleteGenerationDialogSideEffects?.(targetId),
);
closeContextMenus();
}, [
captureCanvasHistory,
canvasGenerationDialogs,
closeContextMenus,
getContextTargetLayerIds,
onDeleteGenerationDialogSideEffects,
onDeleteLayerSideEffects,
selectSingleLayer,
setHoveredLayerId,
setLayers,
@@ -53,6 +53,8 @@ function StageControllerHarness({
layers,
selectedLayerId,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
imageContextMenu,
setImageContextMenu,
contextMenu,
@@ -15,15 +15,19 @@ import type {
} from './ImageCanvasEditorTypes';
import {
createBlankCanvasContextMenu,
createGenerationDialogCanvasContextMenu,
createLayerCanvasContextMenus,
resolveImageCanvasStageControllerModel,
} from './ImageCanvasStageControllerModel';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
type UseImageCanvasStageControllerOptions = {
layers: CanvasLayer[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
selectedLayerId: string | null;
selectedLayerIds: string[];
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
imageContextMenu: ImageContextMenuState | null;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
contextMenu: CanvasContextMenuState | null;
@@ -44,6 +48,8 @@ export function useImageCanvasStageController({
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
imageContextMenu,
setImageContextMenu,
contextMenu,
@@ -134,10 +140,42 @@ export function useImageCanvasStageController({
],
);
const handleGenerationFrameContextMenu = useCallback(
(
event: ReactMouseEvent<HTMLElement>,
dialog: CanvasGenerationDialogState,
) => {
event.preventDefault();
event.stopPropagation();
const selectionId = getCanvasGenerationSelectionId(dialog.id);
if (!selectedLayerIds.includes(selectionId)) {
setSelectedLayerId(null);
setSelectedLayerIds([selectionId]);
}
const nextMenu = createGenerationDialogCanvasContextMenu({
clientX: event.clientX,
clientY: event.clientY,
dialogId: dialog.id,
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
});
setContextMenu(nextMenu);
setImageContextMenu(null);
},
[
getCanvasPointFromClient,
selectedLayerIds,
setContextMenu,
setImageContextMenu,
setSelectedLayerId,
setSelectedLayerIds,
],
);
return {
...model,
clearCanvasFocus,
handleCanvasContextMenu,
handleLayerContextMenu,
handleGenerationFrameContextMenu,
};
}
@@ -93,6 +93,10 @@ type UseImageCanvasStageInteractionsOptions = {
onCloseImageContextMenu: () => void;
};
function focusCanvasInteractionTarget(target: HTMLElement) {
target.focus({ preventScroll: true });
}
export function useImageCanvasStageInteractions({
canvasViewportRef,
activeTool,
@@ -248,6 +252,7 @@ export function useImageCanvasStageInteractions({
generateDialog?.mode === 'video' ||
generateDialog?.mode === 'spec')
) {
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
suppressNextLayerClickRef.current = true;
@@ -255,6 +260,7 @@ export function useImageCanvasStageInteractions({
return;
}
if (isPickingQuickEditReferenceFromCanvas) {
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
suppressNextLayerClickRef.current = true;
@@ -265,6 +271,7 @@ export function useImageCanvasStageInteractions({
isPickingCharacterSpecFromCanvas &&
generateDialog?.mode === 'character'
) {
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
suppressNextLayerClickRef.current = true;
@@ -275,6 +282,7 @@ export function useImageCanvasStageInteractions({
isPickingCharacterReferenceFromCanvas &&
generateDialog?.mode === 'character'
) {
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
suppressNextLayerClickRef.current = true;
@@ -282,6 +290,7 @@ export function useImageCanvasStageInteractions({
return;
}
if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') {
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
suppressNextLayerClickRef.current = true;
@@ -292,6 +301,7 @@ export function useImageCanvasStageInteractions({
isPickingUiDesignSpecFromCanvas &&
generateDialog?.mode === 'ui-design'
) {
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
suppressNextLayerClickRef.current = true;
@@ -302,6 +312,7 @@ export function useImageCanvasStageInteractions({
isPickingPublicationReferenceFromCanvas &&
generateDialog?.mode === 'publication'
) {
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
suppressNextLayerClickRef.current = true;
@@ -309,6 +320,7 @@ export function useImageCanvasStageInteractions({
return;
}
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
const pointer = getPointerClient(event);
@@ -431,6 +443,7 @@ export function useImageCanvasStageInteractions({
return;
}
focusCanvasInteractionTarget(event.currentTarget);
event.preventDefault();
event.stopPropagation();
const pointer = getPointerClient(event);
+18
View File
@@ -4321,6 +4321,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
user-select: none;
}
.image-canvas-editor [aria-label='画布 Agent 对话'],
.image-canvas-editor [aria-label='画布 Agent 对话'] * {
-webkit-touch-callout: default;
-webkit-user-select: text;
user-select: text;
}
.image-canvas-editor [aria-label='画布 Agent 对话'] button,
.image-canvas-editor [aria-label='画布 Agent 对话'] select,
.image-canvas-editor [aria-label='画布 Agent 对话'] textarea {
-webkit-user-select: auto;
user-select: auto;
}
.image-editor-creation-entry-stack {
display: grid;
min-width: 0;
@@ -6556,6 +6570,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.14);
}
.image-canvas-editor__panel-dock--sidebar-open {
bottom: 4.2rem;
}
.image-canvas-editor__panel-dock button {
display: inline-flex;
width: 2.25rem;

Some files were not shown because too many files have changed in this diff Show More