设置 scrollTop 导致的 BUG

624 阅读1分钟

最近老项目需要用 Vue2 实现图片空间,效果如下:

image.png

其中有个点击右键弹出菜单的功能,当所在容器发生滚动事件时,需要隐藏弹出的右键菜单,实现代码如下:

<template>
  <div class="ih-contextmenu" @contextmenu.prevent.stop="">
    <ul>
      <li v-for="(menu, i) in menus" :key="i">
        <button
          type="button"
          v-if="menu != null"
          class="ih-contextmenu-command"
          @click.prevent.stop="$emit('click', menu.key, data)"
          ref="command"
        >
          <span class="ih-contextmenu-icon" v-if="hasIcon">
            <ih-icon :name="menu.icon" v-if="menu.icon" />
          </span>
          <span class="ih-contextmenu-text">{{ menu.label }}</span>
        </button>
      </li>
    </ul>
  </div>
</template>

<script>
import IhIcon from "@/components/IhIcon";
import { alignPoint } from "dom-align";

/**
 * 判断元素是否可以滚动
 *
 * @link https://segmentfault.com/a/1190000021934483
 *
 * @param {Node} el
 * @return {boolean}
 */
function isScrollable(el) {
  if (!(el instanceof HTMLElement)) {
    console.log("fuck off");
    return false;
  }

  if (el.scrollTop > 0) {
    return true;
  }

  el.scrollTop++;
  // 元素不能滚动的话,scrollTop 设置不会生效,还会置为 0
  const top = el.scrollTop;
  // 重置滚动位置
  top && (el.scrollTop = 0);
  return top > 0;
}

/**
 * 获取滚动容器
 * @param el
 * @return {HTMLElement|null}
 */
export function getScrollContainer(el) {
  let container = el.parentNode;
  while (container) {
    if (isScrollable(container)) {
      return container;
    }
    container = container.parentNode;
  }
  return null;
}

export default {
  name: "IhContextmenu",

  components: {
    IhIcon,
  },

  props: {
    menus: {
      type: Array,
      required: true,
      validator(menus) {
        return menus.every(
          (m) => m == null || (m.label != null && m.key != null)
        );
      },
    },
    points: {
      type: Object,
      required: true,
    },
    // 附加数据
    data: {},
  },

  computed: {
    hasIcon() {
      return this.menus.some((m) => m?.icon != null);
    },
  },

  methods: {
    requestLayout() {
      alignPoint(this.$el, this.points, {
        points: ["tl", "tr"],
        overflow: { adjustX: true, adjustY: true },
        useCssTransform: true,
      });
    },
    requestFocus() {
      const commands = this.$refs.command;
      if (!commands?.length) return;
      commands[0].focus();
    },
    // 快捷键(快速关闭)
    bindKeyboardEvent() {
      const window = this.$el.ownerDocument.defaultView;
      const tabKeydownListener = (event) => {
        if (event.code === "Escape") {
          this.$emit("escape");
          this.$emit("close");
        }
      };
      window.addEventListener("keydown", tabKeydownListener, true);
      this.$once("hook:beforeDestroy", () => {
        window.removeEventListener("keydown", tabKeydownListener, true);
      });
    },
    // 滚动关闭
    bindScrollEvent() {
      const container = getScrollContainer(this.$el) ?? this.$el.ownerDocument.defaultView;
      const scrollListener = () => {
        // FIXME:利用设置 scrollTop 值来检测元素是否可滚动,导致的 BUG,有点莫名奇妙
        // 解决方法:忽略掉第一次触发的滚动事件。
        // 判断可滚动参考:https://segmentfault.com/a/1190000021934483
        if (this.closableOfScroll) {
          this.$emit("close");
        } else {
          this.closableOfScroll = true;
        }
      };
      if (container != null) {
          container.addEventListener("scroll", scrollListener, true);
          this.$once("hook:beforeDestroy", () => {
            container.removeEventListener("scroll", scrollListener, true);
          });
      }
    },
  },

  mounted() {
    this.requestLayout();
    this.requestFocus();
    this.bindKeyboardEvent();
    this.bindScrollEvent();
  },

  updated() {
    this.requestLayout();
  },
};
</script>

在组件 mounted 的时候,调用 this.bindScrollEvent(); 绑定滚动关闭右键菜单的功能后**,必须手动对可滚动容器触发滚动事件**后,才能够唤起右键菜单。

解决方法有点取巧,代码还是在 bindScrollEvent 里面:

// 滚动关闭
    bindScrollEvent() {
      const container = getScrollContainer(this.$el) ?? this.$el.ownerDocument.defaultView;
      const scrollListener = () => {
        // FIXME:利用设置 scrollTop 值来检测元素是否可滚动,导致的 BUG,有点莫名奇妙
        // 解决方法:忽略掉第一次触发的滚动事件。
        // 判断可滚动参考:https://segmentfault.com/a/1190000021934483
        if (this.closeByScroll) {
          this.$emit("close");
        } else {
          this.closeByScroll = true;
        }
      };
      if (container != null) {
          container.addEventListener("scroll", scrollListener, true);
          this.$once("hook:beforeDestroy", () => {
            container.removeEventListener("scroll", scrollListener, true);
          });
      }
    },

弄不明白为什么会出现这个问题。那位大佬帮忙看看。

源码:tint/image-hosting: 图片空间组件 (github.com)

另外再问个问题,如何限制键盘Tab键响应的有效范围,例如:

image.png

我希望点击键盘的 Tab 按键时,响应的范围在唤起的右键菜单内循环。