From b19a4f6f533e32a645af3a875676cdb25b05322e Mon Sep 17 00:00:00 2001 From: Zsynuting Date: Thu, 16 Jul 2026 15:20:15 +0800 Subject: [PATCH 1/3] fix(core): prevent NaN in rounded rect edge dragging --- .changeset/fix-rounded-rect-edge-nan.md | 5 + examples/feature-examples/.umirc.ts | 5 + .../edges/custom/rounded-rect-nan/index.less | 99 ++++++ .../edges/custom/rounded-rect-nan/index.tsx | 309 ++++++++++++++++++ .../__tests__/model/polyline-edge.test.ts | 127 +++++++ packages/core/__tests__/util/node.test.ts | 44 +++ .../core/src/model/edge/PolylineEdgeModel.ts | 7 +- packages/core/src/util/node.ts | 35 +- 8 files changed, 625 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-rounded-rect-edge-nan.md create mode 100644 examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.less create mode 100644 examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.tsx create mode 100644 packages/core/__tests__/model/polyline-edge.test.ts diff --git a/.changeset/fix-rounded-rect-edge-nan.md b/.changeset/fix-rounded-rect-edge-nan.md new file mode 100644 index 000000000..1d43fb04c --- /dev/null +++ b/.changeset/fix-rounded-rect-edge-nan.md @@ -0,0 +1,5 @@ +--- +'@logicflow/core': patch +--- + +fix: keep polyline endpoints finite when dragging across rounded rectangle straight-side regions diff --git a/examples/feature-examples/.umirc.ts b/examples/feature-examples/.umirc.ts index 561e19293..f2b321931 100644 --- a/examples/feature-examples/.umirc.ts +++ b/examples/feature-examples/.umirc.ts @@ -120,6 +120,11 @@ export default defineConfig({ name: '圆角折线', component: './edges/custom/curved-polyline', }, + { + path: '/custom-edges/rounded-rect-nan', + name: '圆角矩形拖线 NaN 回归', + component: './edges/custom/rounded-rect-nan', + }, ], }, { diff --git a/examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.less b/examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.less new file mode 100644 index 000000000..5fd002e64 --- /dev/null +++ b/examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.less @@ -0,0 +1,99 @@ +.content { + width: 100%; +} + +.stageScroller { + width: 100%; + overflow-x: auto; + border: 1px solid #d9d9d9; + border-radius: 8px; +} + +.stage { + position: relative; + width: 900px; + height: 560px; + background: #fff; +} + +.viewport { + width: 900px; + height: 560px; +} + +.dragGuide, +.invalidBand { + position: absolute; + z-index: 2; + box-sizing: border-box; + pointer-events: none; +} + +.dragGuide { + border-top: 7px solid rgb(22 119 255 / 35%); + transform: translateY(-3px); +} + +.dragGuide::after { + position: absolute; + top: -9px; + right: -1px; + width: 14px; + height: 14px; + background: #1677ff; + border: 3px solid #fff; + border-radius: 50%; + box-shadow: 0 0 0 2px #1677ff; + content: ''; +} + +.dragGuide span { + position: absolute; + top: -35px; + left: 8px; + padding: 3px 8px; + color: #0958d9; + white-space: nowrap; + background: #e6f4ff; + border-radius: 4px; +} + +.invalidBand { + color: #cf1322; + background: rgb(255 77 79 / 13%); + border: 2px dashed #ff4d4f; +} + +.invalidBand span { + position: absolute; + top: 50%; + left: 10px; + padding: 2px 6px; + white-space: nowrap; + background: rgb(255 255 255 / 88%); + transform: translateY(-50%); +} + +.negative { + color: #cf1322; + font-weight: 700; +} + +.dataPanel { + padding: 12px 16px; + overflow: hidden; + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 8px; +} + +.dataPanel pre { + margin: 6px 0 12px; + overflow-x: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.dataPanel pre:last-child { + margin-bottom: 0; +} diff --git a/examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.tsx b/examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.tsx new file mode 100644 index 000000000..f781bd482 --- /dev/null +++ b/examples/feature-examples/src/pages/edges/custom/rounded-rect-nan/index.tsx @@ -0,0 +1,309 @@ +import LogicFlow, { PolylineEdgeModel } from '@logicflow/core' +import { Alert, Button, Card, Space, Tag, Typography } from 'antd' +import { useEffect, useRef, useState } from 'react' +import styles from './index.less' + +import '@logicflow/core/es/index.css' + +const EDGE_ID = 'rounded-rect-nan-edge' + +const SOURCE = { + id: 'source', + x: 190, + y: 420, + width: 120, + height: 80, +} + +const TARGET = { + id: 'target', + x: 650, + y: 220, + width: 220, + height: 160, + radius: 24, +} + +const targetLeft = TARGET.x - TARGET.width / 2 +const targetTop = TARGET.y - TARGET.height / 2 +const topLeftRadiusCenter = { + x: targetLeft + TARGET.radius, + y: targetTop + TARGET.radius, +} + +// 终点是一个合法的圆弧交点,用来跳过第一次人工拖拽才能制造的前置状态。 +const initialArcY = targetTop + 8 +const initialArcX = + topLeftRadiusCenter.x - + Math.sqrt(TARGET.radius ** 2 - (initialArcY - topLeftRadiusCenter.y) ** 2) + +const terminalSegmentStart = { x: 420, y: initialArcY } +const invalidBandTop = targetTop + TARGET.radius * 2 +const invalidBandBottom = TARGET.y + TARGET.height / 2 - TARGET.radius * 2 + +type Snapshot = { + points: string + pointsList: LogicFlow.Point[] + radicand: number | null + hasAdjusted: boolean + hasNaN: boolean +} + +const emptySnapshot: Snapshot = { + points: '', + pointsList: [], + radicand: null, + hasAdjusted: false, + hasNaN: false, +} + +const createReproData = () => ({ + nodes: [ + { + id: SOURCE.id, + type: 'rect', + x: SOURCE.x, + y: SOURCE.y, + text: '起点', + properties: { + width: SOURCE.width, + height: SOURCE.height, + style: { + radius: 0, + fill: '#e6f4ff', + stroke: '#1677ff', + }, + }, + }, + { + id: TARGET.id, + type: 'rect', + x: TARGET.x, + y: TARGET.y, + text: `目标圆角矩形\nr = ${TARGET.radius}`, + properties: { + width: TARGET.width, + height: TARGET.height, + style: { + radius: TARGET.radius, + fill: '#f6ffed', + stroke: '#52c41a', + strokeWidth: 3, + }, + }, + }, + ], + edges: [ + { + id: EDGE_ID, + type: 'polyline', + sourceNodeId: SOURCE.id, + targetNodeId: TARGET.id, + sourceAnchorId: `${SOURCE.id}_1`, + targetAnchorId: `${TARGET.id}_3`, + startPoint: { + x: SOURCE.x + SOURCE.width / 2, + y: SOURCE.y, + }, + endPoint: { + x: initialArcX, + y: initialArcY, + }, + pointsList: [ + { + x: SOURCE.x + SOURCE.width / 2, + y: SOURCE.y, + }, + { + x: terminalSegmentStart.x, + y: SOURCE.y, + }, + terminalSegmentStart, + { + x: initialArcX, + y: initialArcY, + }, + ], + properties: { + style: { + stroke: '#1677ff', + strokeWidth: 3, + }, + }, + }, + ], +}) + +const getSnapshot = (lf: LogicFlow): Snapshot => { + const edgeModel = lf.getEdgeModelById(EDGE_ID) as + | PolylineEdgeModel + | undefined + const pointsList = (edgeModel?.pointsList || []).map(({ x, y }) => ({ x, y })) + const endPoint = pointsList[pointsList.length - 1] + let radicand: number | null = null + + if (endPoint && Number.isFinite(endPoint.y)) { + const cornerCenterY = + endPoint.y <= TARGET.y + ? targetTop + TARGET.radius + : TARGET.y + TARGET.height / 2 - TARGET.radius + radicand = TARGET.radius ** 2 - (endPoint.y - cornerCenterY) ** 2 + } + + return { + points: edgeModel?.points || '', + pointsList, + radicand, + hasAdjusted: + !!endPoint && (endPoint.x !== initialArcX || endPoint.y !== initialArcY), + hasNaN: + (edgeModel?.points || '').includes('NaN') || + pointsList.some(({ x, y }) => Number.isNaN(x) || Number.isNaN(y)), + } +} + +const formatNumber = (value: number) => { + if (Number.isNaN(value)) return 'NaN' + if (!Number.isFinite(value)) return String(value) + return Number.isInteger(value) ? String(value) : value.toFixed(2) +} + +const formatPointsList = (pointsList: LogicFlow.Point[]) => + pointsList + .map( + ({ x, y }, index) => `${index}: (${formatNumber(x)}, ${formatNumber(y)})`, + ) + .join(' → ') + +export default function RoundedRectNaNReproduction() { + const lfRef = useRef() + const containerRef = useRef(null) + const [snapshot, setSnapshot] = useState(emptySnapshot) + + const renderRepro = (lf: LogicFlow) => { + lf.render(createReproData()) + lf.selectElementById(EDGE_ID) + setSnapshot(getSnapshot(lf)) + } + + useEffect(() => { + if (!containerRef.current || lfRef.current) return + + const lf = new LogicFlow({ + container: containerRef.current, + width: 900, + height: 560, + grid: { + size: 10, + visible: true, + }, + stopScrollGraph: true, + stopZoomGraph: true, + stopMoveGraph: true, + adjustNodePosition: false, + adjustEdge: true, + adjustEdgeMiddle: false, + }) + + const syncSnapshot = () => setSnapshot(getSnapshot(lf)) + lf.on('edge:adjust', syncSnapshot) + lfRef.current = lf + renderRepro(lf) + + return () => { + lf.destroy() + lfRef.current = undefined + } + }, []) + + return ( + + + + 按住蓝色高亮的“箭头前最后一段水平折线”,向下拖入红色区域后松手。 + 修复前连线会消失,并将终点 x 写成 NaN; + 修复后终点应吸附到目标矩形左侧直边 x = {targetLeft}。 + + } + /> + + + + + {snapshot.hasNaN + ? '回归失败:出现 NaN' + : snapshot.hasAdjusted + ? '验证通过:坐标有效' + : '待验证:请拖动高亮线段'} + + + 圆角圆开方项(负数时应改用直边交点): + + {snapshot.radicand === null + ? '-' + : formatNumber(snapshot.radicand)} + + + + +
+
+
+
+ 按住这一段向下拖 +
+
+ 拖入此区域并松手 +
+
+
+ +
+ pointsList +
{formatPointsList(snapshot.pointsList) || '-'}
+ model.points +
{snapshot.points || '-'}
+
+ + + ) +} diff --git a/packages/core/__tests__/model/polyline-edge.test.ts b/packages/core/__tests__/model/polyline-edge.test.ts new file mode 100644 index 000000000..480beb73b --- /dev/null +++ b/packages/core/__tests__/model/polyline-edge.test.ts @@ -0,0 +1,127 @@ +import { LogicFlow, PolylineEdgeModel, SegmentDirection } from '../../src' + +const createLogicFlow = () => { + const container = document.createElement('div') + document.body.appendChild(container) + return new LogicFlow({ + container, + width: 800, + height: 400, + }) +} + +describe('PolylineEdgeModel rounded rectangle intersections', () => { + test('keeps the endpoint finite when the terminal segment enters a straight-side region', () => { + const lf = createLogicFlow() + const radius = 24 + const initialY = 148 + const cornerCenter = { x: 564, y: 164 } + const initialX = + cornerCenter.x - Math.sqrt(radius ** 2 - (initialY - cornerCenter.y) ** 2) + + lf.render({ + nodes: [ + { + id: 'rounded-source', + type: 'rect', + x: 190, + y: 420, + properties: { width: 120, height: 80 }, + }, + { + id: 'rounded-target', + type: 'rect', + x: 650, + y: 220, + properties: { + width: 220, + height: 160, + style: { radius }, + }, + }, + ], + edges: [ + { + id: 'rounded-rect-edge', + type: 'polyline', + sourceNodeId: 'rounded-source', + targetNodeId: 'rounded-target', + sourceAnchorId: 'rounded-source_1', + targetAnchorId: 'rounded-target_3', + startPoint: { x: 250, y: 420 }, + endPoint: { x: initialX, y: initialY }, + pointsList: [ + { x: 250, y: 420 }, + { x: 420, y: 420 }, + { x: 420, y: initialY }, + { x: initialX, y: initialY }, + ], + }, + ], + }) + + const edge = lf.getEdgeModelById('rounded-rect-edge') as PolylineEdgeModel + edge.dragAppendStart() + edge.dragAppend( + { + start: { x: 420, y: initialY }, + end: { x: initialX, y: initialY }, + startIndex: 2, + endIndex: 3, + direction: SegmentDirection.HORIZONTAL, + draggable: true, + }, + { x: 0, y: 50 }, + ) + + expect(edge.points).not.toContain('NaN') + expect(edge.points).toContain('540,198') + + edge.dragAppendEnd() + + expect(edge.endPoint).toEqual({ x: 540, y: 198 }) + expect( + edge.pointsList.every( + ({ x, y }) => Number.isFinite(x) && Number.isFinite(y), + ), + ).toBe(true) + }) + + test('does not replace an endpoint with a non-finite shape intersection', () => { + const lf = createLogicFlow() + lf.render({ + nodes: [ + { id: 'guard-source', type: 'rect', x: 100, y: 220 }, + { + id: 'guard-target', + type: 'circle', + x: 500, + y: 100, + properties: { r: 50 }, + }, + ], + edges: [ + { + id: 'guard-edge', + type: 'polyline', + sourceNodeId: 'guard-source', + targetNodeId: 'guard-target', + startPoint: { x: 150, y: 220 }, + endPoint: { x: 450, y: 220 }, + pointsList: [ + { x: 150, y: 220 }, + { x: 450, y: 220 }, + ], + }, + ], + }) + + const edge = lf.getEdgeModelById('guard-edge') as PolylineEdgeModel + const points = edge.updateCrossPoints([ + { x: 150, y: 220 }, + { x: 450, y: 220 }, + ]) + + expect(points[points.length - 1]).toEqual({ x: 450, y: 220 }) + }) +}) diff --git a/packages/core/__tests__/util/node.test.ts b/packages/core/__tests__/util/node.test.ts index 60b0b096b..50a08f94a 100644 --- a/packages/core/__tests__/util/node.test.ts +++ b/packages/core/__tests__/util/node.test.ts @@ -1,6 +1,7 @@ import { getClosestAnchor, isInNode, + getClosestRadiusCenter, getCrossPointWithCircle, getCrossPointWithEllipse, } from '../../src/util/node' @@ -49,6 +50,49 @@ describe('util/node', () => { y: 10, }) }) + test('get cross point from the straight side of a rounded rectangle', () => { + const node = { + x: 650, + y: 220, + width: 220, + height: 160, + radius: 24, + } + + expect( + getClosestRadiusCenter( + { x: 546.1114561800017, y: 198 }, + 'horizontal', + node, + ), + ).toEqual({ + x: 540, + y: 198, + }) + expect( + getClosestRadiusCenter({ x: 650, y: 198 }, 'vertical', node), + ).toEqual({ + x: 650, + y: 140, + }) + }) + test('keeps using a rounded corner when the line intersects its circle', () => { + const node = { + x: 650, + y: 220, + width: 220, + height: 160, + radius: 24, + } + const expectedX = 564 - Math.sqrt(24 ** 2 - (148 - 164) ** 2) + + expect( + getClosestRadiusCenter({ x: expectedX, y: 148 }, 'horizontal', node), + ).toEqual({ + x: expectedX, + y: 148, + }) + }) test('get cross point with ellipse', () => { const position = { x: 0, y: 0 } const node = { diff --git a/packages/core/src/model/edge/PolylineEdgeModel.ts b/packages/core/src/model/edge/PolylineEdgeModel.ts index 44a63ae57..533ac3362 100644 --- a/packages/core/src/model/edge/PolylineEdgeModel.ts +++ b/packages/core/src/model/edge/PolylineEdgeModel.ts @@ -27,6 +27,9 @@ import Position = LogicFlow.Position import AppendConfig = LogicFlow.AppendConfig import AnchorConfig = Model.AnchorConfig +const isFinitePoint = (point?: Point): point is Point => + !!point && Number.isFinite(point.x) && Number.isFinite(point.y) + export class PolylineEdgeModel extends BaseEdgeModel { modelType = ModelType.POLYLINE_EDGE draggingPointList: Point[] = [] @@ -403,7 +406,7 @@ export class PolylineEdgeModel extends BaseEdgeModel { break } // 如果线段和形状没有交点时startCrossPoint会为undefined导致后续计算报错 - if (startCrossPoint) { + if (isFinitePoint(startCrossPoint)) { list[0] = startCrossPoint } const endPointDirection = segmentDirection(pre, end)! @@ -453,7 +456,7 @@ export class PolylineEdgeModel extends BaseEdgeModel { break } // 如果线段和形状没有交点时startCrossPoint会为undefined导致后续计算报错 - if (endCrossPoint) { + if (isFinitePoint(endCrossPoint)) { list[list.length - 1] = endCrossPoint } return list diff --git a/packages/core/src/util/node.ts b/packages/core/src/util/node.ts index 30ce10ef2..f44333c57 100644 --- a/packages/core/src/util/node.ts +++ b/packages/core/src/util/node.ts @@ -205,17 +205,44 @@ export const getClosestRadiusCenter = ( direction: Direction, node: BaseNodeModel, ): Point => { - const radiusCenter = getRectRadiusCircle(node) - let closestRadiusPoint: RadiusCircle + const radiusCenters = getRectRadiusCircle(node) + let closestRadiusPoint: RadiusCircle | undefined let minDistance = Number.MAX_SAFE_INTEGER - radiusCenter.forEach((item) => { + radiusCenters.forEach((item) => { + // 只有线段所在的水平线或垂直线经过该圆时,才能用圆弧求交点。 + const canIntersect = + direction === SegmentDirection.HORIZONTAL + ? Math.abs(point.y - item.y) <= item.r + : direction === SegmentDirection.VERTICAL + ? Math.abs(point.x - item.x) <= item.r + : false + if (!canIntersect) return + const radiusDistance = distance(point.x, point.y, item.x, item.y) if (radiusDistance < minDistance) { minDistance = radiusDistance closestRadiusPoint = item } }) - return getCrossPointWithCircle(point, direction, closestRadiusPoint!) + if (closestRadiusPoint) { + return getCrossPointWithCircle(point, direction, closestRadiusPoint) + } + + // 不经过任何圆角圆时,线段位于圆角矩形的直边区域。 + const { x, y, width, height } = node + if (direction === SegmentDirection.HORIZONTAL) { + return { + x: point.x <= x ? x - width / 2 : x + width / 2, + y: point.y, + } + } + if (direction === SegmentDirection.VERTICAL) { + return { + x: point.x, + y: point.y <= y ? y - height / 2 : y + height / 2, + } + } + return point } /* 求点在垂直或者水平方向上与圆形的交点 */ export const getCrossPointWithCircle = ( From 5f499cc3de725d17c17006027614ca5f846310fc Mon Sep 17 00:00:00 2001 From: Zsynuting Date: Thu, 16 Jul 2026 15:32:19 +0800 Subject: [PATCH 2/3] fix(extension): align dynamic group paste behavior --- examples/dynamic-group-regression/README.md | 2 + .../src/components/RegressionWorkbench.tsx | 11 +- .../src/components/SelectionCopyControls.tsx | 156 ++++++++++++ .../src/scenarios/index.ts | 6 + .../src/scenarios/selectionCopyPaste.ts | 228 ++++++++++++++++++ packages/extension/src/dynamic-group/index.ts | 2 +- 6 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 examples/dynamic-group-regression/src/components/SelectionCopyControls.tsx create mode 100644 examples/dynamic-group-regression/src/scenarios/selectionCopyPaste.ts diff --git a/examples/dynamic-group-regression/README.md b/examples/dynamic-group-regression/README.md index aa225c2d6..2192cebbd 100644 --- a/examples/dynamic-group-regression/README.md +++ b/examples/dynamic-group-regression/README.md @@ -47,6 +47,7 @@ cd examples/dynamic-group-regression && pnpm dev | `resize-bounds` | LOCAL-resize-bounds(DG 缩小 resize 最小边界) | | `resize-undo-twice` | #1532(关闭 · 暂不修复) | | `resize-single-axis` | #1555(关闭 · 暂不修复) | +| `selection-copy-paste` | LOCAL-copy-paste(选区复制分组+连线后节点/连线分离) | ## 图数据约定 @@ -72,6 +73,7 @@ cd examples/dynamic-group-regression && pnpm dev - 部分场景需**手动**拖折线控制点(#2401、#2399)后再点折叠。 - `#2412` 使用自定义节点类型 `locked-dynamic-group`。 +- `selection-copy-paste` 使用 `SelectionSelect` 插件框选(点「开启框选」后在空白处拖拽),复制/粘贴按钮与键盘 `Cmd/Ctrl+C`、`Cmd/Ctrl+V` 走同一套 core 逻辑;复现要点:框选「分组+组内节点+连线」粘贴后,`addElements` 会让新分组另生成一套幽灵子节点,粘贴出的连线连的是另一批子节点,拖动新分组即分离(点「诊断」可看到重合矩形与归属信息)。 - 布局场景(`layout-format-escape`)支持通过控制面板组合测试:**Dagre (#2205) / ELK (#2332)**、全图/组内布局、`resizeGroup`、分组 `resizable` 与尺寸等。 ## 相关文档 diff --git a/examples/dynamic-group-regression/src/components/RegressionWorkbench.tsx b/examples/dynamic-group-regression/src/components/RegressionWorkbench.tsx index d9dd85a0f..d2eac4b27 100644 --- a/examples/dynamic-group-regression/src/components/RegressionWorkbench.tsx +++ b/examples/dynamic-group-regression/src/components/RegressionWorkbench.tsx @@ -4,6 +4,7 @@ import { DndPanel, DynamicGroup, MiniMap, + SelectionSelect, type ShapeItem, } from '@logicflow/extension' import { Dagre, ElkLayout } from '@logicflow/layout' @@ -51,7 +52,15 @@ const lfOptions: Partial = { allowRotate: false, textEdit: true, keyboard: { enabled: true }, - plugins: [DynamicGroup, Control, DndPanel, Dagre, ElkLayout, MiniMap], + plugins: [ + DynamicGroup, + Control, + DndPanel, + Dagre, + ElkLayout, + MiniMap, + SelectionSelect, + ], pluginsOptions: { dynamicGroup: { disallowEdgeConnectToGroup: true, diff --git a/examples/dynamic-group-regression/src/components/SelectionCopyControls.tsx b/examples/dynamic-group-regression/src/components/SelectionCopyControls.tsx new file mode 100644 index 000000000..85464499e --- /dev/null +++ b/examples/dynamic-group-regression/src/components/SelectionCopyControls.tsx @@ -0,0 +1,156 @@ +import { Alert, Button, Space, Typography } from 'antd' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ScenarioControlsProps } from '@/scenarios/types' +import { + buildSelectionCopyGraph, + copySelection, + diagnoseCopyPaste, + pasteSelection, + selectAll, + type Clipboard, +} from '@/scenarios/selectionCopyPaste' + +const { Text, Paragraph } = Typography + +type LfWithSelection = NonNullable & { + openSelectionSelect?: () => void + closeSelectionSelect?: () => void +} + +export default function SelectionCopyControls({ + lf, + loadGraph, +}: ScenarioControlsProps) { + const clipboardRef = useRef(null) + const lastPastedGroupRef = useRef(undefined) + const [selectionOpen, setSelectionOpen] = useState(false) + const [hasClipboard, setHasClipboard] = useState(false) + + // 离开场景时关闭框选,避免影响其它场景 + useEffect(() => { + return () => { + const target = lf as LfWithSelection | undefined + target?.closeSelectionSelect?.() + } + }, [lf]) + + const toggleSelection = useCallback(() => { + const target = lf as LfWithSelection | undefined + if (!target) return + if (selectionOpen) { + target.closeSelectionSelect?.() + setSelectionOpen(false) + } else { + target.openSelectionSelect?.() + setSelectionOpen(true) + } + }, [lf, selectionOpen]) + + const handleSelectAll = useCallback(() => { + if (!lf) return + selectAll(lf) + }, [lf]) + + const handleCopy = useCallback(() => { + if (!lf) return + const clipboard = copySelection(lf) + clipboardRef.current = clipboard + setHasClipboard(!!clipboard) + if (!clipboard) { + alert('当前没有选中任何元素,请先框选或全选') + } + }, [lf]) + + const handlePaste = useCallback(() => { + if (!lf) return + if (!clipboardRef.current) { + alert('剪贴板为空,请先复制选中') + return + } + const { groupId } = pasteSelection(lf, clipboardRef.current) + lastPastedGroupRef.current = groupId + }, [lf]) + + const handleMovePasted = useCallback(() => { + if (!lf) return + const groupId = lastPastedGroupRef.current + if (!groupId || !lf.getNodeModelById(groupId)) { + alert('请先粘贴出一个新分组') + return + } + lf.graphModel.moveNode(groupId, 0, 200) + }, [lf]) + + const handleDiagnose = useCallback(() => { + if (!lf) return + const result = diagnoseCopyPaste(lf) + console.log('[selection-copy-paste] diagnosis', result) + alert(result.message) + }, [lf]) + + const handleReset = useCallback(() => { + clipboardRef.current = null + lastPastedGroupRef.current = undefined + setHasClipboard(false) + loadGraph(buildSelectionCopyGraph()) + }, [loadGraph]) + + return ( + + + + + 复制/粘贴按钮与键盘 Cmd/Ctrl+C、Cmd/Ctrl+V 走同一套 core 逻辑 + (getSelectElements + + addElements)。开启框选后可直接在画布空白处拖拽选区。 + + + + + + + + + + + + + + ) +} diff --git a/examples/dynamic-group-regression/src/scenarios/index.ts b/examples/dynamic-group-regression/src/scenarios/index.ts index 3604f5287..775129725 100644 --- a/examples/dynamic-group-regression/src/scenarios/index.ts +++ b/examples/dynamic-group-regression/src/scenarios/index.ts @@ -11,12 +11,14 @@ import LayoutFormatEscapeControls from '@/components/LayoutFormatEscapeControls' import ResizeBoundsControls from '@/components/ResizeBoundsControls' import TitleHeaderControls from '@/components/TitleHeaderControls' import CascadeDeleteControls from '@/components/CascadeDeleteControls' +import SelectionCopyControls from '@/components/SelectionCopyControls' import { layoutFormatEscapeScenario } from './layoutFormatEscape' import { resizeBoundsScenario, syncResizeBoundsMembership, } from './resizeBounds' import { titleHeaderScenario } from './titleHeader' +import { selectionCopyPasteScenario } from './selectionCopyPaste' function toggleGroup(lf: LogicFlow, groupId: string, collapse?: boolean) { const model = lf.getNodeModelById(groupId) as { @@ -786,6 +788,10 @@ export const scenarios: Scenario[] = [ Controls: ResizeBoundsControls, afterRender: syncResizeBoundsMembership, }, + { + ...selectionCopyPasteScenario, + Controls: SelectionCopyControls, + }, { id: 'resize-undo-twice', title: '分组 resize 后需撤销两次', diff --git a/examples/dynamic-group-regression/src/scenarios/selectionCopyPaste.ts b/examples/dynamic-group-regression/src/scenarios/selectionCopyPaste.ts new file mode 100644 index 000000000..3e7fdc34f --- /dev/null +++ b/examples/dynamic-group-regression/src/scenarios/selectionCopyPaste.ts @@ -0,0 +1,228 @@ +import type LogicFlow from '@logicflow/core' +import type { Scenario } from './types' +import { makeGroup, makeNode } from './customNodes' + +export const SC_GROUP_ID = 'sc_group' +export const SC_CHILD_A = 'sc_child_a' +export const SC_CHILD_B = 'sc_child_b' +export const SC_EDGE_AB = 'sc_edge_ab' + +/** 复制粘贴的位移步长,与 core keyboard 默认一致 */ +export const SC_TRANSLATION = 40 + +export function buildSelectionCopyGraph(): LogicFlow.GraphConfigData { + return { + nodes: [ + makeGroup(SC_GROUP_ID, 400, 300, [SC_CHILD_A, SC_CHILD_B], { + width: 360, + height: 220, + radius: 5, + }), + makeNode(SC_CHILD_A, 'rect', 320, 300, { + properties: { width: 90, height: 50 }, + }), + makeNode(SC_CHILD_B, 'rect', 500, 300, { + properties: { width: 90, height: 50 }, + }), + ], + edges: [ + { + id: SC_EDGE_AB, + type: 'polyline', + sourceNodeId: SC_CHILD_A, + targetNodeId: SC_CHILD_B, + }, + ], + } +} + +type AnyNodeData = Record +type AnyEdgeData = Record + +/** 与 core keyboard/shortcut.ts 中 translateNodeData 行为一致 */ +function translateNodeData(node: AnyNodeData, distance: number) { + node.x += distance + node.y += distance + if (node.text && typeof node.text === 'object') { + node.text.x += distance + node.text.y += distance + } + return node +} + +/** 与 core keyboard/shortcut.ts 中 translateEdgeData 行为一致 */ +function translateEdgeData(edge: AnyEdgeData, distance: number) { + if (edge.startPoint) { + edge.startPoint.x += distance + edge.startPoint.y += distance + } + if (edge.endPoint) { + edge.endPoint.x += distance + edge.endPoint.y += distance + } + if (Array.isArray(edge.pointsList)) { + edge.pointsList.forEach((point: AnyNodeData) => { + point.x += distance + point.y += distance + }) + } + if (edge.text && typeof edge.text === 'object') { + edge.text.x += distance + edge.text.y += distance + } + return edge +} + +export type Clipboard = LogicFlow.GraphData | null + +/** + * 复制当前选区:等价于按下 Cmd/Ctrl + C。 + * 返回可用于后续 paste 的剪贴板数据(已按一次步长位移)。 + */ +export function copySelection(lf: LogicFlow): Clipboard { + // isIgnoreCheck=false:仅复制两端都被选中的边,避免悬空边 + const selected = lf.getSelectElements(false) + if (selected.nodes.length === 0 && selected.edges.length === 0) { + return null + } + selected.nodes.forEach((node) => translateNodeData(node, SC_TRANSLATION)) + selected.edges.forEach((edge) => translateEdgeData(edge, SC_TRANSLATION)) + return selected +} + +/** + * 粘贴剪贴板内容:等价于按下 Cmd/Ctrl + V。 + * 返回本次新增的分组节点 id(若有),用于「拖动新分组」演示分离。 + */ +export function pasteSelection(lf: LogicFlow, clipboard: Clipboard) { + if (!clipboard) return { groupId: undefined as string | undefined } + lf.clearSelectElements() + const added = lf.addElements(clipboard, SC_TRANSLATION) + added.nodes.forEach((node) => lf.selectElementById(node.id, true)) + added.edges.forEach((edge) => lf.selectElementById(edge.id!, true)) + // 为下一次粘贴继续位移,避免叠在一起 + clipboard.nodes.forEach((node) => + translateNodeData(node as AnyNodeData, SC_TRANSLATION), + ) + clipboard.edges.forEach((edge) => + translateEdgeData(edge as AnyEdgeData, SC_TRANSLATION), + ) + const newGroup = added.nodes.find( + (node) => (node as { type?: string }).type === 'dynamic-group', + ) + return { groupId: newGroup?.id } +} + +export function selectAll(lf: LogicFlow) { + lf.clearSelectElements() + lf.graphModel.nodes.forEach((node) => lf.selectElementById(node.id, true)) + lf.graphModel.edges.forEach((edge) => lf.selectElementById(edge.id, true)) +} + +export type CopyPasteDiagnosis = { + nodeCount: number + edgeCount: number + groupCount: number + rectCount: number + /** 位置完全重合的矩形分组数量(幽灵重复子节点的信号) */ + overlappingRectGroups: number + /** 分组 children 是否都指向真实存在、且被边连接的子节点 */ + detachedGroups: { + groupId: string + children: string[] + childrenExist: boolean + childrenAreEdgeEndpoints: boolean + }[] + message: string +} + +/** + * 诊断粘贴结果:检测幽灵重复子节点与分组归属分离。 + */ +export function diagnoseCopyPaste(lf: LogicFlow): CopyPasteDiagnosis { + const graphModel = lf.graphModel + const nodes = graphModel.nodes + const edges = graphModel.edges + + const rects = nodes.filter((n) => (n.type as string) === 'rect') + const groups = nodes.filter((n) => (n.type as string) === 'dynamic-group') + + // 位置重合的矩形分桶 + const posMap = new Map() + rects.forEach((r) => { + const key = `${Math.round(r.x)},${Math.round(r.y)}` + posMap.set(key, (posMap.get(key) ?? 0) + 1) + }) + const overlappingRectGroups = [...posMap.values()].filter((c) => c > 1).length + + const edgeEndpointIds = new Set() + edges.forEach((e) => { + edgeEndpointIds.add(e.sourceNodeId) + edgeEndpointIds.add(e.targetNodeId) + }) + + const detachedGroups = groups.map((g) => { + const children = [ + ...((g as unknown as { children: Set }).children ?? []), + ] + const childrenExist = children.every((id) => !!lf.getNodeModelById(id)) + const childrenAreEdgeEndpoints = + children.length === 0 || children.some((id) => edgeEndpointIds.has(id)) + return { + groupId: g.id, + children, + childrenExist, + childrenAreEdgeEndpoints, + } + }) + + const hasDetached = detachedGroups.some( + (d) => !d.childrenAreEdgeEndpoints && d.children.length > 0, + ) + + const message = [ + `节点总数: ${nodes.length}(分组 ${groups.length} / 矩形 ${rects.length})`, + `边总数: ${edges.length}`, + `位置重合的矩形组: ${overlappingRectGroups}${ + overlappingRectGroups > 0 ? ' ← 存在幽灵重复子节点' : '' + }`, + ...detachedGroups.map( + (d) => + `分组 ${d.groupId.slice(0, 8)} children=[${d.children + .map((c) => c.slice(0, 8)) + .join( + ', ', + )}] 存在=${d.childrenExist} 被边连接=${d.childrenAreEdgeEndpoints}`, + ), + hasDetached || overlappingRectGroups > 0 + ? '结论: 复现分离 —— 分组拥有的子节点与被边连接的子节点不是同一批' + : '结论: 未检测到分离', + ].join('\n') + + return { + nodeCount: nodes.length, + edgeCount: edges.length, + groupCount: groups.length, + rectCount: rects.length, + overlappingRectGroups, + detachedGroups, + message, + } +} + +export const selectionCopyPasteScenario: Scenario = { + id: 'selection-copy-paste', + title: '选区复制粘贴:连线与节点分离', + issues: ['LOCAL-copy-paste'], + expectedBug: + '框选「分组 + 组内节点 + 连线」后复制粘贴,addElements 让分组重新生成一套幽灵子节点,粘贴出的连线连的却是另一套子节点;拖动新分组时连线和节点分离。', + steps: [ + '1. 画布预置:分组 sc_group 含 sc_child_a、sc_child_b,两子节点间有一条连线。', + '2. 点「开启框选」,在画布空白处拖拽框住整组(或直接点「全选」)。', + '3. 点「复制选中」(等价 Cmd/Ctrl+C),再点「粘贴」(等价 Cmd/Ctrl+V)。', + '4. 点「诊断」:粘贴应只新增 1 组 + 2 子节点 + 1 边;实际会多出 2 个重合的幽灵矩形。', + '5. 点「拖动新分组」:新分组移开后,连线和它连接的子节点留在原地 —— 复现分离。', + '6. 点「重置场景」可重复对比。', + ], + graphData: buildSelectionCopyGraph(), +} diff --git a/packages/extension/src/dynamic-group/index.ts b/packages/extension/src/dynamic-group/index.ts index 1347c5ea9..9073db8b7 100644 --- a/packages/extension/src/dynamic-group/index.ts +++ b/packages/extension/src/dynamic-group/index.ts @@ -902,7 +902,7 @@ export class DynamicGroup { // 使用场景:addElements api 项目内部目前只在快捷键粘贴时使用(此处解决的也应该是粘贴场景的问题) lf.addElements = ( { nodes: selectedNodes, edges: selectedEdges }: GraphConfigData, - distance = 40, + distance = 0, ): GraphElements => { // oldNodeId -> newNodeId 映射 Map const nodeIdMap: Record = {} From 2bfd5275374dca618da17e995c6fa9170f85da9d Mon Sep 17 00:00:00 2001 From: Zsynuting Date: Tue, 21 Jul 2026 17:10:59 +0800 Subject: [PATCH 3/3] fix(extension): handle degenerate curved edge paths --- .../fix-invalid-polyline-path-rendering.md | 5 +++ .../materials/curved-edge/curved-edge.test.ts | 32 +++++++++++++++++++ .../src/materials/curved-edge/index.ts | 22 +++++++++++-- 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-invalid-polyline-path-rendering.md diff --git a/.changeset/fix-invalid-polyline-path-rendering.md b/.changeset/fix-invalid-polyline-path-rendering.md new file mode 100644 index 000000000..d319000db --- /dev/null +++ b/.changeset/fix-invalid-polyline-path-rendering.md @@ -0,0 +1,5 @@ +--- +'@logicflow/extension': patch +--- + +fix: prevent curved-edge path generation from throwing for empty, single-point, or malformed point lists diff --git a/packages/extension/__test__/materials/curved-edge/curved-edge.test.ts b/packages/extension/__test__/materials/curved-edge/curved-edge.test.ts index 8bb880319..5944bd41f 100644 --- a/packages/extension/__test__/materials/curved-edge/curved-edge.test.ts +++ b/packages/extension/__test__/materials/curved-edge/curved-edge.test.ts @@ -1,6 +1,38 @@ import { getCurvedEdgePath } from '../../../src/materials/curved-edge' describe('test curved edge ', () => { + test('handles empty and single-point paths without throwing', () => { + expect(getCurvedEdgePath([], 5)).toBe('') + expect(getCurvedEdgePath([[100, 100]], 5)).toBe('M100 100') + }) + + test.each([ + [ + 'NaN', + [ + [100, 100], + [Number.NaN, 200], + ], + ], + [ + 'Infinity', + [ + [100, 100], + [Number.POSITIVE_INFINITY, 200], + ], + ], + ['a missing coordinate', [[100, 100], [200]]], + [ + 'an extra coordinate', + [ + [100, 100, Number.NaN], + [200, 200], + ], + ], + ])('rejects %s path data', (_, points) => { + expect(getCurvedEdgePath(points, 5)).toBe('') + }) + test('path calculation', () => { const radius = 5 diff --git a/packages/extension/src/materials/curved-edge/index.ts b/packages/extension/src/materials/curved-edge/index.ts index 6aa25fd16..418ebd000 100644 --- a/packages/extension/src/materials/curved-edge/index.ts +++ b/packages/extension/src/materials/curved-edge/index.ts @@ -6,6 +6,13 @@ type DirectionType = 't' | 'b' | 'l' | 'r' | '' // 圆弧所在象限:tl=左上,tr=右上,bl=左下,br=右下,'-' 表示不需要圆弧 type ArcQuadrantType = 'tl' | 'tr' | 'bl' | 'br' | '-' +// SVG 路径点只能是严格的 [x, y];多余坐标也视为格式错误,不能静默忽略。 +const isFinitePointTuple = (point: number[]): point is PointTuple => + Array.isArray(point) && + point.length === 2 && + Number.isFinite(point[0]) && + Number.isFinite(point[1]) + // 方向组合到圆弧象限的映射。 // key 由进入方向(dir1)和离开方向(dir2)拼接,例如 'tr' 表示从上(t)到右(r)的拐角。 // 通过该映射确定在拐点处应该绘制的圆弧象限,用于计算中间控制点。 @@ -164,12 +171,21 @@ function getPartialPath( } function getCurvedEdgePath(points: number[][], radius: number): string { + // 这是可独立调用的导出函数,不能依赖 View 一定提前完成校验。 + if (points.length === 0 || !points.every(isFinitePointTuple)) { + return '' + } + + // i 始终指向下一个尚未写入路径的点:首点只生成 M,单点路径到此结束; + // 两点生成直线,三个及以上的点才为中间拐点计算圆角。 let i = 0 - let d = '' + let d = `M${points[i][0]} ${points[i++][1]}` + if (points.length === 1) { + return d + } if (points.length === 2) { - d += `M${points[i][0]} ${points[i++][1]} L ${points[i][0]} ${points[i][1]}` + d += ` L ${points[i][0]} ${points[i][1]}` } else { - d += `M${points[i][0]} ${points[i++][1]}` for (; i + 1 < points.length; ) { const prev = points[i - 1] as PointTuple const cur = points[i] as PointTuple