新增图片画布编辑器
新增 /editor 图片画布入口与 Lovart 风格画布交互 新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF 接入图片生成和修改的 VectorEngine gpt-image-2 后端通道 完善素材库文件夹、重命名、上传删除、图层和元数据交互 补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
@@ -133,6 +133,10 @@ export default function App() {
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const isImageEditorStage = selectionStage === 'image-editor';
|
||||
const platformShellSurfaceClass = isImageEditorStage
|
||||
? 'bg-white p-0'
|
||||
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
|
||||
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
@@ -150,7 +154,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
|
||||
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden ${platformShellSurfaceClass} font-sans text-[var(--platform-text-strong)]`}
|
||||
>
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
|
||||
518
src/components/image-editor/ImageCanvasEditorView.test.tsx
Normal file
518
src/components/image-editor/ImageCanvasEditorView.test.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
||||
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorProjectClient')
|
||||
>('../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
editEditorImage: editEditorImageMock,
|
||||
generateEditorImage: generateEditorImageMock,
|
||||
};
|
||||
});
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: Element,
|
||||
type: string,
|
||||
init: MouseEventInit & { pointerId: number },
|
||||
) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
|
||||
fireEvent(target, event);
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditorView', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
generateEditorImageMock.mockReset();
|
||||
editEditorImageMock.mockReset();
|
||||
});
|
||||
|
||||
it('toggles the shared sidebar from canvas panel buttons', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||
const assetsButton = within(panelToolbar).getByRole('button', { name: '打开素材' });
|
||||
const layersButton = within(panelToolbar).getByRole('button', { name: '打开图层' });
|
||||
|
||||
expect(within(sidebar).getByText('素材')).toBeTruthy();
|
||||
expect(within(sidebar).getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
|
||||
expect(assetsButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '展开素材栏' })).toBeNull();
|
||||
|
||||
fireEvent.click(layersButton);
|
||||
|
||||
const layerSidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
expect(within(layerSidebar).getByText('图层')).toBeTruthy();
|
||||
expect(
|
||||
within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }),
|
||||
).toBeTruthy();
|
||||
expect(layersButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
||||
|
||||
fireEvent.click(layersButton);
|
||||
|
||||
expect(screen.queryByRole('complementary', { name: '图片资源栏' })).toBeNull();
|
||||
expect(layersButton.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('groups assets by folder and renames sidebar materials', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
expect(within(sidebar).getByRole('region', { name: '项目素材' })).toBeTruthy();
|
||||
expect(within(sidebar).getByRole('region', { name: '参考素材' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重命名素材拼图素材' }));
|
||||
const renameInput = screen.getByLabelText('重命名素材拼图素材');
|
||||
await user.clear(renameInput);
|
||||
await user.type(renameInput, '主视觉素材');
|
||||
await user.click(screen.getByRole('button', { name: '保存素材拼图素材名称' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: '添加主视觉素材' }));
|
||||
|
||||
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
|
||||
const user = userEvent.setup();
|
||||
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectUrlSpy,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '折叠项目素材' }));
|
||||
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: '展开项目素材' }));
|
||||
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
|
||||
await user.type(screen.getByLabelText('素材文件夹名称'), '角色上传');
|
||||
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
|
||||
|
||||
const uploadInput = screen.getByLabelText('上传图片文件');
|
||||
await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
|
||||
await userEvent.upload(
|
||||
uploadInput,
|
||||
new File(['image'], '角色草图.png', { type: 'image/png' }),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
const customFolder = screen.getByRole('region', { name: '角色上传' });
|
||||
expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy();
|
||||
expect(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })).toBeTruthy();
|
||||
|
||||
await user.click(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: '添加角色草图.png' })).toBeNull();
|
||||
expect(screen.getByAltText('画布图片:角色草图.png')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows image size on hover and placeholder toolbar after selecting a layer', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const canvasImage = screen.getByAltText('画布图片:拼图素材');
|
||||
fireEvent.mouseEnter(canvasImage.closest('button')!);
|
||||
|
||||
expect(screen.getByText('420 x 420 px')).toBeTruthy();
|
||||
|
||||
fireEvent.pointerDown(canvasImage.closest('button')!, {
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
});
|
||||
|
||||
const cropButton = screen.getByRole('button', { name: '裁剪占位' });
|
||||
fireEvent.pointerDown(cropButton, {
|
||||
button: 0,
|
||||
pointerId: 2,
|
||||
clientX: 120,
|
||||
clientY: 96,
|
||||
});
|
||||
fireEvent.click(cropButton);
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中');
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats puzzle material as a normal asset without generated metadata tools', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
|
||||
button: 0,
|
||||
pointerId: 61,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '查看拼图素材元数据' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes the selected layer from the floating toolbar', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
|
||||
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
|
||||
button: 0,
|
||||
pointerId: 51,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除图片' }));
|
||||
|
||||
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
|
||||
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uploads an image file as a new canvas layer', async () => {
|
||||
const createObjectUrlSpy = vi.fn(() => 'blob:uploaded-image');
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectUrlSpy,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
|
||||
expect(within(bottomToolbar).queryByRole('button', { name: '局部修改工具' })).toBeNull();
|
||||
|
||||
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
|
||||
await userEvent.upload(
|
||||
screen.getByLabelText('上传图片文件'),
|
||||
new File(['image'], '测试上传.png', { type: 'image/png' }),
|
||||
);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('blocks the browser context menu inside the editor workspace', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const editor = screen.getByRole('region', { name: '图片画布编辑器' });
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const wasNotCanceled = editor.dispatchEvent(contextMenuEvent);
|
||||
|
||||
expect(wasNotCanceled).toBe(false);
|
||||
expect(contextMenuEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('switches the shared sidebar between assets and layers', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
expect(within(sidebar).getByText('素材')).toBeTruthy();
|
||||
expect(within(sidebar).queryByText('已生成文件')).toBeNull();
|
||||
expect(within(sidebar).queryByText('图层')).toBeNull();
|
||||
expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull();
|
||||
expect(screen.queryByRole('complementary', { name: '图层面板' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
||||
const layersPanel = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
expect(
|
||||
within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' }));
|
||||
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '查看大鱼素材元数据' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('adds assets from the sidebar and supports zoom buttons', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 95%' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
||||
|
||||
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
||||
expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers Lovart-style zoom menu commands', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy();
|
||||
expect(screen.getByRole('menuitem', { name: '显示画布所有元素' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 50%' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens a generation dialog before creating a generated layer', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '一张明亮的拼图主视觉',
|
||||
actualPrompt: '一张明亮的拼图主视觉',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-real-task-1',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '生成工具' }));
|
||||
|
||||
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||||
expect(generateDialog).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '一张明亮的拼图主视觉' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张明亮的拼图主视觉',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||||
const metadataButtons = screen.getAllByRole('button', {
|
||||
name: /查看生成图片 .*元数据/,
|
||||
});
|
||||
expect(metadataButtons[0]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows generation errors instead of falling back to mock images', async () => {
|
||||
generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置'));
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '一张真实生成失败的图' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toContain('VectorEngine 未配置');
|
||||
});
|
||||
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
|
||||
});
|
||||
|
||||
it('switches tools and restores the previous tool after holding Space', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
const selectTool = within(bottomToolbar).getByRole('button', { name: '选择工具' });
|
||||
const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' });
|
||||
const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' });
|
||||
|
||||
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
await user.click(textTool);
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
|
||||
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.keyUp(window, { code: 'Space', key: ' ' });
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('switches away from hand tool from the bottom toolbar', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' });
|
||||
const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' });
|
||||
|
||||
await user.click(handTool);
|
||||
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
await user.click(textTool);
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(handTool.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('pans with the middle mouse button without leaving select mode', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
const middlePointerDown = new MouseEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 1,
|
||||
buttons: 4,
|
||||
clientX: 260,
|
||||
clientY: 220,
|
||||
});
|
||||
Object.defineProperty(middlePointerDown, 'pointerId', { value: 11 });
|
||||
fireEvent(viewport, middlePointerDown);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(viewport.className).toContain('image-canvas-editor__viewport--panning');
|
||||
});
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
expect(
|
||||
within(bottomToolbar)
|
||||
.getByRole('button', { name: '选择工具' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('shows snap guides when dragging a layer near another layer alignment', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const puzzleLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!;
|
||||
dispatchPointerEvent(puzzleLayer, 'pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 21,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
});
|
||||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||||
pointerId: 21,
|
||||
clientX: 499,
|
||||
clientY: 169,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('image-canvas-editor-snap-guide-vertical')).toBeTruthy();
|
||||
expect(screen.getByTestId('image-canvas-editor-snap-guide-horizontal')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can switch tools after a layer drag started without pointer release', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
|
||||
button: 0,
|
||||
pointerId: 41,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
});
|
||||
fireEvent.pointerMove(screen.getByLabelText('画布工作区'), {
|
||||
pointerId: 41,
|
||||
clientX: 220,
|
||||
clientY: 160,
|
||||
});
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' });
|
||||
await user.click(textTool);
|
||||
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.queryByTestId('image-canvas-editor-snap-guide-vertical')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens generated image metadata from the corner button and creates a real right-side edit result', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '一张可修改的生成图',
|
||||
actualPrompt: '一张可修改的生成图',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-real-task-2',
|
||||
});
|
||||
editEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '把画面改成黄昏光线',
|
||||
actualPrompt: '把画面改成黄昏光线',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-real-edit-1',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '一张可修改的生成图' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||
});
|
||||
|
||||
const metadataCornerButton = screen.getAllByRole('button', {
|
||||
name: /查看生成图片 .*元数据/,
|
||||
})[0];
|
||||
if (!metadataCornerButton) {
|
||||
throw new Error('metadata corner button should exist');
|
||||
}
|
||||
fireEvent.click(metadataCornerButton);
|
||||
|
||||
const metadataDialog = screen.getByRole('dialog', { name: /生成图片 .*元数据/ });
|
||||
expect(metadataDialog).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
|
||||
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
|
||||
expect(editDialog).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '把画面改成黄昏光线' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改' }));
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('修改中');
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '把画面改成黄昏光线',
|
||||
sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
1951
src/components/image-editor/ImageCanvasEditorView.tsx
Normal file
1951
src/components/image-editor/ImageCanvasEditorView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
type Dispatch,
|
||||
@@ -1077,6 +1077,13 @@ const CustomWorldGenerationView = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const ImageCanvasEditorView = lazy(async () => {
|
||||
const module = await import('../image-editor/ImageCanvasEditorView');
|
||||
return {
|
||||
default: module.ImageCanvasEditorView,
|
||||
};
|
||||
});
|
||||
|
||||
const UnifiedCreationWorkspace = lazy(async () => {
|
||||
const module = await import('../unified-creation/UnifiedCreationWorkspace');
|
||||
return {
|
||||
@@ -4791,16 +4798,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
() => pendingPlatformTaskCompletionDialog,
|
||||
[pendingPlatformTaskCompletionDialog],
|
||||
);
|
||||
const activePlatformTaskCompletionDialog = resolveActivePlatformDialog(
|
||||
currentPlatformTaskCompletionDialog,
|
||||
dismissedPlatformTaskCompletionDialogKey,
|
||||
buildPlatformTaskCompletionDialogDismissKey,
|
||||
);
|
||||
const activePlatformErrorDialog = resolveActivePlatformDialog(
|
||||
currentPlatformErrorDialog,
|
||||
dismissedPlatformErrorDialogKey,
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
);
|
||||
const activePlatformTaskCompletionDialog =
|
||||
selectionStage === 'image-editor'
|
||||
? null
|
||||
: resolveActivePlatformDialog(
|
||||
currentPlatformTaskCompletionDialog,
|
||||
dismissedPlatformTaskCompletionDialogKey,
|
||||
buildPlatformTaskCompletionDialogDismissKey,
|
||||
);
|
||||
const activePlatformErrorDialog =
|
||||
selectionStage === 'image-editor'
|
||||
? null
|
||||
: resolveActivePlatformDialog(
|
||||
currentPlatformErrorDialog,
|
||||
dismissedPlatformErrorDialogKey,
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
);
|
||||
const closePlatformErrorDialog = useCallback(() => {
|
||||
if (!currentPlatformErrorDialog) {
|
||||
return;
|
||||
@@ -14558,9 +14571,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
) : null}
|
||||
</Suspense>
|
||||
);
|
||||
const creationStartContent = renderCreationHubContent(
|
||||
'start-only',
|
||||
'正在加载创作大厅...',
|
||||
const creationStartContent = (
|
||||
<div className="image-editor-creation-entry-stack">
|
||||
<button
|
||||
type="button"
|
||||
className="image-editor-creation-entry"
|
||||
onClick={() => setSelectionStage('image-editor')}
|
||||
aria-label="打开图片编辑器"
|
||||
>
|
||||
<span className="image-editor-creation-entry__icon">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="image-editor-creation-entry__body">
|
||||
<span>图片编辑器</span>
|
||||
<span>画布工具</span>
|
||||
</span>
|
||||
</button>
|
||||
{renderCreationHubContent('start-only', '正在加载创作大厅...')}
|
||||
</div>
|
||||
);
|
||||
const draftHubContent = renderCreationHubContent(
|
||||
'works-only',
|
||||
@@ -14570,6 +14598,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
{selectionStage === 'image-editor' && (
|
||||
<motion.div
|
||||
key="image-editor"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载编辑器..." />}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'platform' && (
|
||||
<motion.div
|
||||
key="platform-home"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
@@ -15,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'image-editor'
|
||||
| 'profile-feedback'
|
||||
| 'work-detail'
|
||||
| 'detail'
|
||||
@@ -70,7 +69,10 @@ export type SelectionStage =
|
||||
|
||||
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
|
||||
|
||||
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
|
||||
export type CustomWorldResultViewSource =
|
||||
| 'saved-profile'
|
||||
| 'agent-draft'
|
||||
| null;
|
||||
|
||||
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
'image-editor': true,
|
||||
'profile-feedback': false,
|
||||
'work-detail': true,
|
||||
detail: true,
|
||||
|
||||
905
src/index.css
905
src/index.css
@@ -3034,6 +3034,911 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.image-editor-stage-shell {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.image-canvas-editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-editor-creation-entry-stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.image-editor-creation-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 4.4rem;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(75, 181, 170, 0.22);
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(75, 181, 170, 0.16),
|
||||
rgba(223, 161, 94, 0.1)
|
||||
),
|
||||
var(--platform-subpanel-fill);
|
||||
padding: 0.7rem;
|
||||
color: var(--platform-text-strong);
|
||||
text-align: left;
|
||||
box-shadow: 0 16px 32px rgba(43, 68, 64, 0.12);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.image-editor-creation-entry:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(75, 181, 170, 0.5);
|
||||
box-shadow: 0 20px 38px rgba(43, 68, 64, 0.16);
|
||||
}
|
||||
|
||||
.image-editor-creation-entry__icon {
|
||||
display: grid;
|
||||
width: 2.7rem;
|
||||
height: 2.7rem;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
border-radius: 0.45rem;
|
||||
background: rgba(75, 181, 170, 0.18);
|
||||
color: #238a82;
|
||||
}
|
||||
|
||||
.image-editor-creation-entry__body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.image-editor-creation-entry__body span:first-child {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.image-editor-creation-entry__body span + span {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar {
|
||||
display: flex;
|
||||
width: min(18.5rem, 34vw);
|
||||
min-width: 15.5rem;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #d9dee8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar-header {
|
||||
display: flex;
|
||||
min-height: 3.4rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid #d9dee8;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar-title,
|
||||
.image-canvas-editor__title-block h1 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar-count,
|
||||
.image-canvas-editor__title-block span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.image-canvas-editor__icon-button,
|
||||
.image-canvas-editor__zoom-trigger,
|
||||
.image-canvas-editor__zoom-menu button,
|
||||
.image-canvas-editor__floating-toolbar button,
|
||||
.image-canvas-editor__bottom-toolbar button,
|
||||
.image-canvas-editor__metadata-header button,
|
||||
.image-canvas-editor__reset-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d9dee8;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__icon-button:hover,
|
||||
.image-canvas-editor__zoom-trigger:hover,
|
||||
.image-canvas-editor__zoom-menu button:hover,
|
||||
.image-canvas-editor__floating-toolbar button:hover,
|
||||
.image-canvas-editor__bottom-toolbar button:hover,
|
||||
.image-canvas-editor__metadata-header button:hover,
|
||||
.image-canvas-editor__reset-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #8fb8ff;
|
||||
background: #eef5ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__icon-button {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #475569;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header button {
|
||||
display: inline-flex;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.36rem;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header button:hover {
|
||||
border-color: #d9dee8;
|
||||
background: #f8fafc;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-header span + span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-folder-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.4rem minmax(0, 1fr) auto;
|
||||
min-height: 5.1rem;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.45rem;
|
||||
background: #f8fafc;
|
||||
padding: 0.45rem;
|
||||
color: #1f2937;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #8fb8ff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-button {
|
||||
display: block;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-thumb {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.35rem;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(148, 163, 184, 0.18) 25%,
|
||||
transparent 25%
|
||||
),
|
||||
linear-gradient(-45deg, rgba(148, 163, 184, 0.18) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(148, 163, 184, 0.18) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(148, 163, 184, 0.18) 75%);
|
||||
background-position:
|
||||
0 0,
|
||||
0 0.5rem,
|
||||
0.5rem -0.5rem,
|
||||
-0.5rem 0;
|
||||
background-size: 1rem 1rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-thumb img,
|
||||
.image-canvas-editor__layer img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-meta {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-meta span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-meta input {
|
||||
min-width: 0;
|
||||
height: 1.8rem;
|
||||
border: 1px solid #8fb8ff;
|
||||
border-radius: 0.35rem;
|
||||
background: #ffffff;
|
||||
padding: 0 0.45rem;
|
||||
color: #1f2937;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-actions button,
|
||||
.image-canvas-editor__folder-create button {
|
||||
display: inline-flex;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.42rem;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-actions button:hover,
|
||||
.image-canvas-editor__folder-create button:hover {
|
||||
border-color: #8fb8ff;
|
||||
background: #ffffff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__folder-create {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.45rem;
|
||||
background: #f8fafc;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__folder-create input {
|
||||
min-width: 0;
|
||||
height: 2rem;
|
||||
border: 1px solid #8fb8ff;
|
||||
border-radius: 0.35rem;
|
||||
background: #ffffff;
|
||||
padding: 0 0.5rem;
|
||||
color: #1f2937;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-meta span + span {
|
||||
color: #64748b;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layers-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.7rem minmax(0, 1fr);
|
||||
min-height: 3.45rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.45rem;
|
||||
background: #f8fafc;
|
||||
padding: 0.38rem;
|
||||
color: #1f2937;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-row:hover,
|
||||
.image-canvas-editor__layer-row--selected {
|
||||
border-color: #8fb8ff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-row-thumb {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 2.7rem;
|
||||
height: 2.7rem;
|
||||
border-radius: 0.35rem;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-row-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-row-meta {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-row-meta span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-row-meta span + span {
|
||||
color: #64748b;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.image-canvas-editor__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.image-canvas-editor__topbar {
|
||||
display: flex;
|
||||
min-height: 3.4rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid #d9dee8;
|
||||
background: #ffffff;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__zoom-menu-wrap {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-canvas-editor__zoom-trigger {
|
||||
min-width: 4.15rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.45rem;
|
||||
padding: 0 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__zoom-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
right: 0;
|
||||
display: grid;
|
||||
min-width: 12rem;
|
||||
gap: 0.2rem;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.35rem;
|
||||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.image-canvas-editor__zoom-menu button {
|
||||
justify-content: flex-start;
|
||||
min-height: 2rem;
|
||||
width: 100%;
|
||||
border-radius: 0.38rem;
|
||||
padding: 0 0.65rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__floating-toolbar button,
|
||||
.image-canvas-editor__bottom-toolbar button,
|
||||
.image-canvas-editor__metadata-header button,
|
||||
.image-canvas-editor__reset-button {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__viewport {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
background-color: #f8fafc;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__viewport--panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.image-canvas-editor__viewport--tool-hand {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.image-canvas-editor__world {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer {
|
||||
position: absolute;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 0.15rem;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer img {
|
||||
display: block;
|
||||
border-radius: 0.1rem;
|
||||
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer--hovered,
|
||||
.image-canvas-editor__layer--selected {
|
||||
border-color: #4bb5aa;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer--selected {
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-corner {
|
||||
position: absolute;
|
||||
right: 0.35rem;
|
||||
top: 0.35rem;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 15, 14, 0.78);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-corner:hover {
|
||||
background: rgba(3, 105, 161, 0.88);
|
||||
}
|
||||
|
||||
.image-canvas-editor__size-badge {
|
||||
position: absolute;
|
||||
left: 0.35rem;
|
||||
top: 0.35rem;
|
||||
z-index: 2;
|
||||
border-radius: 0.3rem;
|
||||
background: rgba(16, 15, 14, 0.82);
|
||||
padding: 0.18rem 0.35rem;
|
||||
color: #ffffff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-canvas-editor__floating-toolbar {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.28rem;
|
||||
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.18);
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.image-canvas-editor__reset-button {
|
||||
position: absolute;
|
||||
right: 0.85rem;
|
||||
bottom: 0.85rem;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock {
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
bottom: 0.85rem;
|
||||
z-index: 11;
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.35rem;
|
||||
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock button {
|
||||
display: inline-flex;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.45rem;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock button:hover,
|
||||
.image-canvas-editor__panel-dock button[aria-pressed='true'] {
|
||||
transform: translateY(-1px);
|
||||
border-color: #38bdf8;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0.85rem;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
max-width: min(calc(100% - 6.6rem), 34rem);
|
||||
overflow-x: auto;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.35rem;
|
||||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar button[aria-pressed='true'] {
|
||||
border-color: #38bdf8;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__snap-guide {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
pointer-events: none;
|
||||
background: #4bb5aa;
|
||||
box-shadow: 0 0 0 1px rgba(75, 181, 170, 0.28);
|
||||
}
|
||||
|
||||
.image-canvas-editor__snap-guide--vertical {
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.image-canvas-editor__snap-guide--horizontal {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.image-canvas-editor__rulers {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
background: rgba(241, 245, 249, 0.92);
|
||||
}
|
||||
|
||||
.image-canvas-editor__rulers--top {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 1.25rem;
|
||||
border-bottom: 1px solid #d9dee8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__rulers--left {
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1.25rem;
|
||||
border-right: 1px solid #d9dee8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(8, 7, 6, 0.58);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-dialog {
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
max-height: min(32rem, calc(100vh - 2rem));
|
||||
overflow: auto;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 26px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-dialog {
|
||||
width: min(30rem, calc(100vw - 2rem));
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 26px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-header {
|
||||
display: flex;
|
||||
min-height: 3rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid #d9dee8;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-body {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-body textarea {
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.45rem;
|
||||
background: #f8fafc;
|
||||
padding: 0.7rem;
|
||||
color: #1f2937;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-body textarea:focus {
|
||||
border-color: #38bdf8;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18);
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-status {
|
||||
border-radius: 0.45rem;
|
||||
background: #eef5ff;
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: #0369a1;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-status--error {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-submit {
|
||||
justify-self: end;
|
||||
min-width: 5.5rem;
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid #38bdf8;
|
||||
border-radius: 0.45rem;
|
||||
background: #0ea5e9;
|
||||
color: #ffffff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-submit:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
gap: 0.55rem 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0.85rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-grid dt {
|
||||
color: #64748b;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-grid dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #1f2937;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.image-canvas-editor {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-height: 14rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d9dee8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-list {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(15rem, 78vw);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layers-list {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(12.5rem, 70vw);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.image-canvas-editor__main {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__topbar {
|
||||
min-height: 3.2rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__title-block h1 {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__floating-toolbar button,
|
||||
.image-canvas-editor__bottom-toolbar button,
|
||||
.image-canvas-editor__reset-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__floating-toolbar {
|
||||
max-width: calc(100% - 1.5rem);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar {
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock {
|
||||
left: 0.75rem;
|
||||
bottom: 3.85rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.platform-bottom-nav {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -21,6 +21,12 @@ describe('appPageRoutes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the image editor route', () => {
|
||||
expect(resolveSelectionStageFromPath('/editor')).toBe('image-editor');
|
||||
expect(resolveSelectionStageFromPath('/EDITOR/')).toBe('image-editor');
|
||||
expect(resolvePathForSelectionStage('image-editor')).toBe('/editor');
|
||||
});
|
||||
|
||||
it('resolves jump-hop creation, gallery and runtime routes', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
|
||||
'jump-hop-workspace',
|
||||
@@ -58,9 +64,9 @@ describe('appPageRoutes', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/puzzle-clear')).toBe(
|
||||
'puzzle-clear-workspace',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/puzzle-clear/generating')).toBe(
|
||||
'puzzle-clear-generating',
|
||||
);
|
||||
expect(
|
||||
resolveSelectionStageFromPath('/creation/puzzle-clear/generating'),
|
||||
).toBe('puzzle-clear-generating');
|
||||
expect(resolveSelectionStageFromPath('/creation/puzzle-clear/result')).toBe(
|
||||
'puzzle-clear-result',
|
||||
);
|
||||
@@ -85,9 +91,9 @@ describe('appPageRoutes', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/wooden-fish')).toBe(
|
||||
'wooden-fish-workspace',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/wooden-fish/generating')).toBe(
|
||||
'wooden-fish-generating',
|
||||
);
|
||||
expect(
|
||||
resolveSelectionStageFromPath('/creation/wooden-fish/generating'),
|
||||
).toBe('wooden-fish-generating');
|
||||
expect(resolveSelectionStageFromPath('/creation/wooden-fish/result')).toBe(
|
||||
'wooden-fish-result',
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
|
||||
|
||||
const STAGE_ROUTE_ENTRIES = [
|
||||
['platform', '/'],
|
||||
['image-editor', '/editor'],
|
||||
['profile-feedback', '/profile/feedback'],
|
||||
['work-detail', '/works/detail'],
|
||||
['detail', '/worlds/detail'],
|
||||
|
||||
251
src/services/image-editor/editorProjectClient.test.ts
Normal file
251
src/services/image-editor/editorProjectClient.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createEditorProject,
|
||||
createEditorProjectResource,
|
||||
editEditorImage,
|
||||
generateEditorImage,
|
||||
loadOrCreateRecentEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
} from './editorProjectClient';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('editorProjectClient', () => {
|
||||
afterEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads the recent project without creating a duplicate when it exists', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-1',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const project = await loadOrCreateRecentEditorProject();
|
||||
|
||||
expect(project.projectId).toBe('editor-project-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/recent',
|
||||
{ method: 'GET' },
|
||||
'读取图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a default project when there is no recent project', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({ project: null })
|
||||
.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-created',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const project = await loadOrCreateRecentEditorProject();
|
||||
|
||||
expect(project.projectId).toBe('editor-project-created');
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/editor/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '未命名画布' }),
|
||||
}),
|
||||
'创建图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves viewport and layer layout through the project API', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-1',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 12, y: 24, scale: 0.5 },
|
||||
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await saveEditorProjectLayout('editor-project-1', {
|
||||
viewport: { x: 12, y: 24, scale: 0.5 },
|
||||
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/editor-project-1',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
viewport: { x: 12, y: 24, scale: 0.5 },
|
||||
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
|
||||
}),
|
||||
}),
|
||||
'保存图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a resource with upload or generated metadata', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
resource: {
|
||||
resourceId: 'resource-generated-1',
|
||||
projectId: 'editor-project-1',
|
||||
imageSrc: '/generated-editor-assets/project/image.png',
|
||||
objectKey: 'generated-editor-assets/project/image.png',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
sourceType: 'generated',
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
},
|
||||
});
|
||||
|
||||
await createEditorProjectResource('editor-project-1', {
|
||||
imageSrc: '/generated-editor-assets/project/image.png',
|
||||
objectKey: 'generated-editor-assets/project/image.png',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
sourceType: 'generated',
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/editor-project-1/resources',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
imageSrc: '/generated-editor-assets/project/image.png',
|
||||
objectKey: 'generated-editor-assets/project/image.png',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
sourceType: 'generated',
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
}),
|
||||
}),
|
||||
'创建图片画布资源失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an explicit project from title input', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-explicit',
|
||||
title: '角色设定板',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await createEditorProject({ title: '角色设定板' });
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '角色设定板' }),
|
||||
}),
|
||||
'创建图片画布工程失败',
|
||||
expect.objectContaining({ authImpact: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates editor images through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,abc',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '一张画布图片',
|
||||
actualPrompt: '一张画布图片',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'vector-task-1',
|
||||
});
|
||||
|
||||
const result = await generateEditorImage({ prompt: '一张画布图片' });
|
||||
|
||||
expect(result.taskId).toBe('vector-task-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/images/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: '一张画布图片' }),
|
||||
}),
|
||||
'生成图片失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('edits editor images through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,edited',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '把画面改成黄昏光线',
|
||||
actualPrompt: '把画面改成黄昏光线',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'vector-edit-1',
|
||||
});
|
||||
|
||||
const result = await editEditorImage({
|
||||
prompt: '把画面改成黄昏光线',
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('vector-edit-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/images/edits',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: '把画面改成黄昏光线',
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
}),
|
||||
}),
|
||||
'修改图片失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
209
src/services/image-editor/editorProjectClient.ts
Normal file
209
src/services/image-editor/editorProjectClient.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
const EDITOR_PROJECT_API_BASE = '/api/editor/projects';
|
||||
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
|
||||
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||
const DEFAULT_PROJECT_TITLE = '未命名画布';
|
||||
const EDITOR_PROJECT_REQUEST_OPTIONS = {
|
||||
authImpact: 'local' as const,
|
||||
};
|
||||
|
||||
export type EditorCanvasViewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export type EditorProjectLayerSnapshot = Record<string, unknown> & {
|
||||
layerId: string;
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export type EditorProjectResourceSourceType =
|
||||
| 'uploaded'
|
||||
| 'generated'
|
||||
| 'mock_generated';
|
||||
|
||||
export type EditorProjectResourceSnapshot = {
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: EditorProjectResourceSourceType;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationInput = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type EditorImageEditInput = {
|
||||
prompt: string;
|
||||
sourceImageSrc: string;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationResult = {
|
||||
imageSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: 'generated';
|
||||
prompt: string;
|
||||
actualPrompt?: string | null;
|
||||
model: string;
|
||||
provider: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type EditorProjectSnapshot = {
|
||||
projectId: string;
|
||||
title: string;
|
||||
viewport: EditorCanvasViewport;
|
||||
layers: EditorProjectLayerSnapshot[];
|
||||
resources: EditorProjectResourceSnapshot[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type EditorProjectCreateInput = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type EditorProjectLayoutSaveInput = {
|
||||
viewport: EditorCanvasViewport;
|
||||
layers: EditorProjectLayerSnapshot[];
|
||||
};
|
||||
|
||||
export type EditorProjectResourceCreateInput = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: EditorProjectResourceSourceType;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
};
|
||||
|
||||
type EditorProjectResponse = {
|
||||
project: EditorProjectSnapshot;
|
||||
};
|
||||
|
||||
type EditorProjectRecentResponse = {
|
||||
project: EditorProjectSnapshot | null;
|
||||
};
|
||||
|
||||
type EditorProjectResourceResponse = {
|
||||
resource: EditorProjectResourceSnapshot;
|
||||
};
|
||||
|
||||
type EditorImageGenerationResponse = EditorImageGenerationResult;
|
||||
|
||||
function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRecentEditorProject() {
|
||||
return requestJson<EditorProjectRecentResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/recent`,
|
||||
{ method: 'GET' },
|
||||
'读取图片画布工程失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEditorProject(input: EditorProjectCreateInput = {}) {
|
||||
const response = await requestJson<EditorProjectResponse>(
|
||||
EDITOR_PROJECT_API_BASE,
|
||||
jsonRequest('POST', { title: input.title?.trim() || DEFAULT_PROJECT_TITLE }),
|
||||
'创建图片画布工程失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function loadOrCreateRecentEditorProject() {
|
||||
const response = await loadRecentEditorProject();
|
||||
if (response.project) {
|
||||
return response.project;
|
||||
}
|
||||
return createEditorProject({ title: DEFAULT_PROJECT_TITLE });
|
||||
}
|
||||
|
||||
export async function saveEditorProjectLayout(
|
||||
projectId: string,
|
||||
input: EditorProjectLayoutSaveInput,
|
||||
) {
|
||||
const response = await requestJson<EditorProjectResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`,
|
||||
jsonRequest('PATCH', {
|
||||
viewport: input.viewport,
|
||||
layers: input.layers,
|
||||
}),
|
||||
'保存图片画布工程失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function createEditorProjectResource(
|
||||
projectId: string,
|
||||
input: EditorProjectResourceCreateInput,
|
||||
) {
|
||||
const response = await requestJson<EditorProjectResourceResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}/resources`,
|
||||
jsonRequest('POST', { ...input }),
|
||||
'创建图片画布资源失败',
|
||||
EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.resource;
|
||||
}
|
||||
|
||||
export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
return requestJson<EditorImageGenerationResponse>(
|
||||
EDITOR_IMAGE_GENERATION_API,
|
||||
jsonRequest('POST', { prompt: input.prompt }),
|
||||
'生成图片失败',
|
||||
{
|
||||
...EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
timeoutMs: 1_200_000,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function editEditorImage(input: EditorImageEditInput) {
|
||||
return requestJson<EditorImageGenerationResponse>(
|
||||
EDITOR_IMAGE_EDIT_API,
|
||||
jsonRequest('POST', {
|
||||
prompt: input.prompt,
|
||||
sourceImageSrc: input.sourceImageSrc,
|
||||
}),
|
||||
'修改图片失败',
|
||||
{
|
||||
...EDITOR_PROJECT_REQUEST_OPTIONS,
|
||||
timeoutMs: 1_200_000,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user