合并图片画布素材分支
将 codex/editor-asset-library 合并到 dev-jenken 保留编辑器生成规范、角色形象和图标素材能力 补回画布布局轻量保存和小地图拖拽手感修复
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ComponentType,
|
||||
DragEventHandler,
|
||||
MouseEventHandler,
|
||||
PointerEventHandler,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
@@ -68,9 +69,16 @@ export type SidebarMediaItemProps = {
|
||||
primaryClassName?: string;
|
||||
actions?: ReactNode;
|
||||
titleNode?: ReactNode;
|
||||
previewOverlay?: ReactNode;
|
||||
footerNode?: ReactNode;
|
||||
draggable?: boolean;
|
||||
onDragStart?: DragEventHandler<HTMLElement>;
|
||||
onDragEnd?: DragEventHandler<HTMLElement>;
|
||||
onDragOver?: DragEventHandler<HTMLDivElement>;
|
||||
onDrop?: DragEventHandler<HTMLDivElement>;
|
||||
onPointerDown?: PointerEventHandler<HTMLDivElement>;
|
||||
onPointerEnter?: PointerEventHandler<HTMLDivElement>;
|
||||
onContextMenu?: MouseEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function SidebarMediaItem({
|
||||
@@ -87,22 +95,37 @@ export function SidebarMediaItem({
|
||||
primaryClassName,
|
||||
actions,
|
||||
titleNode,
|
||||
previewOverlay,
|
||||
footerNode,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onContextMenu,
|
||||
}: SidebarMediaItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${rowClassName} ${selected ? `${rowClassName}--selected` : ''}`}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={primaryClassName}
|
||||
onClick={onPrimaryClick}
|
||||
aria-label={primaryLabel}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<PlatformMediaFrame
|
||||
src={imageSrc}
|
||||
@@ -111,11 +134,13 @@ export function SidebarMediaItem({
|
||||
aspect="square"
|
||||
surface="none"
|
||||
className={thumbnailClassName}
|
||||
previewOverlay={previewOverlay}
|
||||
/>
|
||||
</button>
|
||||
<div className={metaClassName}>
|
||||
{titleNode ?? <span>{title}</span>}
|
||||
<span>{detail}</span>
|
||||
{footerNode}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
@@ -784,6 +784,48 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saves canvas layout without embedding image payloads in layer snapshots', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-data-heavy',
|
||||
folderId: 'project',
|
||||
label: '大图素材',
|
||||
imageSrc: 'data:image/png;base64,'.concat('a'.repeat(4000)),
|
||||
width: 1024,
|
||||
height: 768,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await screen.findByRole('button', { name: '添加大图素材' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加大图素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
|
||||
});
|
||||
const lastLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
|
||||
expect(lastLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
src: expect.stringMatching(/^data:image/u),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers Lovart-style zoom menu commands', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -954,6 +996,53 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps minimap drag direction stable after pausing and reversing', () => {
|
||||
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: () => ({}),
|
||||
});
|
||||
const world = screen
|
||||
.getByLabelText('画布工作区')
|
||||
.querySelector('.image-canvas-editor__world') as HTMLElement;
|
||||
const readTranslateX = () => {
|
||||
const match = /translate\(([-\d.]+)px,/u.exec(world.style.transform);
|
||||
return match ? Number(match[1]) : 0;
|
||||
};
|
||||
|
||||
dispatchPointerEvent(minimap, 'pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 72,
|
||||
clientX: 60,
|
||||
clientY: 42,
|
||||
});
|
||||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||||
button: 0,
|
||||
pointerId: 72,
|
||||
clientX: 120,
|
||||
clientY: 42,
|
||||
});
|
||||
const translateAfterRightDrag = readTranslateX();
|
||||
|
||||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||||
button: 0,
|
||||
pointerId: 72,
|
||||
clientX: 90,
|
||||
clientY: 42,
|
||||
});
|
||||
|
||||
expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag);
|
||||
});
|
||||
|
||||
it('persists layer groups in the canvas layer snapshot', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
|
||||
@@ -305,6 +305,11 @@ type DragState =
|
||||
| {
|
||||
kind: 'minimap';
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startViewport: CanvasViewport;
|
||||
minimapScale: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
const EDITOR_ASSETS: EditorAsset[] = [
|
||||
@@ -416,6 +421,7 @@ const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||||
const FIT_VIEW_PADDING = 10;
|
||||
const MINIMAP_SIZE = { width: 132, height: 84 };
|
||||
const MINIMAP_PADDING = 8;
|
||||
const MINIMAP_DRAG_SENSITIVITY = 0.3;
|
||||
const SPEC_GENERATION_COST = 5;
|
||||
const SPEC_GENERATION_SIZE = '2048x1152';
|
||||
const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
|
||||
@@ -601,7 +607,6 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||||
layerId: layer.id,
|
||||
resourceId: layer.resourceId,
|
||||
title: layer.title,
|
||||
src: layer.src,
|
||||
x: layer.x,
|
||||
y: layer.y,
|
||||
width: layer.width,
|
||||
@@ -625,11 +630,13 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||||
|
||||
function hydrateLayer(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
resourcesById: Map<string, { imageSrc: string }>,
|
||||
): CanvasLayer | null {
|
||||
const resourceId =
|
||||
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
|
||||
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
|
||||
const src = typeof snapshot.src === 'string' ? snapshot.src : '';
|
||||
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
|
||||
const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || '';
|
||||
const title =
|
||||
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
|
||||
if (!resourceId || !layerId || !src) {
|
||||
@@ -1543,8 +1550,14 @@ export function ImageCanvasEditorView() {
|
||||
createProjectResourceForLayer(layer, options);
|
||||
});
|
||||
setViewport(project.viewport);
|
||||
const resourcesById = new Map(
|
||||
project.resources.map((resource) => [
|
||||
resource.resourceId,
|
||||
{ imageSrc: resource.imageSrc },
|
||||
]),
|
||||
);
|
||||
const hydratedLayers = project.layers
|
||||
.map(hydrateLayer)
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
if (hydratedLayers.length > 0) {
|
||||
layerCounterRef.current = hydratedLayers.length;
|
||||
@@ -3367,6 +3380,28 @@ export function ImageCanvasEditorView() {
|
||||
}));
|
||||
};
|
||||
|
||||
const moveViewportFromMinimapDrag = (
|
||||
dragState: Extract<DragState, { kind: 'minimap' }>,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
const deltaWorldX =
|
||||
((clientX - dragState.startClientX) / dragState.minimapScale) *
|
||||
MINIMAP_DRAG_SENSITIVITY;
|
||||
const deltaWorldY =
|
||||
((clientY - dragState.startClientY) / dragState.minimapScale) *
|
||||
MINIMAP_DRAG_SENSITIVITY;
|
||||
setViewport({
|
||||
...dragState.startViewport,
|
||||
x:
|
||||
dragState.startViewport.x -
|
||||
deltaWorldX * dragState.startViewport.scale,
|
||||
y:
|
||||
dragState.startViewport.y -
|
||||
deltaWorldY * dragState.startViewport.scale,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMinimapPointerDown = (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
@@ -3377,8 +3412,12 @@ export function ImageCanvasEditorView() {
|
||||
dragStateRef.current = {
|
||||
kind: 'minimap',
|
||||
pointerId: getPointerId(event),
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
startViewport: { ...viewport },
|
||||
minimapScale: minimapModel?.scale ?? 1,
|
||||
moved: false,
|
||||
};
|
||||
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -3463,7 +3502,14 @@ export function ImageCanvasEditorView() {
|
||||
|
||||
if (dragState.kind === 'minimap') {
|
||||
const pointer = getPointerClient(event);
|
||||
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
||||
const deltaX = pointer.x - dragState.startClientX;
|
||||
const deltaY = pointer.y - dragState.startClientY;
|
||||
if (!dragState.moved && Math.hypot(deltaX, deltaY) >= 2) {
|
||||
dragState.moved = true;
|
||||
}
|
||||
if (dragState.moved) {
|
||||
moveViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3534,6 +3580,10 @@ export function ImageCanvasEditorView() {
|
||||
pointerId < 0 ||
|
||||
dragState.pointerId === pointerId)
|
||||
) {
|
||||
if (dragState.kind === 'minimap' && !dragState.moved) {
|
||||
const pointer = getPointerClient(event);
|
||||
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
setIsPanning(false);
|
||||
setSnapGuide(null);
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ContextType } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { ProjectGalleryView } from './ProjectGalleryView';
|
||||
|
||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
@@ -11,6 +14,29 @@ const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
return {
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn((action: () => void) => action()),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
musicVolume: 0.5,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light' as const,
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
listEditorProjects: listEditorProjectsMock,
|
||||
createEditorProject: createEditorProjectMock,
|
||||
@@ -67,6 +93,115 @@ describe('ProjectGalleryView', () => {
|
||||
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
|
||||
});
|
||||
|
||||
it('uses canvas-center layer composition as the project cover', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
projectId: 'editor-project-cover',
|
||||
title: '封面项目',
|
||||
viewport: { x: 120, y: -60, scale: 0.8 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-cover-back',
|
||||
resourceId: 'resource-back',
|
||||
title: '背景图',
|
||||
x: 200,
|
||||
y: 160,
|
||||
width: 300,
|
||||
height: 180,
|
||||
zIndex: 1,
|
||||
},
|
||||
{
|
||||
layerId: 'layer-cover-front',
|
||||
resourceId: 'resource-front',
|
||||
title: '前景图',
|
||||
x: 420,
|
||||
y: 260,
|
||||
width: 160,
|
||||
height: 120,
|
||||
zIndex: 2,
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-front',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: 'data:image/png;base64,front',
|
||||
width: 160,
|
||||
height: 120,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-back',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: 'data:image/png;base64,back',
|
||||
width: 300,
|
||||
height: 180,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T08:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
||||
|
||||
await screen.findByText('封面项目');
|
||||
const cover = document.querySelector('.project-gallery__canvas-cover');
|
||||
const coverLayers = document.querySelectorAll(
|
||||
'.project-gallery__canvas-cover-layer',
|
||||
);
|
||||
|
||||
expect(cover).toBeTruthy();
|
||||
expect(coverLayers).toHaveLength(2);
|
||||
expect(
|
||||
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,back');
|
||||
expect((coverLayers[0] as HTMLElement).style.zIndex).toBe('1');
|
||||
expect((coverLayers[1] as HTMLElement).style.zIndex).toBe('2');
|
||||
});
|
||||
|
||||
it('opens the login modal when project list loading is unauthorized', async () => {
|
||||
const openLoginModal = vi.fn();
|
||||
listEditorProjectsMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '未授权访问',
|
||||
status: 401,
|
||||
code: 'UNAUTHORIZED',
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
|
||||
<ProjectGalleryView onOpenProject={vi.fn()} />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires login before opening a project card while logged out', async () => {
|
||||
const onOpenProject = vi.fn();
|
||||
const requireAuth = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
|
||||
<ProjectGalleryView onOpenProject={onOpenProject} />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await screen.findByText('角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(onOpenProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders project loading errors through the shared status message', async () => {
|
||||
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));
|
||||
|
||||
|
||||
@@ -14,8 +14,13 @@ import {
|
||||
deleteEditorProject,
|
||||
listEditorProjects,
|
||||
renameEditorProject,
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
@@ -29,6 +34,9 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
const PROJECT_COVER_SIZE = { width: 320, height: 240 };
|
||||
const PROJECT_COVER_VIEWPORT_SIZE = { width: 900, height: 640 };
|
||||
|
||||
type ProjectGalleryViewProps = {
|
||||
onOpenProject: (projectId: string) => void;
|
||||
};
|
||||
@@ -38,16 +46,110 @@ type RenameDraft = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
function resolveProjectPreview(project: EditorProjectSnapshot) {
|
||||
const layerResourceIds = new Set(
|
||||
project.layers
|
||||
.map((layer) => layer.resourceId)
|
||||
.filter((resourceId) => resourceId.trim().length > 0),
|
||||
function isUnauthorizedError(error: unknown) {
|
||||
return error instanceof ApiClientError && error.status === 401;
|
||||
}
|
||||
|
||||
type ProjectCoverLayer = {
|
||||
layerId: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
function numberFromLayer(layer: EditorProjectLayerSnapshot, key: string, fallback: number) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
|
||||
return layer.hidden === true;
|
||||
}
|
||||
|
||||
function resolveProjectCoverLayers(project: EditorProjectSnapshot): ProjectCoverLayer[] {
|
||||
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
|
||||
project.resources.map((resource) => [resource.resourceId, resource]),
|
||||
);
|
||||
|
||||
return project.layers
|
||||
.filter((layer) => !isLayerHidden(layer))
|
||||
.map((layer) => {
|
||||
const resource = resourcesById.get(layer.resourceId);
|
||||
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
layerId: layer.layerId,
|
||||
title: stringFromLayer(layer, 'title') || '画布图片',
|
||||
imageSrc,
|
||||
x: numberFromLayer(layer, 'x', 0),
|
||||
y: numberFromLayer(layer, 'y', 0),
|
||||
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
|
||||
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
|
||||
zIndex: numberFromLayer(layer, 'zIndex', 0),
|
||||
} satisfies ProjectCoverLayer;
|
||||
})
|
||||
.filter((layer): layer is ProjectCoverLayer => Boolean(layer))
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
}
|
||||
|
||||
function ProjectCanvasCover({ project }: { project: EditorProjectSnapshot }) {
|
||||
const coverLayers = resolveProjectCoverLayers(project);
|
||||
if (!coverLayers.length) {
|
||||
return <span className="project-gallery__preview-empty" />;
|
||||
}
|
||||
|
||||
const safeScale =
|
||||
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
|
||||
? project.viewport.scale
|
||||
: 1;
|
||||
const viewportCenterX =
|
||||
(PROJECT_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) / safeScale;
|
||||
const viewportCenterY =
|
||||
(PROJECT_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) / safeScale;
|
||||
const worldPreviewWidth = PROJECT_COVER_VIEWPORT_SIZE.width / safeScale;
|
||||
const worldPreviewHeight = PROJECT_COVER_VIEWPORT_SIZE.height / safeScale;
|
||||
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
|
||||
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
|
||||
const scale = Math.min(
|
||||
PROJECT_COVER_SIZE.width / worldPreviewWidth,
|
||||
PROJECT_COVER_SIZE.height / worldPreviewHeight,
|
||||
);
|
||||
const offsetX = -previewMinX * scale;
|
||||
const offsetY = -previewMinY * scale;
|
||||
|
||||
return (
|
||||
project.resources.find((resource) => layerResourceIds.has(resource.resourceId)) ??
|
||||
project.resources[0] ??
|
||||
null
|
||||
<span className="project-gallery__canvas-cover" aria-hidden="true">
|
||||
{coverLayers.map((layer) => (
|
||||
<span
|
||||
key={layer.layerId}
|
||||
className="project-gallery__canvas-cover-layer"
|
||||
style={{
|
||||
left: offsetX + layer.x * scale,
|
||||
top: offsetY + layer.y * scale,
|
||||
width: layer.width * scale,
|
||||
height: layer.height * scale,
|
||||
zIndex: layer.zIndex,
|
||||
}}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={layer.imageSrc}
|
||||
alt=""
|
||||
className="project-gallery__canvas-cover-image"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +167,7 @@ function formatProjectUpdatedAt(value: string) {
|
||||
}
|
||||
|
||||
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -94,18 +197,26 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
authUi?.openLoginModal(refreshProjects);
|
||||
return;
|
||||
}
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : '读取项目列表失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
}, [authUi]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects();
|
||||
}, [refreshProjects]);
|
||||
|
||||
const createProject = useCallback(() => {
|
||||
if (authUi && !authUi.user) {
|
||||
authUi.openLoginModal(createProject);
|
||||
return;
|
||||
}
|
||||
setErrorMessage(null);
|
||||
createEditorProject()
|
||||
.then((project) => onOpenProject(project.projectId))
|
||||
@@ -114,7 +225,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||
error instanceof Error ? error.message : '创建项目失败',
|
||||
);
|
||||
});
|
||||
}, [onOpenProject]);
|
||||
}, [authUi, onOpenProject]);
|
||||
|
||||
const closeSelectionMode = useCallback(() => {
|
||||
setIsSelectionMode(false);
|
||||
@@ -191,7 +302,6 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||
const projectCards = useMemo(
|
||||
() =>
|
||||
projects.map((project) => {
|
||||
const preview = resolveProjectPreview(project);
|
||||
const selected = selectedProjectIds.has(project.projectId);
|
||||
return (
|
||||
<article
|
||||
@@ -211,18 +321,22 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||
toggleProjectSelection(project.projectId);
|
||||
return;
|
||||
}
|
||||
if (authUi) {
|
||||
authUi.requireAuth(() => onOpenProject(project.projectId));
|
||||
return;
|
||||
}
|
||||
onOpenProject(project.projectId);
|
||||
}}
|
||||
aria-label={`打开项目${project.title}`}
|
||||
>
|
||||
<PlatformMediaFrame
|
||||
src={preview?.imageSrc}
|
||||
src={null}
|
||||
alt=""
|
||||
fallbackLabel="项目"
|
||||
aspect="standard"
|
||||
surface="bright"
|
||||
className="project-gallery__preview"
|
||||
fallbackContent={<span className="project-gallery__preview-empty" />}
|
||||
fallbackContent={<ProjectCanvasCover project={project} />}
|
||||
>
|
||||
{isSelectionMode ? (
|
||||
<span className="project-gallery__checkbox">
|
||||
@@ -279,6 +393,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||
}),
|
||||
[
|
||||
activeMenuProjectId,
|
||||
authUi,
|
||||
deleteProjects,
|
||||
isSelectionMode,
|
||||
onOpenProject,
|
||||
|
||||
Reference in New Issue
Block a user