Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceBookViewport.ts
T
suzmii 85f0f6a57b 修复资源画本入场动画找不到终点卡片
进入子画布时若视口停在空白处,目标卡片不在画面内,FLIP 快照找不到终点会整段退化成淡入淡出,只剩标题栏有动画
新增 ensureResourceBookContentVisible:视口内没有可见卡片时,把离画面中心最近的卡片平移到画面中心,保持当前缩放不变
openResourceBookChild 计算入场视口后应用该修正,保证动画终点落在真实卡片上
补充 resourceBookViewport 用例覆盖已可见、就近居中与空栏目三种情况
2026-09-09 16:09:39 +08:00

191 lines
5.3 KiB
TypeScript

import {
type CanvasViewport,
MAX_SCALE,
MIN_SCALE,
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
export const DEFAULT_RESOURCE_BOOK_VIEWPORT: CanvasViewport = {
x: 0,
y: 0,
scale: 1,
};
/**
* Child resource books are infinite canvases. Do not shrink their initial
* card world below its logical 1:1 size just to fit every card at once; that
* makes the canvas look tiny and gives the user no useful pan space.
*/
export function keepResourceBookViewportAtReadableScale({
viewport,
bounds,
canvasSize,
padding = 16,
}: {
viewport: CanvasViewport;
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
padding?: number;
}): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
if (current.scale >= 1) {
return current;
}
const inset = Math.max(0, padding);
const availableWidth = Math.max(1, canvasSize.width - inset * 2);
const availableHeight = Math.max(1, canvasSize.height - inset * 2);
return {
scale: 1,
x: inset + availableWidth / 2 - (bounds.x + bounds.width / 2),
y: inset + availableHeight / 2 - (bounds.y + bounds.height / 2),
};
}
export function normalizeResourceBookViewport(
viewport: CanvasViewport | undefined,
): CanvasViewport {
return {
x: Number.isFinite(viewport?.x) ? viewport!.x : 0,
y: Number.isFinite(viewport?.y) ? viewport!.y : 0,
scale: Math.min(
MAX_SCALE,
Math.max(
MIN_SCALE,
Number.isFinite(viewport?.scale) ? viewport!.scale : 1,
),
),
};
}
/**
* 入场/返回动画要求目标卡片落在可视区域内,否则 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,
anchor: { x: number; y: number },
): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
const nextScale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, current.scale * factor),
);
if (nextScale === current.scale || !Number.isFinite(nextScale)) {
return current;
}
const ratio = nextScale / current.scale;
return {
scale: nextScale,
x: anchor.x - (anchor.x - current.x) * ratio,
y: anchor.y - (anchor.y - current.y) * ratio,
};
}
export function panResourceBookViewport(
viewport: CanvasViewport,
delta: { x: number; y: number },
): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
return {
...current,
x: current.x + (Number.isFinite(delta.x) ? delta.x : 0),
y: current.y + (Number.isFinite(delta.y) ? delta.y : 0),
};
}
export function resolveResourceBookWheelViewport({
viewport,
deltaX,
deltaY,
shiftKey,
ctrlKey,
metaKey,
screenPoint,
}: {
viewport: CanvasViewport;
deltaX: number;
deltaY: number;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
screenPoint: { x: number; y: number };
}) {
return resolveViewportFromWheel({
viewport: normalizeResourceBookViewport(viewport),
deltaX,
deltaY,
shiftKey,
ctrlKey,
metaKey,
screenPoint,
});
}