WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -107,6 +107,7 @@ import {
|
||||
import { ResourceBookTransitionLayer } from './ResourceBookTransitionLayer';
|
||||
import {
|
||||
DEFAULT_RESOURCE_BOOK_VIEWPORT,
|
||||
ensureResourceBookContentVisible,
|
||||
keepResourceBookViewportAtReadableScale,
|
||||
normalizeResourceBookViewport,
|
||||
panResourceBookViewport,
|
||||
@@ -118,6 +119,8 @@ import {
|
||||
createResourceCanvasCardSizeByResourceId,
|
||||
fitResourceCanvasViewportToContent,
|
||||
normalizeInfiniteResourceCanvasViewport,
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
RESOURCE_CANVAS_DRAG_THRESHOLD,
|
||||
RESOURCE_CANVAS_FIT_PADDING,
|
||||
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||||
@@ -2549,7 +2552,7 @@ export default function ProjectDevelopmentView({
|
||||
resourcePositionsByCategory.get(category) ?? [],
|
||||
resourceCardSizeByResourceId,
|
||||
);
|
||||
const categoryViewport = resourceCanvasFitKeysRef.current.has(
|
||||
const fittedCategoryViewport = resourceCanvasFitKeysRef.current.has(
|
||||
categoryFitKey,
|
||||
)
|
||||
? resourceCanvasViewportTargetsRef.current[sortMode][category]
|
||||
@@ -2564,6 +2567,24 @@ export default function ProjectDevelopmentView({
|
||||
canvasSize: fitCanvasSize,
|
||||
padding: RESOURCE_CANVAS_FIT_PADDING,
|
||||
});
|
||||
// 入场动画要落在真实卡片上;视口停在空白处时,先把最近的卡片带进画面。
|
||||
const categoryViewport = ensureResourceBookContentVisible({
|
||||
viewport: fittedCategoryViewport,
|
||||
cards: (resourcePositionsByCategory.get(category) ?? []).map(
|
||||
(position) => ({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width:
|
||||
resourceCardSizeByResourceId.get(position.resourceId)?.width ??
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
height:
|
||||
resourceCardSizeByResourceId.get(position.resourceId)?.height ??
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
}),
|
||||
),
|
||||
canvasSize: fitCanvasSize,
|
||||
padding: RESOURCE_CANVAS_FIT_PADDING,
|
||||
});
|
||||
|
||||
// Opening a child explicitly establishes the initial viewport for this
|
||||
// sort/category pair. Mark that fit as settled so the paged-canvas
|
||||
|
||||
@@ -57,6 +57,77 @@ export function normalizeResourceBookViewport(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 入场/返回动画要求目标卡片落在可视区域内,否则 FLIP 找不到终点,整段动画
|
||||
* 会退化成淡入淡出。视口若停在空白处,就把离画面中心最近的卡片平移到中心,
|
||||
* 保持当前缩放不变,避免顺手改变用户的缩放预期。
|
||||
*/
|
||||
export function ensureResourceBookContentVisible({
|
||||
viewport,
|
||||
cards,
|
||||
canvasSize,
|
||||
padding = 16,
|
||||
}: {
|
||||
viewport: CanvasViewport;
|
||||
cards: readonly {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
canvasSize: { width: number; height: number };
|
||||
padding?: number;
|
||||
}): CanvasViewport {
|
||||
const current = normalizeResourceBookViewport(viewport);
|
||||
if (
|
||||
cards.length === 0 ||
|
||||
!Number.isFinite(canvasSize.width) ||
|
||||
!Number.isFinite(canvasSize.height) ||
|
||||
canvasSize.width <= 0 ||
|
||||
canvasSize.height <= 0
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const inset = Math.max(0, padding);
|
||||
const viewportRect = {
|
||||
left: inset,
|
||||
top: inset,
|
||||
right: Math.max(inset + 1, canvasSize.width - inset),
|
||||
bottom: Math.max(inset + 1, canvasSize.height - inset),
|
||||
};
|
||||
const screenRects = cards.map((card) => ({
|
||||
card,
|
||||
left: current.x + card.x * current.scale,
|
||||
top: current.y + card.y * current.scale,
|
||||
width: Math.max(1, card.width * current.scale),
|
||||
height: Math.max(1, card.height * current.scale),
|
||||
}));
|
||||
const intersectsViewport = (rect: (typeof screenRects)[number]) =>
|
||||
rect.left < viewportRect.right &&
|
||||
rect.left + rect.width > viewportRect.left &&
|
||||
rect.top < viewportRect.bottom &&
|
||||
rect.top + rect.height > viewportRect.top;
|
||||
if (screenRects.some(intersectsViewport)) {
|
||||
return current;
|
||||
}
|
||||
const centerX = canvasSize.width / 2;
|
||||
const centerY = canvasSize.height / 2;
|
||||
const nearest = screenRects.reduce((closest, candidate) => {
|
||||
const closestDistance =
|
||||
Math.abs(closest.left + closest.width / 2 - centerX) +
|
||||
Math.abs(closest.top + closest.height / 2 - centerY);
|
||||
const candidateDistance =
|
||||
Math.abs(candidate.left + candidate.width / 2 - centerX) +
|
||||
Math.abs(candidate.top + candidate.height / 2 - centerY);
|
||||
return candidateDistance < closestDistance ? candidate : closest;
|
||||
});
|
||||
return {
|
||||
scale: current.scale,
|
||||
x: centerX - (nearest.card.x + nearest.card.width / 2) * current.scale,
|
||||
y: centerY - (nearest.card.y + nearest.card.height / 2) * current.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function zoomResourceBookViewport(
|
||||
viewport: CanvasViewport,
|
||||
factor: number,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureResourceBookContentVisible,
|
||||
keepResourceBookViewportAtReadableScale,
|
||||
panResourceBookViewport,
|
||||
} from '../src/view/project-development/resourceBookViewport';
|
||||
@@ -29,4 +30,41 @@ describe('resource book viewport', () => {
|
||||
),
|
||||
).toEqual({ x: -4_000, y: 3_000, scale: 1 });
|
||||
});
|
||||
|
||||
it('keeps a viewport that already shows a card', () => {
|
||||
const viewport = { x: -40, y: -20, scale: 1 };
|
||||
expect(
|
||||
ensureResourceBookContentVisible({
|
||||
viewport,
|
||||
cards: [{ x: 100, y: 100, width: 180, height: 128 }],
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
padding: 16,
|
||||
}),
|
||||
).toEqual(viewport);
|
||||
});
|
||||
|
||||
it('centers the nearest card when the viewport would show nothing', () => {
|
||||
expect(
|
||||
ensureResourceBookContentVisible({
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
cards: [
|
||||
{ x: 4_000, y: 4_000, width: 180, height: 128 },
|
||||
{ x: 2_000, y: 2_000, width: 180, height: 128 },
|
||||
],
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
padding: 16,
|
||||
}),
|
||||
).toEqual({ x: -1_640, y: -1_744, scale: 1 });
|
||||
});
|
||||
|
||||
it('leaves an empty category viewport untouched', () => {
|
||||
const viewport = { x: 12, y: 34, scale: 1.5 };
|
||||
expect(
|
||||
ensureResourceBookContentVisible({
|
||||
viewport,
|
||||
cards: [],
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual(viewport);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user