Element Plus s dialog弹出框 设置拖拽属性之后,缩小浏览器窗口, 弹出框位置发生偏移

原因:Element Plus 把拖拽偏移存在 useDraggable 的闭包里,外部无法访问。所以 resize 修正 DOM 的 transform 后,闭包内的旧偏移并不会同步。

解决方法:手动实现dialog内置的拖拽效果

<template>
  <el-dialog
    :draggable="false"
    v-model="visibleDilog"
    class="tn-dialog"
    :class="{ 'hide-footer': hideFooter }"
    top="50px"
    :fullscreen="fullscreen"
    :scrollBody="scrollBody"
    :before-close="beforeClose"
    :destroy-on-close="destroyOnClose"
  >
    <template #header>
      <div class="header" :class="{ 'is-draggable': isDraggable }" :style="headerStyles" @mousedown="onDragStart">
        <div class="left-title">
          <slot name="left-title" />
        </div>

        <slot name="title">
          <span>{{ title }}</span>
        </slot>

        <div class="operation">
          <el-space>
            <slot name="operation"></slot>
          </el-space>
          <!-- 默认全屏的情况下 不再显示全屏按钮 -->
          <svg-icon class="icon" size="14px" v-if="!hideFull" @click="toggleFullscreen" :name="fullscreen ? 'top-exit' : 'top-full'"></svg-icon>
        </div>
      </div>
    </template>
    <slot />

    <template #footer v-if="$slots.footer">
      <slot name="footer" />
    </template>
  </el-dialog>
</template>

<script setup>
import { onMounted, onUnmounted } from 'vue';
import { useVModel } from '@vueuse/core';

const props = defineProps({
  modelValue: Boolean,
  title: String,
  hideFooter: Boolean, // 隐藏底部footer
  titleAlign: String, // center
  scrollBody: {
    type: Boolean,
    default: true,
  }, // 只滚动body部分
  hideFull: Boolean, // 是否隐藏全屏按钮
  fullscreen: Boolean, // 是否默认全屏,
  isDraggable: {
    type: Boolean,
    default: true,
  },
  destroyOnClose: {
    type: Boolean,
    default: true,
  },
});

const emit = defineEmits(['beforeClose', 'update:modelValue']);
const visibleDilog = useVModel(props, 'modelValue', emit);

const headerStyles = computed(() => {
  if (props.titleAlign) {
    return {
      justifyContent: props.titleAlign,
    };
  }
});

const fullscreen = ref(props.fullscreen);
const toggleFullscreen = () => {
  fullscreen.value = !fullscreen.value;
};

// 自实现拖拽,替代 Element Plus 内置 draggable。
// 原因:内置 useDraggable 把拖拽偏移存在闭包内,resize 修正 DOM 的 transform 后闭包不同步,
// 再次拖拽时弹窗会跳回旧偏移位置、表头被遮挡。改由组件内部 dragOffset 维护偏移,
// resize 修正时同步更新,拖拽不再跳变。
const dragOffset = reactive({ x: 0, y: 0 });
const dialogElRef = ref(null);

const onDragStart = e => {
  if (!props.isDraggable) return;
  // 仅响应左键(避免右键/中键触发)
  if (e.button !== 0) return;
  // 阻止 mousedown 默认行为:浏览器默认会进入文本选区模式,
  // 拖到顶部后由于遮罩层(fixed inset:0)还盖在底部文档上,鼠标拖动时整页文字会被选中
  e.preventDefault();
  // 通过 header 元素向上找到 el-dialog 根节点(teleport 挂载到 body)
  const dialogEl = e.currentTarget.closest('.el-dialog');
  if (!dialogEl) return;
  dialogElRef.value = dialogEl;

  const downX = e.clientX;
  const downY = e.clientY;
  const { x: offsetX, y: offsetY } = dragOffset;
  const rect = dialogEl.getBoundingClientRect();
  const targetLeft = rect.left;
  const targetTop = rect.top;
  const targetWidth = rect.width;
  const targetHeight = rect.height;
  const clientWidth = document.documentElement.clientWidth;
  const clientHeight = document.documentElement.clientHeight;
  // 边界限制:拖拽过程中不允许拖出视口(与 Element Plus 内置逻辑保持一致)
  const minLeft = -targetLeft + offsetX;
  const minTop = -targetTop + offsetY;
  const maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
  const maxTop = clientHeight - targetTop - targetHeight + offsetY;

  const onMouseMove = ev => {
    const moveX = Math.min(Math.max(offsetX + ev.clientX - downX, minLeft), maxLeft);
    const moveY = Math.min(Math.max(offsetY + ev.clientY - downY, minTop), maxTop);
    dragOffset.x = moveX;
    dragOffset.y = moveY;
    dialogEl.style.transform = `translate(${moveX}px, ${moveY}px)`;
    // 兜底:拖拽过程中清除任何可能已经产生的文本选区(部分浏览器在 preventDefault 之外仍可能选中 iframe 内的内容)
    const sel = window.getSelection && window.getSelection();
    if (sel && sel.rangeCount > 0) sel.removeAllRanges();
  };
  const onMouseUp = () => {
    document.removeEventListener('mousemove', onMouseMove);
    document.removeEventListener('mouseup', onMouseUp);
  };
  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
};

// 弹窗每次打开时重置拖拽状态(destroy-on-close 下 DOM 已重建,偏移需归零)
watch(visibleDilog, val => {
  if (val) {
    dragOffset.x = 0;
    dragOffset.y = 0;
    dialogElRef.value = null;
  }
});

const resetDialogPos = () => {
  // 监听窗口缩放:弹窗被拖动后遇到窗口尺寸变化,修正其位置,确保表头不被遮挡、弹窗自适应视口。
  // 拖拽偏移由组件内部 dragOffset 维护,修正后同步更新,下次拖拽不会跳变。
  const dialogEl = dialogElRef.value;
  if (!dialogEl || !dialogEl.isConnected) return;
  // 全屏模式下弹窗占满视口,无需处理
  if (dialogEl.classList.contains('is-fullscreen')) return;

  const rect = dialogEl.getBoundingClientRect();
  // 宽高为 0 说明弹窗未真正显示,跳过
  if (rect.width === 0 || rect.height === 0) return;

  const viewportWidth = window.innerWidth;
  const viewportHeight = window.innerHeight;

  let offsetX = dragOffset.x;
  let offsetY = dragOffset.y;
  let needUpdate = false;

  // 顶部超出视口(表头被遮挡)—— 优先处理,保证表头可见
  if (rect.top < 0) {
    offsetY = dragOffset.y - rect.top;
    needUpdate = true;
  }
  // 底部超出视口:弹窗整体高于视口时优先保证表头可见(top = 0),否则上移贴底
  if (rect.bottom > viewportHeight) {
    offsetY = rect.height >= viewportHeight ? dragOffset.y - rect.top : dragOffset.y - (rect.bottom - viewportHeight);
    needUpdate = true;
  }
  // 左侧超出视口
  if (rect.left < 0) {
    offsetX = dragOffset.x - rect.left;
    needUpdate = true;
  }
  // 右侧超出视口:弹窗整体宽于视口时优先保证左边可见,否则左移贴右
  if (rect.right > viewportWidth) {
    offsetX = rect.width >= viewportWidth ? dragOffset.x - rect.left : dragOffset.x - (rect.right - viewportWidth);
    needUpdate = true;
  }

  // 仅在位置越界时才修正,避免对正常居中的弹窗造成不必要的 transform 重置
  if (needUpdate) {
    dragOffset.x = offsetX;
    dragOffset.y = offsetY;
    dialogEl.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
  }
};

function beforeClose() {
  emit('beforeClose');
  visibleDilog.value = false;
}

onMounted(() => {
  window.addEventListener('resize', resetDialogPos);
});

onUnmounted(() => {
  window.removeEventListener('resize', resetDialogPos);
});
</script>

<style lang="scss" scoped>
@media screen and (max-width: 1300px) {
  :global(.tn-dialog) {
    margin: 20px auto 0;
  }
  :global(.tn-dialog.is-fullscreen) {
    margin: 0;
  }
}
:global(.tn-dialog.is-fullscreen) {
  height: 100vh;
  display: flex;
  flex-direction: column;
}
:global(.tn-dialog .el-dialog__header) {
  border-bottom: 1px solid #f0f0f0;
  padding: 12px 20px;
  font-weight: bold;
  margin-right: 0;
}
:global(.tn-dialog .el-dialog__headerbtn) {
  top: 3px;
  width: 40px;
  height: 44px;
  margin-right: 4px;
}
:global(.tn-dialog[scrollBody='true'] .el-dialog__body) {
  max-height: calc(100vh - 140px);
  overflow: auto;
}
:global(.tn-dialog[scrollBody='true'].is-fullscreen .el-dialog__body) {
  max-height: none;
  // max-height: calc(100vh - 131px);
  overflow: auto;
}
:global(.tn-dialog[scrollBody='true'].hide-footer.is-fullscreen .el-dialog__body) {
  height: calc(100vh - 72px);
  box-sizing: border-box;
}
:global(.tn-dialog[scrollBody='true'].hide-footer.is-fullscreen .el-dialog__footer) {
  display: none;
}
:global(.tn-dialog[scrollBody='true'].hide-footer .el-dialog__footer) {
  display: none;
}
:global(.tn-dialog[scrollBody='true'] .el-dialog__footer) {
  position: relative;
  border-top: 1px solid #f0f0f0;
  text-align: right;
  // height: 60px;
  padding: 12px 20px;
  z-index: 2;
}
:global(.tn-dialog[grayBody='true'] .el-dialog__body) {
  background: #eeeeee;
}

.tn-dialog {
  .header {
    display: flex;
    align-items: center;
    position: relative;
    font-size: 16px;
    margin-right: 20px;

    // 仅在可拖拽时显示移动光标,提示用户可拖动
    &.is-draggable {
      cursor: move;
    }

    .left-title {
      position: absolute;
      left: 5px;
      z-index: 1;
      max-width: 44%;
      overflow: hidden;
    }

    .operation {
      position: absolute;
      right: 5px;
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 1;

      .icon {
        cursor: pointer;
        color: var(--el-color-info);
        &:hover {
          color: var(--el-color-primary);
        }
      }
    }
  }
}
</style>

 

posted @ 2026-07-07 15:53  收破烂的小伙子  阅读(2)  评论(0)    收藏  举报