fix(editor): connector target position NaN (#11606)

Close [BS-3086](https://linear.app/affine-design/issue/BS-3086/frame里套frame,连一下connector,拖两下,白板损坏)

### What Changes
- Fixed `bound.toRelative` may be return `NaN` when `bound.w === 0 || bound.h ===0`
- Remove type assertions from `connector-manager.ts` for more type safety
This commit is contained in:
L-Sun
2025-04-10 12:33:24 +00:00
parent d5aebc1421
commit 588659ef67
5 changed files with 109 additions and 59 deletions

View File

@@ -12,7 +12,6 @@ import type { IBound, IVec, IVec3 } from '@blocksuite/global/gfx';
import { import {
almostEqual, almostEqual,
Bound, Bound,
clamp,
getBezierCurveBoundingBox, getBezierCurveBoundingBox,
getBezierParameters, getBezierParameters,
getBoundFromPoints, getBoundFromPoints,
@@ -85,7 +84,7 @@ export function calculateNearestLocation(
) { ) {
const { x, y, w, h } = bounds; const { x, y, w, h } = bounds;
return locations return locations
.map(offset => [x + offset[0] * w, y + offset[1] * h] as IVec) .map<IVec>(offset => [x + offset[0] * w, y + offset[1] * h])
.map(point => getPointFromBoundsWithRotation(bounds, point)) .map(point => getPointFromBoundsWithRotation(bounds, point))
.reduce( .reduce(
(prev, curr, index) => { (prev, curr, index) => {
@@ -99,7 +98,7 @@ export function calculateNearestLocation(
return prev; return prev;
}, },
[...locations[0]] [...locations[0]]
) as IVec; );
} }
function rBound(ele: GfxModel, anti = false): IBound { function rBound(ele: GfxModel, anti = false): IBound {
@@ -139,21 +138,19 @@ export function getAnchors(ele: GfxModel) {
const anchors: { point: PointLocation; coord: IVec }[] = []; const anchors: { point: PointLocation; coord: IVec }[] = [];
const rotate = ele.rotate; const rotate = ele.rotate;
[ (
[bound.center[0], bound.y - offset], [
[bound.center[0], bound.maxY + offset], [bound.center[0], bound.y - offset],
[bound.x - offset, bound.center[1]], [bound.center[0], bound.maxY + offset],
[bound.maxX + offset, bound.center[1]], [bound.x - offset, bound.center[1]],
] [bound.maxX + offset, bound.center[1]],
.map(vec => ] satisfies IVec[]
getPointFromBoundsWithRotation({ ...bound, rotate }, vec as IVec) )
) .map(vec => getPointFromBoundsWithRotation({ ...bound, rotate }, vec))
.forEach(vec => { .forEach(vec => {
const rst = ele.getLineIntersections(bound.center as IVec, vec as IVec); const rst = ele.getLineIntersections(bound.center, vec);
if (!rst) { if (!rst) return;
console.error(`Failed to get line intersections for ${ele.id}`);
return;
}
const originPoint = getPointFromBoundsWithRotation( const originPoint = getPointFromBoundsWithRotation(
{ ...bound, rotate: -rotate }, { ...bound, rotate: -rotate },
rst[0] rst[0]
@@ -164,7 +161,7 @@ export function getAnchors(ele: GfxModel) {
} }
function getConnectableRelativePosition(connectable: GfxModel, position: IVec) { function getConnectableRelativePosition(connectable: GfxModel, position: IVec) {
const location = connectable.getRelativePointLocation(position as IVec); const location = connectable.getRelativePointLocation(position);
if (isVecZero(Vec.sub(position, [0, 0.5]))) if (isVecZero(Vec.sub(position, [0, 0.5])))
location.tangent = Vec.rot([0, -1], toRadian(connectable.rotate)); location.tangent = Vec.rot([0, -1], toRadian(connectable.rotate));
else if (isVecZero(Vec.sub(position, [1, 0.5]))) else if (isVecZero(Vec.sub(position, [1, 0.5])))
@@ -184,7 +181,11 @@ export function getNearestConnectableAnchor(ele: Connectable, point: IVec) {
); );
} }
function closestPoint(points: PointLocation[], point: IVec) { function closestPoint(
points: PointLocation[],
point: IVec
): PointLocation | null {
if (points.length === 0) return null;
const rst = points.map(p => ({ p, d: Vec.dist(p, point) })); const rst = points.map(p => ({ p, d: Vec.dist(p, point) }));
rst.sort((a, b) => a.d - b.d); rst.sort((a, b) => a.d - b.d);
return rst[0].p; return rst[0].p;
@@ -245,7 +246,7 @@ function filterConnectablePoints<T extends IVec3 | IVec>(
): T[] { ): T[] {
return points.filter(point => { return points.filter(point => {
if (!bound) return true; if (!bound) return true;
return !bound.isPointInBound(point as IVec); return !bound.isPointInBound([point[0], point[1]]);
}); });
} }
@@ -368,15 +369,17 @@ function pushGapMidPoint(
bound.lowerLine, bound.lowerLine,
bound2.upperLine, bound2.upperLine,
bound2.lowerLine, bound2.lowerLine,
].map(line => { ]
return lineIntersects( .map(line => {
point as unknown as IVec, return lineIntersects(
[point[0], point[1] + 1], [point[0], point[1]],
line[0], [point[0], point[1] + 1],
line[1], line[0],
true line[1],
) as IVec; true
}); );
})
.filter(p => p !== null);
rst.sort((a, b) => a[1] - b[1]); rst.sort((a, b) => a[1] - b[1]);
const midPoint = Vec.lrp(rst[1], rst[2], 0.5); const midPoint = Vec.lrp(rst[1], rst[2], 0.5);
pushWithPriority(points, [midPoint], 6); pushWithPriority(points, [midPoint], 6);
@@ -399,15 +402,17 @@ function pushGapMidPoint(
bound.rightLine, bound.rightLine,
bound2.leftLine, bound2.leftLine,
bound2.rightLine, bound2.rightLine,
].map(line => { ]
return lineIntersects( .map(line => {
point as unknown as IVec, return lineIntersects(
[point[0] + 1, point[1]], [point[0], point[1]],
line[0], [point[0] + 1, point[1]],
line[1], line[0],
true line[1],
) as IVec; true
}); );
})
.filter(p => p !== null);
rst.sort((a, b) => a[0] - b[0]); rst.sort((a, b) => a[0] - b[0]);
const midPoint = Vec.lrp(rst[1], rst[2], 0.5); const midPoint = Vec.lrp(rst[1], rst[2], 0.5);
pushWithPriority(points, [midPoint], 6); pushWithPriority(points, [midPoint], 6);
@@ -480,14 +485,14 @@ function getConnectablePoints(
expandEndBound: Bound | null expandEndBound: Bound | null
) { ) {
const lineBound = Bound.fromPoints([ const lineBound = Bound.fromPoints([
startPoint, [startPoint[0], startPoint[1]],
endPoint, [endPoint[0], endPoint[1]],
] as unknown[] as IVec[]); ]);
const outerBound = const outerBound =
expandStartBound && expandStartBound &&
expandEndBound && expandEndBound &&
expandStartBound.unite(expandEndBound); expandStartBound.unite(expandEndBound);
let points = [nextStartPoint, lastEndPoint] as IVec3[]; let points = [nextStartPoint, lastEndPoint];
pushWithPriority(points, lineBound.getVerticesAndMidpoints()); pushWithPriority(points, lineBound.getVerticesAndMidpoints());
if (!startBound || !endBound) { if (!startBound || !endBound) {
@@ -534,7 +539,7 @@ function getConnectablePoints(
pushWithPriority(points, expandStartBound.getVerticesAndMidpoints()); pushWithPriority(points, expandStartBound.getVerticesAndMidpoints());
pushWithPriority( pushWithPriority(
points, points,
expandStartBound.include(lastEndPoint as unknown as IVec).points expandStartBound.include([lastEndPoint[0], lastEndPoint[1]]).points
); );
} }
@@ -542,7 +547,7 @@ function getConnectablePoints(
pushWithPriority(points, expandEndBound.getVerticesAndMidpoints()); pushWithPriority(points, expandEndBound.getVerticesAndMidpoints());
pushWithPriority( pushWithPriority(
points, points,
expandEndBound.include(nextStartPoint as unknown as IVec).points expandEndBound.include([nextStartPoint[0], nextStartPoint[1]]).points
); );
} }
@@ -561,7 +566,7 @@ function getConnectablePoints(
almostEqual(item[0], point[0], 0.02) && almostEqual(item[0], point[0], 0.02) &&
almostEqual(item[1], point[1], 0.02) almostEqual(item[1], point[1], 0.02)
); );
}) as IVec3[]; });
if (!startEnds[0] || !startEnds[1]) { if (!startEnds[0] || !startEnds[1]) {
throw new BlockSuiteError( throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists, BlockSuiteError.ErrorCode.ValueNotExists,
@@ -603,7 +608,9 @@ function mergePath(points: IVec[] | IVec3[]) {
continue; continue;
result.push([cur[0], cur[1]]); result.push([cur[0], cur[1]]);
} }
result.push(last(points as IVec[]) as IVec); if (points.length !== 0) {
result.push([points[points.length - 1][0], points[points.length - 1][1]]);
}
for (let i = 0; i < result.length - 1; i++) { for (let i = 0; i < result.length - 1; i++) {
const cur = result[i]; const cur = result[i];
const next = result[i + 1]; const next = result[i + 1];
@@ -687,7 +694,7 @@ function getNextPoint(
offsetW = 10, offsetW = 10,
offsetH = 10 offsetH = 10
) { ) {
const result: IVec = Array.from(point) as IVec; const result: IVec = [point[0], point[1]];
if (almostEqual(bound.x, result[0])) result[0] -= offsetX; if (almostEqual(bound.x, result[0])) result[0] -= offsetX;
else if (almostEqual(bound.y, result[1])) result[1] -= offsetY; else if (almostEqual(bound.y, result[1])) result[1] -= offsetY;
else if (almostEqual(bound.maxX, result[0])) result[0] += offsetW; else if (almostEqual(bound.maxX, result[0])) result[0] += offsetW;
@@ -993,7 +1000,7 @@ export class ConnectionOverlay extends Overlay {
this.highlightPoint = anchor.point; this.highlightPoint = anchor.point;
result = { result = {
id: connectable.id, id: connectable.id,
position: anchor.coord as IVec, position: anchor.coord,
}; };
} }
} }
@@ -1001,7 +1008,7 @@ export class ConnectionOverlay extends Overlay {
if (shortestDistance < 8 && result) break; if (shortestDistance < 8 && result) break;
// if not, check if closes to bound // if not, check if closes to bound
const nearestPoint = connectable.getNearestPoint(point as IVec) as IVec; const nearestPoint = connectable.getNearestPoint(point);
if (Vec.dist(nearestPoint, point) < 8) { if (Vec.dist(nearestPoint, point) < 8) {
this.highlightPoint = nearestPoint; this.highlightPoint = nearestPoint;
@@ -1013,9 +1020,7 @@ export class ConnectionOverlay extends Overlay {
target.push(connectable); target.push(connectable);
result = { result = {
id: connectable.id, id: connectable.id,
position: bound position: Vec.clampV(bound.toRelative(originPoint), 0, 1),
.toRelative(originPoint)
.map(n => clamp(n, 0, 1)) as IVec,
}; };
} }
@@ -1048,7 +1053,7 @@ export class ConnectionOverlay extends Overlay {
// at last, if not, just return the point // at last, if not, just return the point
if (!result) { if (!result) {
result = { result = {
position: point as IVec, position: point,
}; };
} }
@@ -1383,7 +1388,7 @@ export class ConnectorPathGenerator extends PathGenerator {
const eb = Bound.deserialize(end.xywh); const eb = Bound.deserialize(end.xywh);
const startPoint = getNearestConnectableAnchor(start, eb.center); const startPoint = getNearestConnectableAnchor(start, eb.center);
const endPoint = getNearestConnectableAnchor(end, sb.center); const endPoint = getNearestConnectableAnchor(end, sb.center);
return [startPoint, endPoint]; return (startPoint && endPoint && [startPoint, endPoint]) ?? [];
} else { } else {
const endPoint = this._getConnectionPoint(connector, 'target'); const endPoint = this._getConnectionPoint(connector, 'target');
const startPoint = this._getConnectionPoint(connector, 'source'); const startPoint = this._getConnectionPoint(connector, 'source');

View File

@@ -11,6 +11,24 @@ export function randomSeed(): number {
return Math.floor(Math.random() * 2 ** 31); return Math.floor(Math.random() * 2 ** 31);
} }
/**
* Calculates the intersection point of two line segments.
*
* @param sp - Start point of the first line segment [x, y]
* @param ep - End point of the first line segment [x, y]
* @param sp2 - Start point of the second line segment [x, y]
* @param ep2 - End point of the second line segment [x, y]
* @param infinite - If true, treats the lines as infinite lines rather than line segments
* @returns The intersection point [x, y] if the lines intersect, null if they are parallel or coincident
*
* @example
* const intersection = lineIntersects([0, 0], [2, 2], [0, 2], [2, 0]);
* // Returns [1, 1] - the intersection point of the two line segments
*
* @example
* const parallel = lineIntersects([0, 0], [2, 2], [0, 1], [2, 3], true);
* // Returns null - the lines are parallel
*/
export function lineIntersects( export function lineIntersects(
sp: IVec, sp: IVec,
ep: IVec, ep: IVec,
@@ -45,10 +63,23 @@ export function lineIntersects(
return null; return null;
} }
/**
* Finds the nearest point on a polygon to a given point.
*
* @param points - Array of points defining the polygon vertices [x, y][]
* @param point - The point to find the nearest point to [x, y]
* @returns The nearest point on the polygon to the given point
* @throws Error if points array is empty or has less than 2 points
*/
export function polygonNearestPoint(points: IVec[], point: IVec) { export function polygonNearestPoint(points: IVec[], point: IVec) {
const len = points.length; const len = points.length;
let rst: IVec; if (len < 2) {
let dis = Infinity; throw new Error('Polygon must have at least 2 points');
}
let rst: IVec = points[0]; // Initialize with first point as fallback
let dis = Vec.dist(points[0], point);
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const p = points[i]; const p = points[i];
const p2 = points[(i + 1) % len]; const p2 = points[(i + 1) % len];
@@ -59,7 +90,7 @@ export function polygonNearestPoint(points: IVec[], point: IVec) {
rst = temp; rst = temp;
} }
} }
return rst!; return rst;
} }
export function polygonPointDistance(points: IVec[], point: IVec) { export function polygonPointDistance(points: IVec[], point: IVec) {

View File

@@ -341,8 +341,15 @@ export class Bound implements IBound {
return serializeXYWH(this.x, this.y, this.w, this.h); return serializeXYWH(this.x, this.y, this.w, this.h);
} }
/**
* Convert a point to relative coordinates.
* @param point - The point to convert.
* @returns The normalized relative coordinates of the point.
*/
toRelative([x, y]: IVec): IVec { toRelative([x, y]: IVec): IVec {
return [(x - this.x) / this.w, (y - this.y) / this.h]; const normalizedX = this.w === 0 ? 0 : (x - this.x) / this.w;
const normalizedY = this.h === 0 ? 0 : (y - this.y) / this.h;
return [normalizedX, normalizedY];
} }
toXYWH(): XYWH { toXYWH(): XYWH {

View File

@@ -565,6 +565,8 @@ export class Vec {
* @param n * @param n
* @param min * @param min
*/ */
static clampV(A: IVec, min: number, max?: number): IVec;
static clampV(A: number[], min: number): number[]; static clampV(A: number[], min: number): number[];
// eslint-disable-next-line @typescript-eslint/unified-signatures // eslint-disable-next-line @typescript-eslint/unified-signatures

View File

@@ -214,7 +214,12 @@ export class Viewport {
* This property is used to calculate the scale of the editor. * This property is used to calculate the scale of the editor.
*/ */
get viewScale() { get viewScale() {
if (!this._shell || this._cachedOffsetWidth === null) return 1; if (
!this._shell ||
this._cachedOffsetWidth === null ||
this._cachedOffsetWidth === 0
)
return 1;
return this.boundingClientRect.width / this._cachedOffsetWidth; return this.boundingClientRect.width / this._cachedOffsetWidth;
} }