完善图片画布素材库持久化

新增账号级素材文件夹和素材表,并接入 SpacetimeDB procedure、spacetime-client facade 与 api-server BFF。

编辑器素材栏支持文件夹新建、折叠、重命名、删除、多文件上传、拖拽定向上传、框选和批量删除。

画布支持拖拽上传落点创建图层、图层打组、小地图拖拽、普通滚轮纵向滚动和 Ctrl 滚轮缩放。

更新图片画布技术方案、后端数据契约、TRACKING 和团队决策记录。
This commit is contained in:
2026-06-14 14:29:13 +08:00
parent 6bc2f11d04
commit a6025365f7
43 changed files with 4459 additions and 125 deletions

View File

@@ -1,4 +1,9 @@
import type { ComponentType, ReactNode } from 'react';
import type {
ComponentType,
DragEventHandler,
PointerEventHandler,
ReactNode,
} from 'react';
type IconComponent = ComponentType<{ className?: string }>;
@@ -55,6 +60,9 @@ export type SidebarMediaItemProps = {
primaryClassName?: string;
actions?: ReactNode;
titleNode?: ReactNode;
onDragOver?: DragEventHandler<HTMLDivElement>;
onDrop?: DragEventHandler<HTMLDivElement>;
onPointerEnter?: PointerEventHandler<HTMLDivElement>;
};
export function SidebarMediaItem({
@@ -71,10 +79,16 @@ export function SidebarMediaItem({
primaryClassName,
actions,
titleNode,
onDragOver,
onDrop,
onPointerEnter,
}: SidebarMediaItemProps) {
return (
<div
className={`${rowClassName} ${selected ? `${rowClassName}--selected` : ''}`}
onDragOver={onDragOver}
onDrop={onDrop}
onPointerEnter={onPointerEnter}
>
<button
type="button"

View File

@@ -9,8 +9,16 @@ import { ImageCanvasEditorView } from './ImageCanvasEditorView';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const createEditorAssetMock = vi.hoisted(() => vi.fn());
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -19,9 +27,17 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
return {
...actual,
editEditorImage: editEditorImageMock,
createEditorAsset: createEditorAssetMock,
createEditorAssetFolder: createEditorAssetFolderMock,
createEditorProjectResource: createEditorProjectResourceMock,
deleteEditorAsset: deleteEditorAssetMock,
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
generateEditorImage: generateEditorImageMock,
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
loadEditorProject: loadEditorProjectMock,
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
saveEditorProjectLayout: saveEditorProjectLayoutMock,
updateEditorAssetFolder: updateEditorAssetFolderMock,
};
});
@@ -49,14 +65,78 @@ describe('ImageCanvasEditorView', () => {
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValue({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
createEditorAssetMock.mockImplementation(async (input) => ({
assetId: `persisted-${input.label}`,
folderId: input.folderId,
label: input.label,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role-persisted',
label: '角色上传',
collapsed: false,
systemDefault: false,
});
updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({
folderId,
label: input.label ?? '角色上传',
collapsed: input.collapsed ?? false,
systemDefault: false,
}));
deleteEditorAssetFolderMock.mockResolvedValue({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
deleteEditorAssetMock.mockResolvedValue({});
createEditorProjectResourceMock.mockImplementation(async (projectId, input) => ({
resourceId: `resource-${projectId}-${input.width}`,
projectId,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
}));
saveEditorProjectLayoutMock.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
generateEditorImageMock.mockReset();
editEditorImageMock.mockReset();
createEditorAssetMock.mockReset();
createEditorProjectResourceMock.mockReset();
createEditorAssetFolderMock.mockReset();
updateEditorAssetFolderMock.mockReset();
deleteEditorAssetFolderMock.mockReset();
deleteEditorAssetMock.mockReset();
loadEditorAssetLibraryMock.mockReset();
loadEditorProjectMock.mockReset();
loadOrCreateRecentEditorProjectMock.mockReset();
saveEditorProjectLayoutMock.mockReset();
window.history.replaceState(null, '', '/editor/canvas');
});
@@ -170,6 +250,215 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByAltText('画布图片:角色草图.png')).toBeTruthy();
});
it('renames and deletes asset folders through the persisted asset library API', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-role',
label: '角色',
sortOrder: 100,
collapsed: false,
systemDefault: false,
},
],
assets: [],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('region', { name: '角色' });
await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
const folderRenameInput = screen.getByLabelText('重命名文件夹角色');
await user.clear(folderRenameInput);
await user.type(folderRenameInput, '角色参考');
await user.click(screen.getByRole('button', { name: '保存文件夹角色名称' }));
expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', {
label: '角色参考',
});
await user.click(screen.getByRole('button', { name: '删除文件夹角色参考' }));
expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
});
it('uploads multiple files and persists them as account-level assets', async () => {
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
new File(['image-a'], '第一张.png', { type: 'image/png' }),
new File(['image-b'], '第二张.png', { type: 'image/png' }),
]);
await waitFor(() => {
expect(screen.getByAltText('画布图片:第一张.png')).toBeTruthy();
expect(screen.getByAltText('画布图片:第二张.png')).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
});
it('supports asset selection mode and batch delete with shared toolbar', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-a',
folderId: 'project',
label: '账号素材A',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
assetId: 'asset-b',
folderId: 'project',
label: '账号素材B',
imageSrc: 'data:image/png;base64,Yg==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('button', { name: '添加账号素材A' });
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
await user.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
expect(within(batchToolbar).getByText(/ 1/u)).toBeTruthy();
await user.click(within(batchToolbar).getByRole('button', { name: '删除' }));
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(screen.queryByRole('button', { name: '选择素材账号素材A' })).toBeNull();
});
it('selects multiple assets with a marquee in asset selection mode', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-a',
folderId: 'project',
label: '账号素材A',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
assetId: 'asset-b',
folderId: 'project',
label: '账号素材B',
imageSrc: 'data:image/png;base64,Yg==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
});
render(<ImageCanvasEditorView />);
const firstAssetButton = await screen.findByRole('button', {
name: '添加账号素材A',
});
const secondAssetButton = screen.getByRole('button', { name: '添加账号素材B' });
const assetList = firstAssetButton.closest(
'.image-canvas-editor__asset-list',
) as HTMLElement;
vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 320,
bottom: 600,
width: 320,
height: 600,
toJSON: () => ({}),
});
vi.spyOn(
firstAssetButton.closest('[data-asset-id]') as HTMLElement,
'getBoundingClientRect',
).mockReturnValue({
x: 16,
y: 120,
left: 16,
top: 120,
right: 280,
bottom: 200,
width: 264,
height: 80,
toJSON: () => ({}),
});
vi.spyOn(
secondAssetButton.closest('[data-asset-id]') as HTMLElement,
'getBoundingClientRect',
).mockReturnValue({
x: 16,
y: 240,
left: 16,
top: 240,
right: 280,
bottom: 320,
width: 264,
height: 80,
toJSON: () => ({}),
});
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
dispatchPointerEvent(assetList, 'pointerdown', {
button: 0,
pointerId: 88,
clientX: 8,
clientY: 100,
});
dispatchPointerEvent(assetList, 'pointermove', {
button: 0,
pointerId: 88,
clientX: 300,
clientY: 330,
});
dispatchPointerEvent(assetList, 'pointerup', {
button: 0,
pointerId: 88,
clientX: 300,
clientY: 330,
});
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
expect(within(batchToolbar).getByText(/ 2/u)).toBeTruthy();
});
it('shows image size on hover and placeholder toolbar after selecting a layer', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ImageCanvasEditorView />);
@@ -231,11 +520,6 @@ describe('ImageCanvasEditorView', () => {
});
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 />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
@@ -251,8 +535,15 @@ describe('ImageCanvasEditorView', () => {
new File(['image'], '测试上传.png', { type: 'image/png' }),
);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
await waitFor(() => {
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
label: '测试上传.png',
imageSrc: expect.stringMatching(/^data:image\/png;base64,/u),
}),
);
expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy();
});
@@ -348,6 +639,75 @@ describe('ImageCanvasEditorView', () => {
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
});
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy();
fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 });
expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy();
fireEvent.wheel(viewport, {
deltaY: -120,
ctrlKey: true,
clientX: 400,
clientY: 280,
});
expect(screen.getByRole('button', { name: '当前缩放比例 90%' })).toBeTruthy();
});
it('drags the minimap to move the canvas viewport', () => {
render(<ImageCanvasEditorView />);
const minimap = screen.getByRole('button', { name: '画布小地图' });
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 132,
bottom: 84,
width: 132,
height: 84,
toJSON: () => ({}),
});
dispatchPointerEvent(minimap, 'pointerdown', {
button: 0,
pointerId: 71,
clientX: 120,
clientY: 72,
});
const firstLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!;
expect(Number.parseFloat((firstLayer as HTMLElement).style.left)).toBe(470);
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
});
it('persists layer groups in the canvas layer snapshot', async () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
fireEvent.click(screen.getByRole('button', { name: '图层打组' }));
await waitFor(() => {
expect(screen.getByText(//u)).toBeTruthy();
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '拼图素材',
groupId: expect.stringMatching(/^layer-group-/u),
}),
]),
}),
);
});
});
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',

File diff suppressed because it is too large Load Diff