228 lines
5.8 KiB
TypeScript
228 lines
5.8 KiB
TypeScript
import type { Padding, SceneElement, Viewport } from './domino'
|
|
|
|
/**
|
|
* 计算元素移动到安全视野内的 Viewport 变换 (纯算法,无 DOM 依赖)
|
|
*/
|
|
export function calculateScrollIntoViewTransform(
|
|
element: SceneElement,
|
|
viewport: Viewport,
|
|
containerRect: { width: number; height: number },
|
|
padding: Required<Padding>,
|
|
options: {
|
|
force?: boolean
|
|
block?: 'start' | 'center' | 'end' | 'nearest'
|
|
inline?: 'start' | 'center' | 'end' | 'nearest'
|
|
targetScale?: number
|
|
},
|
|
): Viewport | null {
|
|
const { x: currentX, y: currentY, scale: currentScale } = viewport
|
|
const { force, block = 'nearest', inline = 'nearest', targetScale } = options
|
|
const scale = targetScale ?? currentScale
|
|
|
|
// 计算安全区域
|
|
const safeLeft = padding.left
|
|
const safeTop = padding.top
|
|
const safeRight = containerRect.width - padding.right
|
|
const safeBottom = containerRect.height - padding.bottom
|
|
const safeWidth = safeRight - safeLeft
|
|
const safeHeight = safeBottom - safeTop
|
|
|
|
// 元素即时边界
|
|
const elW = element.width * scale
|
|
const elH = element.height * scale
|
|
const elLeft = element.x * scale + currentX
|
|
const elRight = elLeft + elW
|
|
const elTop = element.y * scale + currentY
|
|
const elBottom = elTop + elH
|
|
|
|
const isXOutOfView = force || elLeft < safeLeft || elRight > safeRight
|
|
const isYOutOfView = force || elTop < safeTop || elBottom > safeBottom
|
|
|
|
let nextX = currentX
|
|
let nextY = currentY
|
|
|
|
if (isXOutOfView) {
|
|
if (inline === 'center') {
|
|
nextX = safeLeft + (safeWidth - elW) / 2 - element.x * scale
|
|
} else if (
|
|
inline === 'start' ||
|
|
(inline === 'nearest' && elLeft < safeLeft)
|
|
) {
|
|
nextX = safeLeft - element.x * scale
|
|
} else if (
|
|
inline === 'end' ||
|
|
(inline === 'nearest' && elRight > safeRight)
|
|
) {
|
|
nextX = safeRight - elW - element.x * scale
|
|
}
|
|
}
|
|
|
|
if (isYOutOfView) {
|
|
if (block === 'center') {
|
|
nextY = safeTop + (safeHeight - elH) / 2 - element.y * scale
|
|
} else if (block === 'start' || (block === 'nearest' && elTop < safeTop)) {
|
|
nextY = safeTop - element.y * scale
|
|
} else if (
|
|
block === 'end' ||
|
|
(block === 'nearest' && elBottom > safeBottom)
|
|
) {
|
|
nextY = safeBottom - elH - element.y * scale
|
|
}
|
|
}
|
|
|
|
const hasChange =
|
|
nextX !== currentX ||
|
|
nextY !== currentY ||
|
|
(targetScale !== undefined && targetScale !== currentScale)
|
|
|
|
return hasChange ? { x: nextX, y: nextY, scale } : null
|
|
}
|
|
|
|
/**
|
|
* 计算适应屏幕的 Viewport 变换 (纯算法,无 DOM 依赖)
|
|
*/
|
|
export function calculateZoomToFitTransform(
|
|
elements: SceneElement[],
|
|
containerRect: { width: number; height: number },
|
|
padding: Required<Padding>,
|
|
): Viewport | null {
|
|
if (elements.length === 0) return null
|
|
|
|
let minX = Infinity
|
|
let minY = Infinity
|
|
let maxX = -Infinity
|
|
let maxY = -Infinity
|
|
|
|
elements.forEach(el => {
|
|
minX = Math.min(minX, el.x)
|
|
minY = Math.min(minY, el.y)
|
|
maxX = Math.max(maxX, el.x + el.width)
|
|
maxY = Math.max(maxY, el.y + el.height)
|
|
})
|
|
|
|
const contentWidth = maxX - minX
|
|
const contentHeight = maxY - minY
|
|
|
|
const safeWidth = containerRect.width - padding.left - padding.right
|
|
const safeHeight = containerRect.height - padding.top - padding.bottom
|
|
|
|
if (safeWidth <= 0 || safeHeight <= 0) return null
|
|
|
|
const scaleX = safeWidth / contentWidth
|
|
const scaleY = safeHeight / contentHeight
|
|
const targetScale = Math.max(
|
|
0.02,
|
|
Math.min(4, Math.min(scaleX, scaleY) * 0.95),
|
|
)
|
|
|
|
const nextX =
|
|
padding.left +
|
|
(safeWidth - contentWidth * targetScale) / 2 -
|
|
minX * targetScale
|
|
const nextY =
|
|
padding.top +
|
|
(safeHeight - contentHeight * targetScale) / 2 -
|
|
minY * targetScale
|
|
|
|
return { x: nextX, y: nextY, scale: targetScale }
|
|
}
|
|
|
|
/**
|
|
* 计算元素的绝对位置和旋转 (世界坐标)
|
|
*/
|
|
export function getElementWorldRect(
|
|
element: SceneElement,
|
|
elements: Record<string, SceneElement>,
|
|
) {
|
|
let x = element.x
|
|
let y = element.y
|
|
let rotation = element.rotation || 0
|
|
let curr = element
|
|
|
|
const visited = new Set<string>([element.id])
|
|
|
|
while (curr.parentId) {
|
|
if (visited.has(curr.parentId)) {
|
|
console.warn(
|
|
'Circular dependency detected in element tree',
|
|
curr.parentId,
|
|
)
|
|
break
|
|
}
|
|
|
|
const parent = elements[curr.parentId]
|
|
if (!parent) break
|
|
|
|
x += parent.x
|
|
y += parent.y
|
|
rotation += parent.rotation || 0
|
|
|
|
visited.add(curr.parentId)
|
|
curr = parent
|
|
}
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
width: element.width,
|
|
height: element.height,
|
|
rotation,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 计算一组元素的包围盒 (考虑旋转和父级偏移)
|
|
*/
|
|
export function getElementsBounds(
|
|
ids: string[],
|
|
elements: Record<string, SceneElement>,
|
|
) {
|
|
if (ids.length === 0) return null
|
|
|
|
let minX = Infinity
|
|
let minY = Infinity
|
|
let maxX = -Infinity
|
|
let maxY = -Infinity
|
|
|
|
ids.forEach(id => {
|
|
const rawEl = elements[id]
|
|
if (!rawEl) return
|
|
|
|
const el = getElementWorldRect(rawEl, elements)
|
|
|
|
const rad = (el.rotation * Math.PI) / 180
|
|
const cx = el.x + el.width / 2
|
|
const cy = el.y + el.height / 2
|
|
const corners = [
|
|
[-el.width / 2, -el.height / 2],
|
|
[el.width / 2, -el.height / 2],
|
|
[el.width / 2, el.height / 2],
|
|
[-el.width / 2, el.height / 2],
|
|
]
|
|
|
|
corners.forEach(([px, py]) => {
|
|
const rx = px * Math.cos(rad) - py * Math.sin(rad)
|
|
const ry = px * Math.sin(rad) + py * Math.cos(rad)
|
|
const wx = rx + cx
|
|
const wy = ry + cy
|
|
minX = Math.min(minX, wx)
|
|
minY = Math.min(minY, wy)
|
|
maxX = Math.max(maxX, wx)
|
|
maxY = Math.max(maxY, wy)
|
|
})
|
|
})
|
|
|
|
if (minX === Infinity) return null
|
|
|
|
return {
|
|
left: minX,
|
|
top: minY,
|
|
width: maxX - minX,
|
|
height: maxY - minY,
|
|
right: maxX,
|
|
bottom: maxY,
|
|
centerX: (minX + maxX) / 2,
|
|
centerY: (minY + maxY) / 2,
|
|
}
|
|
}
|