合并图片画布素材分支

将 codex/editor-asset-library 合并到 dev-jenken

保留编辑器生成规范、角色形象和图标素材能力

补回画布布局轻量保存和小地图拖拽手感修复
This commit is contained in:
2026-06-16 17:08:28 +08:00
12 changed files with 1370 additions and 34 deletions

View File

@@ -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>

View File

@@ -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 />);

View File

@@ -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);