Files
Endoleap/src/lib/canvas-geometry.ts
T

99 lines
7.0 KiB
TypeScript

/**
* 旧卡片/分区的二维交互几何。输入是显示节点及其起始几何,纯函数返回位置/尺寸/角度或锚点,
* 不修改节点、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}`;
}