5180 fichiers 38.5 Mo 0 téléchargements 350 vues mis à jour il y a 7 heures ui
ContextMenuView.tsx 7.4 Ko Télécharger le fichier
import { GetStage, OctaneRectangle, RoomObjectType } from '@octane/renderer';
import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AddAnimationTickerCallback, FixedSizeStack, GetRoomObjectBounds, GetRoomObjectScreenLocation, GetRoomSession } from '../../../../api';
import { BaseProps } from '../../../../common';
import { NativeTextTree } from '../../../../common/native-text/NativeTextTree';
import { ContextMenuCaretView } from './ContextMenuCaretView';

interface ContextMenuViewProps extends BaseProps<HTMLDivElement> {
    objectId: number;
    category: number;
    userType?: number;
    fades?: boolean;
    onClose: () => void;
    collapsable?: boolean;
    tallAvatarOffset?: number;
    maximumVerticalLeadRatio?: number;
    freezePositionOnHover?: boolean;
    repositionKey?: string | number;
    showCaretIcon?: boolean;
    /** AIR AvatarContextInfoButtonView: the bubble sits this far above the anchor (negative is up), for every unit type. */
    anchorGap?: number;
}

const LOCATION_STACK_SIZE = 25;
const BUBBLE_DROP_SPEED = 3;
const FADE_DELAY = 5000;
const FADE_LENGTH = 75;
const SPACE_AROUND_EDGES = 10;

export const ContextMenuView: FC<ContextMenuViewProps> = ({
    objectId = -1,
    category = -1,
    userType = -1,
    fades = false,
    onClose,
    classNames = [],
    style = {},
    children = null,
    collapsable = false,
    tallAvatarOffset = 15,
    maximumVerticalLeadRatio = null,
    freezePositionOnHover = false,
    repositionKey = null,
    showCaretIcon = true,
    anchorGap = null,
    onMouseEnter = null,
    onMouseLeave = null,
    ...rest
}) =>
{
    const [pos, setPos] = useState<{ x: number; y: number }>({ x: null, y: null });
    const [opacity, setOpacity] = useState(1);
    const [isFading, setIsFading] = useState(false);
    const [isCollapsed, setIsCollapsed] = useState(false);
    const elementRef = useRef<HTMLDivElement>(null);
    const isPointerOverRef = useRef(false);
    const forcePositionUpdateRef = useRef(false);
    const stackRef = useRef<FixedSizeStack>(new FixedSizeStack(LOCATION_STACK_SIZE));
    const maxStackRef = useRef(-1000000);

    const updatePosition = useCallback(
        (bounds: OctaneRectangle, location: { x: number; y: number }) =>
        {
            if (
                !bounds ||
                !location ||
                !elementRef.current ||
                (freezePositionOnHover && isPointerOverRef.current && !forcePositionUpdateRef.current)
            )
                return;

            let offset = -elementRef.current.offsetHeight;
            if (anchorGap !== null)
            {
                offset += anchorGap;
            }
            else if (userType > -1 && [RoomObjectType.USER, RoomObjectType.BOT, RoomObjectType.RENTABLE_BOT].includes(userType))
            {
                offset += bounds.height > 50 ? tallAvatarOffset : 0;
            }
            else
            {
                offset -= 14;
            }

            stackRef.current.addValue(location.y - bounds.top);
            let maxStack = stackRef.current.getMax();
            if (maxStack < maxStackRef.current - BUBBLE_DROP_SPEED)
            {
                maxStack = maxStackRef.current - BUBBLE_DROP_SPEED;
            }
            maxStackRef.current = maxStack;

            const deltaY = location.y - maxStack;
            let x = Math.round(location.x - elementRef.current.offsetWidth / 2);
            let y = Math.round(deltaY + offset);

            if (maximumVerticalLeadRatio !== null)
            {
                const maximumVerticalLead = Math.trunc(bounds.height * maximumVerticalLeadRatio);
                const minimumY = Math.trunc(bounds.top + offset) - maximumVerticalLead;

                y = Math.max(y, minimumY);
            }

            const stage = GetStage();
            const maxLeft = stage.width - elementRef.current.offsetWidth - SPACE_AROUND_EDGES;
            const maxTop = stage.height - elementRef.current.offsetHeight - SPACE_AROUND_EDGES;

            x = Math.max(SPACE_AROUND_EDGES, Math.min(x, maxLeft));
            y = Math.max(SPACE_AROUND_EDGES, Math.min(y, maxTop));

            setPos((previous) => (previous.x === x && previous.y === y ? previous : { x, y }));
            forcePositionUpdateRef.current = false;
        },
        [anchorGap, freezePositionOnHover, maximumVerticalLeadRatio, tallAvatarOffset, userType]
    );

    const getClassNames = useMemo(() =>
    {
        const classes = ['octane-context-menu', pos.x !== null ? 'visible' : 'invisible'];
        if (isCollapsed) classes.push('menu-hidden');
        return [...classes, ...classNames];
    }, [pos.x, isCollapsed, classNames]);

    const getStyle = useMemo(
        () => ({
            left: pos.x ?? 0,
            top: pos.y ?? 0,
            transition: isFading ? 'opacity 75ms linear' : undefined,
            opacity,
            ...style
        }),
        [pos, opacity, isFading, style]
    );

    useEffect(() =>
    {
        if (!elementRef.current) return;

        const update = () =>
        {
            if (!elementRef.current) return;
            const roomSession = GetRoomSession();

            if (!roomSession)
            {
                onClose();
                return;
            }

            const bounds = GetRoomObjectBounds(roomSession.roomId, objectId, category);
            const location = GetRoomObjectScreenLocation(roomSession.roomId, objectId, category);
            updatePosition(bounds, location);
        };

        return AddAnimationTickerCallback(update);
    }, [objectId, category, updatePosition, onClose]);

    useEffect(() =>
    {
        if (!fades) return;

        const timeout = setTimeout(() =>
        {
            setIsFading(true);
            setTimeout(onClose, FADE_LENGTH);
        }, FADE_DELAY);

        return () => clearTimeout(timeout);
    }, [fades, onClose]);

    useEffect(() =>
    {
        if (!isFading) return;

        const frame = requestAnimationFrame(() => setOpacity(0));

        return () => cancelAnimationFrame(frame);
    }, [isFading]);

    useEffect(() =>
    {
        forcePositionUpdateRef.current = true;
    }, [repositionKey]);

    return (
        <NativeTextTree>
            <div
                ref={elementRef}
                className={getClassNames.join(' ')}
                style={getStyle}
                onMouseEnter={(event) =>
                {
                    isPointerOverRef.current = true;
                    onMouseEnter?.(event);
                }}
                onMouseLeave={(event) =>
                {
                    isPointerOverRef.current = false;
                    onMouseLeave?.(event);
                }}
                {...rest}
            >
                {!(collapsable && isCollapsed) && children}
                {collapsable && (
                    <ContextMenuCaretView
                        collapsed={isCollapsed}
                        showIcon={showCaretIcon}
                        onClick={() =>
                        {
                            // AIR swaps to a separate 45x35 minimized bubble and explicitly
                            // forces a position update even while the pointer is hovering it.
                            forcePositionUpdateRef.current = true;
                            setIsCollapsed((prev) => !prev);
                        }}
                    />
                )}
            </div>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
3En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser