优化组件选择交互与视图适配时机

This commit is contained in:
ljz committed 2026-08-03 11:59:24 +08:00
1 parent 11093613c5
commit cf249d6c0d
7 files changed
+473 -18

No files matched your search

+157 -6
View File
@@ -420,11 +420,98 @@ const NODE_SYMBOL_WIDTH = 112;
const NODE_SYMBOL_HEIGHT = 84; const NODE_SYMBOL_HEIGHT = 84;
const SYMBOL_VIEWBOX_WIDTH = 64; const SYMBOL_VIEWBOX_WIDTH = 64;
const SYMBOL_VIEWBOX_HEIGHT = 48; const SYMBOL_VIEWBOX_HEIGHT = 48;
const CONTACT_SNAP_RADIUS_PX = 24; const SYMBOL_ENVELOPE_PADDING_PX = 2;
const CONTACT_SNAP_RADIUS_PX = 12;
const CONTACT_DISCONNECT_DISTANCE_PX = 8; const CONTACT_DISCONNECT_DISTANCE_PX = 8;
const CONTACT_GEOMETRY_TOLERANCE = 0.5; const CONTACT_GEOMETRY_TOLERANCE = 0.5;
const CONTACT_DELETE_SEPARATION = 16; const CONTACT_DELETE_SEPARATION = 16;
type SymbolEnvelope = {
left: number;
top: number;
width: number;
height: number;
};
const DEFAULT_SYMBOL_ENVELOPE: SymbolEnvelope = {
left: 0,
top: 0,
width: NODE_SYMBOL_WIDTH,
height: NODE_SYMBOL_HEIGHT,
};
function measuredSymbolEnvelope(svg: SVGSVGElement): SymbolEnvelope {
const viewBox = svg.viewBox.baseVal;
const geometryBounds = svg.getBBox();
let bounds: Pick<DOMRect, "x" | "y" | "width" | "height"> = geometryBounds;
try {
const paintBounds = svg.getBBox({
fill: true,
stroke: true,
markers: true,
clipped: false,
});
const includesPaintOutsideGeometry =
paintBounds.x < geometryBounds.x ||
paintBounds.y < geometryBounds.y ||
paintBounds.x + paintBounds.width >
geometryBounds.x + geometryBounds.width ||
paintBounds.y + paintBounds.height >
geometryBounds.y + geometryBounds.height;
if (includesPaintOutsideGeometry) {
bounds = paintBounds;
}
} catch {
// Older SVG implementations may not support the getBBox options object.
}
if (bounds === geometryBounds) {
const maximumStrokeWidth = Array.from(svg.querySelectorAll<SVGElement>("*"))
.map((element) => getComputedStyle(element))
.filter((style) => style.stroke !== "none")
.reduce(
(maximum, style) =>
Math.max(maximum, Number.parseFloat(style.strokeWidth) || 0),
0,
);
if (maximumStrokeWidth > 0) {
const strokeRadius = maximumStrokeWidth / 2;
bounds = {
x: geometryBounds.x - strokeRadius,
y: geometryBounds.y - strokeRadius,
width: geometryBounds.width + maximumStrokeWidth,
height: geometryBounds.height + maximumStrokeWidth,
};
}
}
if (
viewBox.width <= 0 ||
viewBox.height <= 0 ||
bounds.width <= 0 ||
bounds.height <= 0
) {
return DEFAULT_SYMBOL_ENVELOPE;
}
const scaleX = NODE_SYMBOL_WIDTH / viewBox.width;
const scaleY = NODE_SYMBOL_HEIGHT / viewBox.height;
const left = (bounds.x - viewBox.x) * scaleX - SYMBOL_ENVELOPE_PADDING_PX;
const top = (bounds.y - viewBox.y) * scaleY - SYMBOL_ENVELOPE_PADDING_PX;
const right =
(bounds.x + bounds.width - viewBox.x) * scaleX +
SYMBOL_ENVELOPE_PADDING_PX;
const bottom =
(bounds.y + bounds.height - viewBox.y) * scaleY +
SYMBOL_ENVELOPE_PADDING_PX;
const roundPixel = (value: number) => Math.round(value * 100) / 100;
return {
left: roundPixel(left),
top: roundPixel(top),
width: roundPixel(Math.max(1, right - left)),
height: roundPixel(Math.max(1, bottom - top)),
};
}
function defaultModelingPaneLayout(): ModelingPaneLayout { function defaultModelingPaneLayout(): ModelingPaneLayout {
return { return {
paletteWidth: 230, paletteWidth: 230,
@@ -1396,6 +1483,10 @@ function assignMissingAmesimGasDefinitionIndices(
function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNode>) { function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNode>) {
const updateNodeInternals = useUpdateNodeInternals(); const updateNodeInternals = useUpdateNodeInternals();
const symbolContainerRef = useRef<HTMLDivElement>(null);
const [symbolEnvelope, setSymbolEnvelope] = useState<SymbolEnvelope>(
DEFAULT_SYMBOL_ENVELOPE,
);
const rotation = normalizeNodeRotation(data.rotation); const rotation = normalizeNodeRotation(data.rotation);
const mirrored = Boolean(data.mirrored); const mirrored = Boolean(data.mirrored);
const vertical = rotation === 90 || rotation === 270; const vertical = rotation === 90 || rotation === 270;
@@ -1412,6 +1503,27 @@ function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNod
portAnchors, portAnchors,
); );
useLayoutEffect(() => {
if (!hasDedicatedSymbol) {
return;
}
const svg = symbolContainerRef.current?.querySelector<SVGSVGElement>(
".component-symbol-svg",
);
if (!svg) {
return;
}
const nextEnvelope = measuredSymbolEnvelope(svg);
setSymbolEnvelope((currentEnvelope) =>
currentEnvelope.left === nextEnvelope.left &&
currentEnvelope.top === nextEnvelope.top &&
currentEnvelope.width === nextEnvelope.width &&
currentEnvelope.height === nextEnvelope.height
? currentEnvelope
: nextEnvelope,
);
}, [hasDedicatedSymbol, symbol]);
useEffect(() => { useEffect(() => {
updateNodeInternals(id); updateNodeInternals(id);
}, [mirrored, rotation, id, updateNodeInternals]); }, [mirrored, rotation, id, updateNodeInternals]);
@@ -1459,10 +1571,21 @@ function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNod
<div className="node-symbol-anchor" aria-hidden="true"> <div className="node-symbol-anchor" aria-hidden="true">
<div <div
className="node-symbol" className="node-symbol"
ref={symbolContainerRef}
style={{ style={{
transform: `rotate(${rotation}deg) scaleX(${mirrored ? -1 : 1})`, transform: `rotate(${rotation}deg) scaleX(${mirrored ? -1 : 1})`,
}} }}
> >
<div
className="node-symbol-envelope"
data-symbol-envelope="true"
style={{
left: symbolEnvelope.left,
top: symbolEnvelope.top,
width: symbolEnvelope.width,
height: symbolEnvelope.height,
}}
/>
<ComponentSymbol symbol={symbol} surface="canvas" /> <ComponentSymbol symbol={symbol} surface="canvas" />
</div> </div>
</div> </div>
@@ -3211,6 +3334,9 @@ function FlowWorkbench() {
const renderedNodes = useMemo( const renderedNodes = useMemo(
() => () =>
nodes.map((node) => { nodes.map((node) => {
const hasDedicatedSymbol = hasComponentSymbol(
node.data.symbol ?? node.data.componentType,
);
const portConnectionStates = Object.fromEntries( const portConnectionStates = Object.fromEntries(
node.data.ports.map((port) => { node.data.ports.map((port) => {
const key = portConnectionKey(node.id, port.name); const key = portConnectionKey(node.id, port.name);
@@ -3242,11 +3368,18 @@ function FlowWorkbench() {
return [port.name, state]; return [port.name, state];
}), }),
); );
const className = [
node.className,
hasDedicatedSymbol ? "component-icon-node" : "",
validationSeverityByNode.has(node.id)
? `validation-${validationSeverityByNode.get(node.id)}`
: "",
]
.filter(Boolean)
.join(" ");
return { return {
...node, ...node,
className: validationSeverityByNode.has(node.id) className: className || undefined,
? `validation-${validationSeverityByNode.get(node.id)}`
: node.className,
data: { data: {
...node.data, ...node.data,
portConnectionStates, portConnectionStates,
@@ -3791,14 +3924,32 @@ function FlowWorkbench() {
id, id,
type: "simulationComponent", type: "simulationComponent",
position, position,
selected: true,
data, data,
}; };
recordHistory(); recordHistory();
setNodes((currentNodes) => [...currentNodes, node]); setNodes((currentNodes) => [
...currentNodes.map((currentNode) => ({
...currentNode,
selected: false,
})),
node,
]);
setEdges((currentEdges) =>
currentEdges.map((edge) => ({ ...edge, selected: false })),
);
setSelectedNodeId(id); setSelectedNodeId(id);
setSelectedEdgeId(null);
appendConsoleEntry("info", `已添加组件:${id}`); appendConsoleEntry("info", `已添加组件:${id}`);
}, },
[appendConsoleEntry, componentDefinitions, reactFlow, recordHistory, setNodes], [
appendConsoleEntry,
componentDefinitions,
reactFlow,
recordHistory,
setEdges,
setNodes,
],
); );
const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => { const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => {
+31 -3
View File
@@ -1,9 +1,10 @@
import { useLayoutEffect } from "react"; import { useLayoutEffect, useRef } from "react";
import { useReactFlow, useStore } from "@xyflow/react"; import { useReactFlow, useStore } from "@xyflow/react";
type AutoFitViewProps = { type AutoFitViewProps = {
enabled?: boolean; enabled?: boolean;
expectedNodeCount: number; expectedNodeCount: number;
fitOnMount?: boolean;
minZoom?: number; minZoom?: number;
padding?: number; padding?: number;
requestKey: number | string; requestKey: number | string;
@@ -12,6 +13,7 @@ type AutoFitViewProps = {
export function AutoFitView({ export function AutoFitView({
enabled = true, enabled = true,
expectedNodeCount, expectedNodeCount,
fitOnMount = false,
minZoom = 0.1, minZoom = 0.1,
padding = 0.18, padding = 0.18,
requestKey, requestKey,
@@ -33,15 +35,41 @@ export function AutoFitView({
return true; return true;
}); });
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
const lastHandledRequestKey = useRef<number | string | null>(
fitOnMount ? null : requestKey,
);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!enabled || !expectedNodesAreMeasured) { if (Object.is(lastHandledRequestKey.current, requestKey)) {
return; return;
} }
void fitView({ duration: 0, minZoom, padding }); if (!enabled || expectedNodeCount <= 0) {
lastHandledRequestKey.current = requestKey;
return;
}
if (!expectedNodesAreMeasured) {
return;
}
let secondFrame = 0;
const firstFrame = window.requestAnimationFrame(() => {
secondFrame = window.requestAnimationFrame(() => {
lastHandledRequestKey.current = requestKey;
void fitView({ duration: 0, minZoom, padding });
});
});
return () => {
window.cancelAnimationFrame(firstFrame);
if (secondFrame !== 0) {
window.cancelAnimationFrame(secondFrame);
}
};
}, [ }, [
enabled, enabled,
expectedNodeCount,
expectedNodesAreMeasured, expectedNodesAreMeasured,
fitView, fitView,
minZoom, minZoom,
+1
View File
@@ -1147,6 +1147,7 @@ export function SimulationResultsView({
<AutoFitView <AutoFitView
enabled={readOnlyNodes.length > 0} enabled={readOnlyNodes.length > 0}
expectedNodeCount={readOnlyNodes.length} expectedNodeCount={readOnlyNodes.length}
fitOnMount
requestKey={snapshot.id} requestKey={snapshot.id}
/> />
<Background gap={18} /> <Background gap={18} />
+48 -8
View File
@@ -1253,6 +1253,12 @@ textarea {
cursor: move; cursor: move;
} }
.flow-canvas .react-flow__nodesselection-rect {
border: 0;
background: transparent;
pointer-events: none;
}
.sim-node { .sim-node {
position: relative; position: relative;
width: 132px; width: 132px;
@@ -1280,9 +1286,30 @@ textarea {
transform-origin: center; transform-origin: center;
} }
.sim-node.selected .sim-node-frame { .sim-node.selected {
border-color: #1d6fb8; outline: 1px dashed #111827;
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.15); outline-offset: 4px;
}
.flow-canvas .react-flow__node.component-icon-node {
pointer-events: none !important;
}
.flow-canvas .react-flow__node.component-icon-node .sim-node {
pointer-events: none;
}
.flow-canvas .react-flow__node.component-icon-node .node-symbol-envelope,
.flow-canvas
.react-flow__node.component-icon-node
.port-handle.port-connection-idle,
.flow-canvas
.react-flow__node.component-icon-node
.port-handle.port-connection-origin,
.flow-canvas
.react-flow__node.component-icon-node
.port-handle.port-connection-compatible {
pointer-events: auto;
} }
.react-flow__node.validation-error .sim-node-frame { .react-flow__node.validation-error .sim-node-frame {
@@ -1306,6 +1333,7 @@ textarea {
} }
.node-symbol { .node-symbol {
position: relative;
display: grid; display: grid;
width: 112px; width: 112px;
height: 84px; height: 84px;
@@ -1314,10 +1342,27 @@ textarea {
} }
.node-symbol > .component-symbol { .node-symbol > .component-symbol {
position: relative;
z-index: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.node-symbol-envelope {
position: absolute;
z-index: 2;
box-sizing: border-box;
}
.sim-node.component-icon-only.selected {
outline: none;
}
.sim-node.component-icon-only.selected .node-symbol-envelope {
outline: 1px dashed #111827;
outline-offset: 3px;
}
.sim-node.symbol-presentation-bare .sim-node-frame, .sim-node.symbol-presentation-bare .sim-node-frame,
.sim-node.symbol-presentation-bare.selected .sim-node-frame, .sim-node.symbol-presentation-bare.selected .sim-node-frame,
.sim-node.component-icon-only .sim-node-frame, .sim-node.component-icon-only .sim-node-frame,
@@ -1334,11 +1379,6 @@ textarea {
background: transparent; background: transparent;
} }
.sim-node.symbol-presentation-bare.selected .component-symbol-svg,
.sim-node.component-icon-only.selected .component-symbol-svg {
filter: drop-shadow(0 0 3px rgba(29, 111, 184, 0.62));
}
.react-flow__node.validation-error .react-flow__node.validation-error
.sim-node.component-icon-only .sim-node.component-icon-only
.component-symbol-svg { .component-symbol-svg {
+85 -1
View File
@@ -428,6 +428,8 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
"data-connection-state", "data-connection-state",
"incompatible", "incompatible",
); );
await expect(sameNodePort).toHaveCSS("pointer-events", "none");
await expect(signalInput).toHaveCSS("pointer-events", "none");
await page.locator(".flow-canvas .react-flow__pane").click({ await page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 20, y: 20 }, position: { x: 20, y: 20 },
@@ -455,9 +457,23 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true"); await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0"); await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
await expect(compatiblePhysicalPort).toHaveCSS("opacity", "0"); await expect(compatiblePhysicalPort).toHaveCSS("opacity", "0");
await expect(firstPhysicalOutput).toHaveCSS("pointer-events", "none");
await expect(compatiblePhysicalPort).toHaveCSS("pointer-events", "none");
const physicalEdge = page.locator(".flow-canvas .react-flow__edge").first(); const physicalEdge = page.locator(".flow-canvas .react-flow__edge").first();
await physicalEdge.locator(".react-flow__edge-interaction").click({ force: true }); const edgeClickPoint = await physicalEdge
.locator(".react-flow__edge-interaction")
.evaluate((path: SVGPathElement) => {
const point = path.getPointAtLength(path.getTotalLength() / 2);
const matrix = path.getScreenCTM();
if (!matrix) {
throw new Error("Unable to resolve the edge screen position");
}
const screenPoint = new DOMPoint(point.x, point.y).matrixTransform(matrix);
return { x: screenPoint.x, y: screenPoint.y };
});
await page.mouse.click(edgeClickPoint.x, edgeClickPoint.y);
await expect(physicalEdge).toHaveClass(/selected|editor-edge-selected/);
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click(); await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "false"); await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "false");
@@ -567,6 +583,19 @@ test("兼容接口接近时吸附为无连线接触连接,拖开后自动断
'.port-handle[data-port-name="port_1"]', '.port-handle[data-port-name="port_1"]',
); );
await dragNodePortNearTarget(
page,
secondOrifice,
secondPort,
firstPort,
16,
);
await expect(firstPort).toHaveAttribute("data-connection-state", "idle");
await expect(secondPort).toHaveAttribute("data-connection-state", "idle");
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(14);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await dragNodePortNearTarget( await dragNodePortNearTarget(
page, page,
secondOrifice, secondOrifice,
@@ -801,3 +830,58 @@ test("旧工程保存的通用图标会按当前目录定义升级", async ({ pa
).toBeVisible(); ).toBeVisible();
await expect(restoredNode.locator('[data-component-symbol="pipe"]')).toHaveCount(0); await expect(restoredNode.locator('[data-component-symbol="pipe"]')).toHaveCount(0);
}); });
test("专用图标的选择框和鼠标命中范围贴合实际图形包络", async ({ page }) => {
await page.goto("/");
const helium = await dragSymbolToCanvas(page, "amesim_helium_medium", {
x: 430,
y: 280,
});
const simNode = helium.locator(".sim-node");
const envelope = helium.locator('[data-symbol-envelope="true"]');
const circle = helium.locator(".component-symbol-svg circle");
await expect(helium).toHaveClass(/component-icon-node/);
await expect(helium).toHaveCSS("pointer-events", "none");
await expect(simNode).toHaveCSS("pointer-events", "none");
await expect(envelope).toHaveCSS("outline-style", "dashed");
await expect(envelope).toHaveCSS("pointer-events", "auto");
await expect(simNode).toHaveCSS("outline-style", "none");
const [nodeBounds, envelopeBounds, circleBounds, paneBounds] =
await Promise.all([
helium.boundingBox(),
envelope.boundingBox(),
circle.boundingBox(),
page.locator(".flow-canvas .react-flow__pane").boundingBox(),
]);
expect(nodeBounds).not.toBeNull();
expect(envelopeBounds).not.toBeNull();
expect(circleBounds).not.toBeNull();
expect(paneBounds).not.toBeNull();
expect(envelopeBounds!.width).toBeLessThan(nodeBounds!.width * 0.7);
expect(
Math.abs(envelopeBounds!.width - envelopeBounds!.height),
).toBeLessThan(3);
expect(envelopeBounds!.width - circleBounds!.width).toBeGreaterThanOrEqual(0);
expect(envelopeBounds!.width - circleBounds!.width).toBeLessThanOrEqual(4);
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
await expect(helium).not.toHaveClass(/selected/);
const oldTransparentMargin = {
x: nodeBounds!.x + 5,
y: envelopeBounds!.y + envelopeBounds!.height / 2,
};
expect(oldTransparentMargin.x).toBeLessThan(envelopeBounds!.x - 8);
await page.mouse.click(oldTransparentMargin.x, oldTransparentMargin.y);
await expect(helium).not.toHaveClass(/selected/);
await page.mouse.click(
envelopeBounds!.x + envelopeBounds!.width / 2,
envelopeBounds!.y + envelopeBounds!.height / 2,
);
await expect(helium).toHaveClass(/selected/);
await expect(envelope).toHaveCSS("outline-style", "dashed");
});
+39
View File
@@ -6,6 +6,31 @@ import {
wideProject, wideProject,
} from "./fixtures"; } from "./fixtures";
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const zoomOut = page.locator(".flow-canvas .react-flow__controls-zoomout");
await zoomOut.click();
await zoomOut.click();
await page.waitForTimeout(250);
const viewportBeforeDrop = await viewport.getAttribute("style");
expect(viewportBeforeDrop).toBeTruthy();
await page
.getByRole("button", { name: "通用测试元件", exact: true })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(1);
await page.waitForTimeout(250);
await expect(viewport).toHaveAttribute("style", viewportBeforeDrop!);
await page.getByRole("button", { name: "适配视图", exact: true }).click();
await expect
.poll(() => viewport.getAttribute("style"))
.not.toBe(viewportBeforeDrop);
});
test("加载本地工程后自动适配建模画布", async ({ page }) => { test("加载本地工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page); await prepareApp(page);
await page.addInitScript((project) => { await page.addInitScript((project) => {
@@ -28,6 +53,19 @@ test("加载本地工程后自动适配建模画布", async ({ page }) => {
await expect(consolePanel).toContainText("工程已加载"); await expect(consolePanel).toContainText("工程已加载");
}); });
test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
await page.locator('input[type="file"]').setInputFiles({
name: "wide-project.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(wideProject)),
});
await expectAllNodesInsideCanvas(page, ".flow-canvas");
});
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => { test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page); await prepareApp(page);
await page.addInitScript((project) => { await page.addInitScript((project) => {
@@ -80,6 +118,7 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
).toHaveCount(0); ).toHaveCount(0);
await page.getByRole("tab", { name: "建模" }).click(); await page.getByRole("tab", { name: "建模" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
await expect(freeModelingHandle).toHaveAttribute( await expect(freeModelingHandle).toHaveAttribute(
"data-connection-state", "data-connection-state",
"idle", "idle",
+112
View File
@@ -44,3 +44,115 @@ test("未知图形元件使用带端口和名称的默认节点,并将动作
await expect(page.locator(".validation-section")).toHaveCount(0); await expect(page.locator(".validation-section")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("仿真结果"); await expect(page.locator(".properties")).not.toContainText("仿真结果");
}); });
test("拖入组件后仅选择新组件,变换操作不会继续作用于旧选择", async ({
page,
}) => {
await page.goto("/");
const paletteItem = page.getByRole("button", { name: /通用测试元件/ });
const pane = page.locator(".flow-canvas .react-flow__pane");
await paletteItem.dragTo(pane, { targetPosition: { x: 220, y: 220 } });
const firstNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
await expect(firstNode).toHaveClass(/selected/);
await expect(firstNode.locator(".sim-node")).toHaveCSS(
"outline-style",
"dashed",
);
await paletteItem.dragTo(pane, { targetPosition: { x: 480, y: 340 } });
const secondNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_2"]',
);
await expect(firstNode).not.toHaveClass(/selected/);
await expect(secondNode).toHaveClass(/selected/);
await expect(firstNode.locator(".sim-node")).toHaveCSS("outline-style", "none");
await expect(secondNode.locator(".sim-node")).toHaveCSS(
"outline-style",
"dashed",
);
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
await expect(firstNode.locator(".sim-node")).not.toHaveClass(
/orientation-vertical/,
);
await expect(secondNode.locator(".sim-node")).toHaveClass(
/orientation-vertical/,
);
});
test("框选结束后隐藏整体包围框并保留每个组件的虚线选中框", async ({
page,
}) => {
await page.goto("/");
const paletteItem = page.getByRole("button", { name: /通用测试元件/ });
const pane = page.locator(".flow-canvas .react-flow__pane");
await paletteItem.dragTo(pane, { targetPosition: { x: 220, y: 220 } });
await paletteItem.dragTo(pane, { targetPosition: { x: 480, y: 340 } });
const firstNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
const secondNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_2"]',
);
const [paneBounds, firstBounds, secondBounds] = await Promise.all([
pane.boundingBox(),
firstNode.boundingBox(),
secondNode.boundingBox(),
]);
expect(paneBounds).not.toBeNull();
expect(firstBounds).not.toBeNull();
expect(secondBounds).not.toBeNull();
const startX = Math.max(
paneBounds!.x + 4,
Math.min(firstBounds!.x, secondBounds!.x) - 14,
);
const startY = Math.max(
paneBounds!.y + 4,
Math.min(firstBounds!.y, secondBounds!.y) - 14,
);
const endX = Math.min(
paneBounds!.x + paneBounds!.width - 4,
Math.max(
firstBounds!.x + firstBounds!.width,
secondBounds!.x + secondBounds!.width,
) + 14,
);
const endY = Math.min(
paneBounds!.y + paneBounds!.height - 4,
Math.max(
firstBounds!.y + firstBounds!.height,
secondBounds!.y + secondBounds!.height,
) + 14,
);
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 12 });
await page.mouse.up();
await expect(firstNode).toHaveClass(/selected/);
await expect(secondNode).toHaveClass(/selected/);
await expect(firstNode.locator(".sim-node")).toHaveCSS(
"outline-style",
"dashed",
);
await expect(secondNode.locator(".sim-node")).toHaveCSS(
"outline-style",
"dashed",
);
await expect(page.locator(".flow-canvas .react-flow__selection")).toHaveCount(0);
const aggregateSelection = page.locator(
".flow-canvas .react-flow__nodesselection-rect",
);
await expect(aggregateSelection).toHaveCount(1);
await expect(aggregateSelection).toHaveCSS("border-top-width", "0px");
await expect(aggregateSelection).toHaveCSS("pointer-events", "none");
});