WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
3 changed files with 131 additions and 1 deletions
Showing only changes of commit 85f0f6a57b - Show all commits
@@ -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);
});
});