/** * 旧卡片/分区的二维交互几何。输入是显示节点及其起始几何,纯函数返回位置/尺寸/角度或锚点, * 不修改节点、history或DOM;新版registry几何协议与旧布局约定分开管理。 */ import type {CanvasGeometryNode as CanvasNode} from "./canvas-bindings/compat-types.ts"; /** 旧几何计算的二维点/向量,具体坐标空间由调用者保证一致。 */ type XYPosition={x:number;y:number}; /** 八向缩放手柄,边手柄只影响一轴,角手柄同时影响两轴。 */ export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" | "top-right" | "bottom-left" | "bottom-right"; /** 返回的几何修改提案,可包含位置、尺寸、旋转或字号,不直接应用到文档。 */ export type NodeTransform = { position?: XYPosition; width?: number; height?: number; rotation?: number; fontSize?: number }; /** 旧尺寸优先级:显式width/height→可转数字style尺寸→measured→按kind默认值。 * 使用||回退,零值视作未就绪;不是完整的几何有效性验证器。 */ export function nodeSize(node: CanvasNode) { return { width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "frame" ? 640 : node.data.kind === "plain-image" ? 320 : node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 240 : 260), height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "frame" ? 400 : node.data.kind === "plain-image" ? 240 : node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 72 : 200), }; } /** 把有限角度按360度周期规约到[0,360),负数也归入正向区间;输入合法性由调用方负责。 */ export function normalizeRotation(angle: number) { return ((angle % 360) + 360) % 360; } /** 围绕原点旋转向量,角度为度,公式内换弧度;平移量不能混进此函数。 */ export function rotateVector(point: XYPosition, angle: number): XYPosition { const radians = angle * Math.PI / 180; return { x: point.x * Math.cos(radians) - point.y * Math.sin(radians), y: point.x * Math.sin(radians) + point.y * Math.cos(radians) }; } /** 用未旋转布局矩形求中心,旋转仍围绕这个中心,不改其位置。 */ export function nodeCenter(node: CanvasNode): XYPosition { const { width, height } = nodeSize(node); return { x: node.position.x + width / 2, y: node.position.y + height / 2 }; } /** 比较起始/当前指针相对中心的极角并叠加原旋转;snap时吸附到15度,最后归一化。 */ export function rotationFromPointer(node: CanvasNode, start: XYPosition, current: XYPosition, snap = false) { const center = nodeCenter(node); const startAngle = Math.atan2(start.y - center.y, start.x - center.x); const angle = Math.atan2(current.y - center.y, current.x - center.x); let rotation = (node.data.rotation || 0) + (angle - startAngle) * 180 / Math.PI; if (snap) rotation = Math.round(rotation / 15) * 15; return normalizeRotation(rotation); } /** 把与节点同坐标空间的delta反旋到节点局部轴,按手柄方向缩放,再旋回中心位移保持对侧锚定。 * 原节点应是手势起点快照,不能每次用上一帧结果继续累计同一个总delta。 */ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition, lockAspect?: boolean): NodeTransform { const { width, height } = nodeSize(node); const rotation = node.data.rotation || 0; // 指针位移先消除节点旋转,才能区分拖动的是本地宽还是高。 const local = rotateVector(delta, -rotation); const horizontal = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0; const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0; const plain = node.data.kind === "plain-text"; const icon = node.data.kind === "icon" || node.data.kind === "plain-image"; const viewScale = node.data.viewScale || 1; // 约束随显示viewScale缩放;frame可更大,普通卡片/文字/图标最小尺寸各有不同。 const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale; const minWidth = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 60 : 140) * viewScale; const minHeight = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 24 : 100) * viewScale; let nextWidth = Math.max(minWidth, Math.min(maximum, width + horizontal * local.x)); let nextHeight = Math.max(minHeight, Math.min(maximum, height + vertical * local.y)); // icon和纯图片默认锁比例,调用方可显式开关;取偏离1更大的缩放轴,结合双边范围算候选,最终尺寸仍经命令/格式校验。 if (lockAspect ?? icon) { const sx = 1 + horizontal * local.x / width; const sy = 1 + vertical * local.y / height; let scale = !horizontal ? sy : !vertical ? sx : Math.abs(sx - 1) > Math.abs(sy - 1) ? sx : sy; scale = Math.max(minWidth / width, minHeight / height, Math.min(scale, maximum / width, maximum / height)); nextWidth = width * scale; nextHeight = height * scale; } const center = nodeCenter(node); // 半尺寸差在局部轴上移动中心,再旋回场景,避免只改宽高导致固定对边漂移。 const shift = rotateVector({ x: horizontal * (nextWidth - width) / 2, y: vertical * (nextHeight - height) / 2 }, rotation); return { position: { x: center.x + shift.x - nextWidth / 2, y: center.y + shift.y - nextHeight / 2 }, width: nextWidth, height: nextHeight, }; } /** 卡片四边连接位置名,与模型中可扩展端口ID不是同一概念。 */ export type ConnectorSide = "top" | "right" | "bottom" | "left"; /** 锚点位置与向外单位法线,供贝塞尔控制点计算。 */ export type Connector = { point: XYPosition; normal: XYPosition }; /** Attach to the card border itself, independent of the handle's hit area. */ /** 在卡片真实边框中点取点并随节点旋转法线/偏移,忽略DOM手柄较大的点击热区。 */ export function nodeConnector(node: CanvasNode, side: ConnectorSide): Connector { const { width, height } = nodeSize(node); const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } }; const local = vectors[side]; const angle = node.data.rotation || 0; const normal = rotateVector(local, angle); const offset = rotateVector({ x: local.x * width / 2, y: local.y * height / 2 }, angle); const center = nodeCenter(node); return { point: { x: center.x + offset.x, y: center.y + offset.y }, normal }; } /** 用两端朝外法线构造单段SVG三次曲线,控制伸展按端距40%并夹到40~240显示单位。 */ export function connectorBezier(from: Connector, to: Connector) { const reach = Math.min(240, Math.max(40, Math.hypot(to.point.x - from.point.x, to.point.y - from.point.y) * 0.4)); return `M ${from.point.x},${from.point.y} C ${from.point.x + from.normal.x * reach},${from.point.y + from.normal.y * reach} ${to.point.x + to.normal.x * reach},${to.point.y + to.normal.y * reach} ${to.point.x},${to.point.y}`; }