让Floating UI根据需要固定弹出框的位置

前端开发 2026-07-11

我还没能让Floating UI与 React一起按我想要的方式工作。我创建了一个跟随鼠标移动的弹出组件。我可以设置 pinnedAt 属性让弹出框固定在一个位置,但Floating UI仍会在其容器滚动时持续更新已固定弹出框的位置,使其相对于视口保持在同一位置。我希望一旦固定后,它能够随容器一起滚动。

我该如何实现?这是我的组件代码:

import scopedCss from '@/components/TechNodePopup.module.css';

import { useEffect, useRef } from "react";
import clsx from "clsx";
import {
    useFloating,
    offset,
    flip,
    shift,
    autoUpdate
} from "@floating-ui/react";
import { getAlignment } from "@floating-ui/utils";
import type { AbilityType } from "@/types/AbilityType";
import type { TechNodeWithMeta } from "@/types/TechNodeWithMeta";
import TechNodePopupContent from './TechNodePopupContent';

export interface TechNodePopupProps {
    abilityType: AbilityType;
    abilityName?: string;
    nodeData: TechNodeWithMeta;
    getTechNameFromId: (id: string) => string;
    initialX: number;
    initialY: number;
    pinnedAt?: { x: number; y: number };
}

export default function TechNodePopup({ abilityType, abilityName, nodeData, getTechNameFromId, initialX, initialY, pinnedAt }: TechNodePopupProps) {
    const mouseRef = useRef({ x: initialX, y: initialY });

    const {
        refs,
        floatingStyles,
        update,
    } = useFloating({
        strategy: "absolute",
        placement: "top-end",
        middleware: [
            offset(({ placement }) => {
                return {
                    mainAxis: 20,
                    crossAxis: getAlignment(placement) === "start" ? 20 : -20
                };
            }),
            flip({
                mainAxis: true,
                crossAxis: true,
            }),
            shift({
                mainAxis: true,
                crossAxis: true,
            }),
        ],
        whileElementsMounted: autoUpdate,
    });

    // Virtual element representing the mouse cursor
    useEffect(() => {
        refs.setReference({
            getBoundingClientRect() {
                const pos = pinnedAt ?? mouseRef.current;
                return {
                    x: pos.x,
                    y: pos.y,
                    top: pos.y,
                    left: pos.x,
                    right: pos.x,
                    bottom: pos.y,
                    width: 0,
                    height: 0,
                };
            },
        });
    }, [refs, pinnedAt]);

    // Get floating element to position itself based on mouse cursor upon mouse movement
    useEffect(() => {
        function handleMouseMove(e: MouseEvent) {
            mouseRef.current = { x: e.clientX, y: e.clientY };
            if (pinnedAt) { return; }

            update();
        }

        window.addEventListener("mousemove", handleMouseMove);
        return () => {
            window.removeEventListener("mousemove", handleMouseMove);
        };
    }, [update, pinnedAt]);

    // Calc tech type class (needed here and in content component)
    let techTypeClass = "";
    if (abilityType === "tech") {
        techTypeClass = clsx(nodeData.techType && `tech-${nodeData.techType.toLowerCase()}`);
    }

    return (
        <div
            ref={refs.setFloating}
            className={clsx("techNode", scopedCss.techNode, "selected", "highlighted", techTypeClass, pinnedAt && scopedCss.pinned)}
            style={{
                ...floatingStyles,
                width: "410px",
                zIndex: 999,
            }}
        >
            <TechNodePopupContent
                abilityType={abilityType}
                abilityName={abilityName}
                techTypeClass={techTypeClass}
                nodeData={nodeData}
                getTechNameFromId={getTechNameFromId}
            />
        </div>
    );
}

解决方案

你所看到的行为是autoUpdate在按它的设计去做。它在让一个“浮动”元素与其“参考”元素保持同步,即使你滚动。当你的虚拟参考,即getBoundingClientRect函数,返回的是相对于视口的坐标(clientX、clientY)时,问题就出现了。当容器滚动时,autoUpdate会重新执行该函数。它看到相同的坐标并将弹出框移动到视口中恰好相同的位置。解决方法是切换autoUpdate,使弹出框在固定后“粘”在内容上并随其滚动;你需要在pinnedAt存在时关闭autoUpdate。Floating UI的 whileElementsMounted选项允许你使用一个函数。如果你基于条件返回undefined或一个清理函数,你就可以有效地“冻结”定位逻辑。

以上大部分内容都已经解决;还需要做一些其他的改动:

    whileElementsMounted(reference, floating, updateFn) {
        if (pinnedAt) {
            // pinned mode: do nothing, return no-op cleanup
            return () => { };
        }

        // mouse cursor floating mode: run autoUpdate as usual, return its cleanup
        const cleanup = autoUpdate(reference, floating, updateFn);
        return () => {
            cleanup();
        };
    }

...作为附加,这还包括一个改动,当 pinnedAt 被设置为某个新值而弹出框已经固定时,会让弹出框只移动一次:

    // One-off moving of popup if pinnedAt changed while already pinned
    useEffect(() => {
        if (pinnedAt) {
            update();
        }
    }, [pinnedAt, update]);
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章