85f0f6a57b
进入子画布时若视口停在空白处,目标卡片不在画面内,FLIP 快照找不到终点会整段退化成淡入淡出,只剩标题栏有动画 新增 ensureResourceBookContentVisible:视口内没有可见卡片时,把离画面中心最近的卡片平移到画面中心,保持当前缩放不变 openResourceBookChild 计算入场视口后应用该修正,保证动画终点落在真实卡片上 补充 resourceBookViewport 用例覆盖已可见、就近居中与空栏目三种情况
191 lines
5.3 KiB
TypeScript
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,
|
|
});
|
|
}
|