Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] useRightClickMenu / useContextMenu #1771

Open
GrinZero opened this issue Jul 19, 2022 · 12 comments
Open

[RFC] useRightClickMenu / useContextMenu #1771

GrinZero opened this issue Jul 19, 2022 · 12 comments

Comments

@GrinZero
Copy link

GrinZero commented Jul 19, 2022

用于添加自定义右键菜单,只需要传入需要展现的菜单以及对应的容器

API

经过一定讨论,基本形成如下的结论

type RightClickMenuInstance = [number, number, (visible: boolean) => void];
export const useRightClickMenu = (
  menu: JSX.Element | (() => JSX.Element),
  target:
    | HTMLElement
    | (() => HTMLElement)
    | React.MutableRefObject<HTMLElement> = document.body
): RightClickMenuInstance

DEMO

//default: 默认全局绑定
useRightClickMenu(<Menu/>)

// ref: 可选ref绑定
const ref=useRef();
useRightClickMenu(<Menu/>,ref );
return <div ref={ref}></div>

// HTMLElement: 可选直接通过element绑定
const container=document.getElementById("xxx")
useRightClickMenu(<Menu/>,container);

// ()=>HTMLElement
useRightClickMenu(<Menu/>,()=>document.getElementById("xxx"));
@GpingFeng
Copy link
Contributor

这里的设计是否可以不传入菜单以及对应的容器。
参考这篇文章返回值可以设计为:

xPos, yPos, showMenu。

@GrinZero
Copy link
Author

这里的设计是否可以不传入菜单以及对应的容器。 参考这篇文章返回值可以设计为:

xPos, yPos, showMenu。

我认为你说的有道理,这是改造之后的useRightClickMenu

type RightClickMenuInstance = [number, number, (visible: boolean) => void];
export const useRightClickMenu = (
  menu: JSX.Element | (() => JSX.Element),
  container: HTMLElement | Element = document.body,
  overflow: 'auto' | 'visible' = 'auto'
): RightClickMenuInstance
  1. 返回xPos,yPos确实不错,也应该返回详细点,这里对应前两个number
  2. 原版的容器意义并不是很大,所以我改造了一下,现在传入容器是可以将监听contextmenu注册在对应容器上,不传的话默认绑定body。如图,某些iwiki只有左侧目录右键会弹出自定义菜单
    image
  3. 关于不传入菜单,其实我的思路和该文章的简单区别是,他这边只做了获取pageX,pageY,而我还做了一个额外事情是对于弹出层应该弹出位置的计算。即如果使用该文章的组件会发现,在最右侧、最下方都会存在菜单超出页面边界,而我由于将menu直接传入hook,我在进行弹出的时候并不是直接使用e.pageX,e.pageY,而是先进行了边界情况的相关计算,再通过不同的模式(overflow="auto")来给菜单赋予位置。而想做到这点一定需要将组件传入hook

@GpingFeng
Copy link
Contributor

这个 overflow 不是很理解。假如是控制菜单的展示位置,感觉我们自动帮它处理了就可以了「也就是你提到的解决不传入 menu 的文图」,估计就是 auto?还有其他的模式代表什么含义呢?

@GrinZero
Copy link
Author

这个 overflow 不是很理解。假如是控制菜单的展示位置,感觉我们自动帮它处理了就可以了「也就是你提到的解决不传入 menu 的文图」,估计就是 auto?还有其他的模式代表什么含义呢?

  • overflow==="auto"
    auto的情况下,会进行边界情况处理,保证菜单显示位置不超出容器。
  • overflow==="visible"
    visible,有些场景我们不需要做边界计算,比如上边这个左侧目录右键的案例中,我们应当允许超出这个容器,否则菜单的显示是不符合预期的。

同时我在思考,要不要有选择的把具体到x轴y轴的overflow交给用户选择

@GpingFeng
Copy link
Contributor

这个 overflow 不是很理解。假如是控制菜单的展示位置,感觉我们自动帮它处理了就可以了「也就是你提到的解决不传入 menu 的文图」,估计就是 auto?还有其他的模式代表什么含义呢?

  • overflow==="auto"
    auto的情况下,会进行边界情况处理,保证菜单显示位置不超出容器。
  • overflow==="visible"
    visible,有些场景我们不需要做边界计算,比如上边这个左侧目录右键的案例中,我们应当允许超出这个容器,否则菜单的显示是不符合预期的。

同时我在思考,要不要有选择的把具体到x轴y轴的overflow交给用户选择

我理解我们这个边界处理应该是针对页面的吧?不应该针对容器?
另外这个 DOM 的入参设计需要符合 ahooks 的处理规范 哈。

@GrinZero
Copy link
Author

我理解我们这个边界处理应该是针对页面的吧?不应该针对容器?
另外这个 DOM 的入参设计需要符合 ahooks 的处理规范 哈。

你说的有道理,边界处理确实不应该针对容器,调整了一下,现在同时也支持了ref:

type RightClickMenuInstance = [number, number, (visible: boolean) => void];
export const useRightClickMenu = (
  menu: JSX.Element | (() => JSX.Element),
  target:
    | HTMLElement
    | (() => HTMLElement)
    | React.MutableRefObject<HTMLElement> = document.body
): RightClickMenuInstance

那么现在overflow去掉了,弹窗将依据页面高宽进行边界处理

@GpingFeng
Copy link
Contributor

@brickspert @crazylxr
大佬们怎么看,我个人觉得这个场景可以支持一下?

@li-jia-nan
Copy link
Collaborator

1111
2222

简单实现了一下,大佬们看看我这个思路对不对

@GrinZero
Copy link
Author

1111 2222

简单实现了一下,大佬们看看我这个思路对不对

待确定下来我提个PR吧...你这是按照文章做的目测

@li-jia-nan
Copy link
Collaborator

1111 2222
简单实现了一下,大佬们看看我这个思路对不对

待确定下来我提个PR吧...你这是按照文章做的目测

OK,你来吧,我学习一下

@xmsz
Copy link

xmsz commented Nov 10, 2022

情况如何

@GrinZero
Copy link
Author

情况如何

很糟糕,没有进展,但是你可以先尝试使用下边的代码

// useAppendRootNode.tsx
import { useEffect } from 'react';
import ReactDOM from 'react-dom';

export const isBrowser = () => typeof window !== 'undefined';

interface AppendRootNodeInstance {
  show: () => void;
  destory: () => void;
}

type AppendRootNodeResult = [string, AppendRootNodeInstance];

export const useAppendRootNode = (
  id: string,
  render: (() => JSX.Element) | JSX.Element,
  createElement?: () => HTMLElement,
  parent: HTMLElement = isBrowser() ? document.body : null
): AppendRootNodeResult => {
  const show = () => {
    if (document.getElementById(id)) {
      return;
    }
    const ele = createElement?.() ?? document.createElement('div');
    ele.id = id;
    parent.append(ele);
  };
  const destory = () => {
    const ele = document.getElementById(id);
    if (!ele) return;
    parent.removeChild(ele);
  };

  useEffect(() => {
    show();
    return () => {
      destory();
    };
  }, []);

  useEffect(() => {
    ReactDOM.render(
      render instanceof Function ? render() : render,
      document.getElementById(id)
    );
  }, [render, id]);
  return [id, { show, destory }];
};

export default useAppendRootNode;


// useRightClickMenu.tsx
import React, { useEffect, useState, useRef } from 'react';
import { useAppendRootNode } from './useAppendRootNode';
import { throttle } from 'lodash-es';

type RightClickMenuInstance = [number, number, (visible: boolean) => void];
export const useRightClickMenu = (
  menu: JSX.Element | (() => JSX.Element),
  target:
    | HTMLElement
    | (() => HTMLElement)
    | React.MutableRefObject<HTMLElement> = document.body
): RightClickMenuInstance => {
  const [contextMenu, setContextMenu] = useState({
    x: 0,
    y: 0,
    visible: true,
  });
  const memoAttr = useRef(null);
  const ref = useRef(null);
  const container = (() => {
    if (!target) return null;
    if (target instanceof Function) {
      return target();
    }
    // @ts-ignore
    if (target.current !== void 0) {
      // @ts-ignore
      return target.current;
    }
    return target;
  })();

  useAppendRootNode(
    'right-click-context-menu',
    <div
      className="absolute"
      ref={ref}
      style={{
        position: 'absolute',
        left: contextMenu.x,
        top: contextMenu.y,
        display: contextMenu.visible ? 'flex' : 'none',
        zIndex: 9999999,
        visibility: memoAttr.current === null ? 'hidden' : 'visible',
      }}
    >
      {menu instanceof Function ? menu() : menu}
    </div>
  );

  useEffect(() => {
    if (!ref.current) return;
    const { clientHeight, clientWidth } = ref.current;
    memoAttr.current = {
      clientHeight,
      clientWidth,
    };
    setContextMenu({
      x: 0,
      y: 0,
      visible: false,
    });
  }, [ref.current]);

  useEffect(() => {
    if (!container) return;
    const handleContextMenuClick = (e: PointerEvent) => {
      e.preventDefault();
      const { pageX, pageY } = e;

      const { clientHeight, clientWidth } = memoAttr.current;
      const {
        scrollHeight: windowHeight,
        scrollWidth: windowWidth,
      } = document.body;

      if (clientHeight > windowHeight || clientWidth > windowWidth) {
        throw new Error('the menu is longer than the browser');
      }

      const x = clientWidth + pageX > windowWidth ? pageX - clientWidth : pageX;

      const y =
        clientHeight + pageY > windowHeight ? pageY - clientHeight : pageY;
      setContextMenu({
        x,
        y,
        visible: true,
      });
    };
    const handleOutsideClick = (
      e: PointerEvent & { path: Array<HTMLElement> }
    ) => {
      if (e.path.includes(ref.current)) {
        return;
      }
      setContextMenu({
        ...contextMenu,
        visible: false,
      });
    };
    const handleThrottleOutSideClick = throttle(handleOutsideClick, 800);

    container.addEventListener('contextmenu', handleContextMenuClick);
    document.addEventListener('click', handleOutsideClick);
    document.addEventListener('scroll', handleThrottleOutSideClick);
    window.addEventListener('resize', handleThrottleOutSideClick);

    return () => {
      container.removeEventListener('contextmenu', handleContextMenuClick);
      document.removeEventListener('click', handleOutsideClick);
      document.removeEventListener('scroll', handleThrottleOutSideClick);
      window.removeEventListener('resize', handleThrottleOutSideClick);
    };
  }, [container]);

  return [
    contextMenu.x,
    contextMenu.y,
    visible => {
      setContextMenu({
        ...contextMenu,
        visible,
      });
    },
  ];
};

export default useRightClickMenu;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants