优化项目封面加载与画布吸附
项目页封面和画布图层增加轻量加载态,图片解码不阻塞外框文字。 缩小等距吸附候选范围,降低多素材拖拽热路径开销。 补充项目封面、画布加载、吸附性能测试和文档说明。
This commit is contained in:
@@ -707,4 +707,59 @@ describe('ImageCanvasEditorModel', () => {
|
||||
horizontal: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps equal-spacing snap focused on nearby overlapping layers', () => {
|
||||
const movingLayer: CanvasLayer = {
|
||||
id: 'moving',
|
||||
resourceId: 'resource-moving',
|
||||
title: '移动图',
|
||||
src: 'data:image/png;base64,moving',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 80,
|
||||
originalWidth: 80,
|
||||
originalHeight: 80,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
};
|
||||
const firstLayer: CanvasLayer = {
|
||||
...movingLayer,
|
||||
id: 'first',
|
||||
resourceId: 'resource-first',
|
||||
x: 100,
|
||||
y: 160,
|
||||
zIndex: 2,
|
||||
};
|
||||
const secondLayer: CanvasLayer = {
|
||||
...movingLayer,
|
||||
id: 'second',
|
||||
resourceId: 'resource-second',
|
||||
x: 260,
|
||||
y: 160,
|
||||
zIndex: 3,
|
||||
};
|
||||
const offscreenLayers = Array.from({ length: 80 }, (_, index) => ({
|
||||
...movingLayer,
|
||||
id: `offscreen-${index}`,
|
||||
resourceId: `resource-offscreen-${index}`,
|
||||
x: 2400 + index * 96,
|
||||
y: 1600,
|
||||
zIndex: 10 + index,
|
||||
}));
|
||||
|
||||
const snapped = resolveSnappedLayerPosition(
|
||||
movingLayer,
|
||||
417,
|
||||
160,
|
||||
[movingLayer, ...offscreenLayers, firstLayer, secondLayer],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(snapped.x).toBe(420);
|
||||
expect(snapped.guide).toEqual({
|
||||
vertical: 460,
|
||||
horizontal: 160,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ export const TOOLBAR_HALF_WIDTH = 132;
|
||||
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
||||
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||||
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
|
||||
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
|
||||
export const FIT_VIEW_PADDING = 10;
|
||||
export const MINIMAP_SIZE = { width: 132, height: 84 };
|
||||
export const MINIMAP_PADDING = 8;
|
||||
@@ -1291,10 +1292,19 @@ function findNearestEqualSpacingSnap({
|
||||
items: CanvasSnapItem[];
|
||||
threshold: number;
|
||||
}): SnapCandidate | null {
|
||||
const orderedItems = [...items].sort(
|
||||
(firstItem, secondItem) =>
|
||||
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
||||
);
|
||||
const orderedItems = items
|
||||
.filter((item) =>
|
||||
snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
item,
|
||||
axis,
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(firstItem, secondItem) =>
|
||||
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
||||
);
|
||||
let nearest: SnapCandidate | null = null;
|
||||
|
||||
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
|
||||
@@ -1304,29 +1314,14 @@ function findNearestEqualSpacingSnap({
|
||||
}
|
||||
for (
|
||||
let secondIndex = firstIndex + 1;
|
||||
secondIndex < orderedItems.length;
|
||||
secondIndex < orderedItems.length &&
|
||||
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
|
||||
secondIndex += 1
|
||||
) {
|
||||
const secondItem = orderedItems[secondIndex];
|
||||
if (!secondItem) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
firstItem,
|
||||
axis,
|
||||
) ||
|
||||
!snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
secondItem,
|
||||
axis,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstStart = getSnapItemStart(firstItem, axis);
|
||||
const firstEnd = getSnapItemEnd(firstItem, axis);
|
||||
|
||||
@@ -130,6 +130,23 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(screen.queryByRole('status', { name: '' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the image layer frame visible while the image is loading', () => {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(() => ({
|
||||
resolvedUrl: '',
|
||||
isResolving: true,
|
||||
shouldResolve: true,
|
||||
}));
|
||||
|
||||
renderWorldView();
|
||||
|
||||
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
|
||||
|
||||
expect(
|
||||
layerButton.querySelector('.image-canvas-editor__layer-image-loading'),
|
||||
).toBeTruthy();
|
||||
expect(within(layerButton).queryByAltText('画布图片:角色主图')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves image layers from objectKey before falling back to src', () => {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(
|
||||
(_source: string, options?: { objectKey?: string | null }) => ({
|
||||
|
||||
@@ -629,17 +629,48 @@ function ImageCanvasImageLayer({
|
||||
layer: CanvasLayer;
|
||||
mediaTransform?: string;
|
||||
}) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, {
|
||||
objectKey: layer.objectKey,
|
||||
refreshKey: layer.taskId ?? layer.resourceId,
|
||||
});
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
|
||||
layer.src,
|
||||
{
|
||||
objectKey: layer.objectKey,
|
||||
refreshKey: layer.taskId ?? layer.resourceId,
|
||||
},
|
||||
);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
const isLoading =
|
||||
isResolving ||
|
||||
(shouldResolve && !resolvedUrl) ||
|
||||
Boolean(resolvedUrl && !isLoaded);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedUrl || undefined}
|
||||
alt={`画布图片:${layer.title}`}
|
||||
style={{ transform: mediaTransform }}
|
||||
/>
|
||||
<>
|
||||
{isLoading ? (
|
||||
<span
|
||||
className="image-canvas-editor__layer-image-loading"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{resolvedUrl ? (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt={`画布图片:${layer.title}`}
|
||||
decoding="async"
|
||||
className={
|
||||
isLoaded
|
||||
? 'image-canvas-editor__layer-image--loaded'
|
||||
: 'image-canvas-editor__layer-image--loading'
|
||||
}
|
||||
style={{ transform: mediaTransform }}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={() => setIsLoaded(true)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -671,17 +702,23 @@ function ImageCanvasImageSequenceLayer({
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(frames.length > 1);
|
||||
const currentFrame = frames[Math.min(frameIndex, frames.length - 1)];
|
||||
const hasFrameSource = Boolean(currentFrame?.imageSrc?.trim());
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(currentFrame?.imageSrc, {
|
||||
refreshKey: `${layer.taskId ?? layer.resourceId}:${
|
||||
currentFrame?.frameIndex ?? frameIndex + 1
|
||||
}`,
|
||||
});
|
||||
const [isFrameLoaded, setIsFrameLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex(0);
|
||||
setIsPlaying(frames.length > 1);
|
||||
}, [layer.taskId, layer.resourceId, frames.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFrameLoaded(false);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || frames.length <= 1) {
|
||||
return undefined;
|
||||
@@ -702,12 +739,27 @@ function ImageCanvasImageSequenceLayer({
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={resolvedUrl || undefined}
|
||||
alt={`画布序列帧:${layer.title}`}
|
||||
className="image-canvas-editor__layer-image-sequence-frame"
|
||||
style={{ transform: mediaTransform }}
|
||||
/>
|
||||
{hasFrameSource && (!resolvedUrl || !isFrameLoaded) ? (
|
||||
<span
|
||||
className="image-canvas-editor__layer-image-loading"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{resolvedUrl ? (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt={`画布序列帧:${layer.title}`}
|
||||
decoding="async"
|
||||
className={`image-canvas-editor__layer-image-sequence-frame ${
|
||||
isFrameLoaded
|
||||
? 'image-canvas-editor__layer-image--loaded'
|
||||
: 'image-canvas-editor__layer-image--loading'
|
||||
}`}
|
||||
style={{ transform: mediaTransform }}
|
||||
onLoad={() => setIsFrameLoaded(true)}
|
||||
onError={() => setIsFrameLoaded(true)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="image-canvas-editor__image-sequence-controls"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import type {
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
export const PROJECT_CANVAS_COVER_SIZE = { width: 320, height: 240 } as const;
|
||||
export const PROJECT_CANVAS_COVER_VIEWPORT_SIZE = {
|
||||
@@ -15,6 +17,8 @@ export type ProjectCanvasCoverLayer = {
|
||||
layerId: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
objectKey: string | null;
|
||||
refreshKey: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -67,7 +71,8 @@ export function resolveProjectCanvasCoverLayers(
|
||||
.filter((layer) => !isLayerHidden(layer))
|
||||
.map((layer) => {
|
||||
const resource = resourcesById.get(layer.resourceId);
|
||||
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||
const imageSrc =
|
||||
stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
@@ -75,10 +80,26 @@ export function resolveProjectCanvasCoverLayers(
|
||||
layerId: layer.layerId,
|
||||
title: stringFromLayer(layer, 'title') || '画布图片',
|
||||
imageSrc,
|
||||
objectKey:
|
||||
stringFromLayer(layer, 'objectKey') ||
|
||||
resource?.objectKey?.trim() ||
|
||||
null,
|
||||
refreshKey:
|
||||
stringFromLayer(layer, 'taskId') ||
|
||||
resource?.taskId?.trim() ||
|
||||
resource?.updatedAt?.trim() ||
|
||||
resource?.resourceId ||
|
||||
null,
|
||||
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)),
|
||||
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 ProjectCanvasCoverLayer;
|
||||
})
|
||||
@@ -86,6 +107,60 @@ export function resolveProjectCanvasCoverLayers(
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
}
|
||||
|
||||
function ProjectCanvasCoverImage({
|
||||
layer,
|
||||
className,
|
||||
}: {
|
||||
layer: ProjectCanvasCoverLayer;
|
||||
className?: string;
|
||||
}) {
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
|
||||
layer.imageSrc,
|
||||
{
|
||||
objectKey: layer.objectKey,
|
||||
refreshKey: layer.refreshKey,
|
||||
},
|
||||
);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
const isLoading =
|
||||
isResolving ||
|
||||
(shouldResolve && !resolvedUrl) ||
|
||||
Boolean(resolvedUrl && !isLoaded);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<span
|
||||
className="project-gallery__canvas-cover-loading"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{resolvedUrl ? (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={joinClassNames(
|
||||
'project-gallery__canvas-cover-image',
|
||||
isLoaded
|
||||
? 'project-gallery__canvas-cover-image--loaded'
|
||||
: 'project-gallery__canvas-cover-image--loading',
|
||||
className,
|
||||
)}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={() => setIsLoaded(true)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectCanvasCover({
|
||||
project,
|
||||
classNames,
|
||||
@@ -126,7 +201,10 @@ export function ProjectCanvasCover({
|
||||
|
||||
return (
|
||||
<span
|
||||
className={joinClassNames('project-gallery__canvas-cover', classNames?.root)}
|
||||
className={joinClassNames(
|
||||
'project-gallery__canvas-cover',
|
||||
classNames?.root,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coverLayers.map((layer) => (
|
||||
@@ -144,14 +222,7 @@ export function ProjectCanvasCover({
|
||||
zIndex: layer.zIndex,
|
||||
}}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={layer.imageSrc}
|
||||
alt=""
|
||||
className={joinClassNames(
|
||||
'project-gallery__canvas-cover-image',
|
||||
classNames?.image,
|
||||
)}
|
||||
/>
|
||||
<ProjectCanvasCoverImage layer={layer} className={classNames?.image} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -178,6 +178,9 @@ describe('ProjectGalleryView', () => {
|
||||
|
||||
expect(cover).toBeTruthy();
|
||||
expect(coverLayers).toHaveLength(2);
|
||||
expect(
|
||||
document.querySelector('.project-gallery__canvas-cover-loading'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,back');
|
||||
|
||||
@@ -3079,6 +3079,61 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 23, 42, 0.06), transparent 56%),
|
||||
#f3f4f6;
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover-layer {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
border-radius: 0.18rem;
|
||||
background: rgba(229, 231, 235, 0.86);
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover-loading,
|
||||
.image-canvas-editor__layer-image-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.62),
|
||||
transparent
|
||||
),
|
||||
rgba(226, 232, 240, 0.72);
|
||||
animation: image-canvas-generation-scan 1.45s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover-image--loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover-image--loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-gallery__preview-empty {
|
||||
width: 36%;
|
||||
aspect-ratio: 1;
|
||||
@@ -5141,6 +5196,27 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-image-loading {
|
||||
z-index: 0;
|
||||
border-radius: 0.1rem;
|
||||
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-image--loading,
|
||||
.image-canvas-editor__layer-image--loaded {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-image--loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-image--loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__layer-video-preview {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user