# Split分隔面板

使用Vue3实现的分隔面板,可以将一片区域,分割为可以拖拽调整宽度或高度的两部分区域。

# Split Props

属性 说明 类型 默认值
mode 类型,可选值为 horizontalvertical String horizontal
min 左侧或者上侧的最小阈值 String | Number 40px
max 左侧或者上侧的最小阈值(0表示不设置最大值) String | Number 0
min2 右侧或者下侧的最小阈值 String | Number 40px
modelValue 面板位置,可以是 0~1 (代表百分比),或具体数值的像素(不支持空字符串),可用 v-model 双向绑定;0~1代表百分比,字符串代表像素值 String |Number 0.5

# Split event

事件名 说明 参数
on-move-start 拖拽开始 event
on-moving 拖拽中 event
on-move-end 拖拽结束 event

# Split slot

名称 说明
left mode 为 horizontal 时可用,左边面板
right mode 为 horizontal 时可用,右边面板
top mode 为 vertical 时可用,上边面板
bottom mode 为 vertical 时可用,下边面板
trigger 自定义分隔拖拽节点

# 实现代码

XSplit.vue

<template>
  <div class="x-split-wrapper"
       ref="WrapperEl">
    <div :class="{'x-split--horizontal': horizontalMode,'x-split--vertical': verticalMode,}">
      <div :class="{'x-split-pane': true, 'x-pane--left': horizontalMode, 'x-pane--top': verticalMode}"
           ref="leftTopPaneEl"
           :style="{
                right: horizontalMode ? rightValueStyle : '',
                bottom: verticalMode ? rightValueStyle : '',
           }">
        <slot v-if="horizontalMode"
              name="left"></slot>
        <slot v-if="mode === 'vertical'"
              name="top"></slot>
      </div>
      <div class="x-split-trigger-wrapper"
           ref="triggerWrapperEl"
           :style="{
                left: horizontalMode ? leftTopValueStyle : '',
                top: verticalMode ? leftTopValueStyle : '',
           }"
           @mousedown="onMouoseDown($event)">
        <div :class="{'x-split-trigger': true, 'x-split-trigger--vertical': horizontalMode, 'x-split-trigger--horizontal': verticalMode}">
          <slot name="trigger">
            <div v-for="i in 8"
                 :key="i"
                 class="x-split-trigger__bar"></div>
          </slot>

        </div>
      </div>
      <div :class="{'x-split-pane': true, 'x-pane--right': horizontalMode, 'x-pane--bottom': verticalMode}"
           ref="rightBottomPaneEl"
           :style="{
                left: horizontalMode ? leftTopValueStyle : '',
                top: verticalMode ? leftTopValueStyle : '',
           }">
        <slot v-if="horizontalMode"
              name="right"></slot>
        <slot v-if="verticalMode"
              name="bottom"></slot>
      </div>

    </div>
  </div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
defineOptions({ name: 'XSplit' });
const props = defineProps({
  mode: {
    type: String,
    default: 'horizontal',
    validator: (val) => ['horizontal', 'vertical'].includes(val)
  },
  modelValue: {
    type: [Number, String],
    default: 0.5
  },
  min: {
    type: [Number, String],
    default: 40
  },
  max: {
    type: [Number, String],
    default: 0
  },
  min2: {
    type: [Number, String],
    default: 46
  }
});
const emits = defineEmits(['update:modelValue', 'on-move-start', 'on-moving', 'on-move-end']);
// emits: ['update:modelValue', 'change'],

const horizontalMode = computed(() => {
  return props.mode === 'horizontal';
});
const verticalMode = computed(() => {
  return props.mode === 'vertical';
});

const isPercent = ref(false);

let _min = 40;
watchEffect(() => {
  try {
    _min = parseFloat(props.min);
  } catch (error) {
    console.error(error);
  }
});

let _min2 = 46;
watchEffect(() => {
  try {
    _min2 = parseFloat(props.min2);
  } catch (error) {
    console.error(error);
  }
});

let _max = 0;
watchEffect(() => {
  try {
    _max = parseFloat(props.max);
  } catch (error) {
    console.error(error);
  }
});

let _modelValue = parseFloat(props.modelValue);
if (0 <= _modelValue && _modelValue <= 1) {
  // 表示百分比
  isPercent.value = true;
} else {
  // 当做数值型
  isPercent.value = false;
}

const leftTopValue = ref(0);
leftTopValue.value = _modelValue;

const WrapperEl = ref(null);
const leftTopPaneEl = ref(null);
const triggerWrapperEl = ref(null);
const rightBottomPaneEl = ref(null);

const leftTopValueStyle = computed(() => {
  if (isPercent.value) {
    return leftTopValue.value * 100 + '%';
  }
  return leftTopValue.value + 'px';
});
const rightValueStyle = ref('');
const setRightValueStyle = () => {
  if (horizontalMode.value) {
    if (isPercent.value) {
      rightValueStyle.value = (1 - leftTopValue.value) * 100 + '%';
      return;
    }
    if (WrapperEl.value) {
      rightValueStyle.value = WrapperEl.value.offsetWidth - leftTopValue.value + 'px';
    }
  }
  if (verticalMode.value) {
    if (isPercent.value) {
      rightValueStyle.value = (1 - leftTopValue.value) * 100 + '%';
      return;
    }
    if (WrapperEl.value) {
      rightValueStyle.value = WrapperEl.value.offsetHeight - leftTopValue.value + 'px';
    }
  }
};

let isMoving = false;
let startClientX = 0;
let startLeftWidth = 0;
let startLeftValue = 0;

let startClientY = 0;
let startTopHeight = 0;
let startTopValue = 0;

const onMouoseDown = (event) => {
  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
  startLeftWidth = leftTopPaneEl.value.offsetWidth;
  startTopHeight = leftTopPaneEl.value.offsetHeight;
  startLeftValue = leftTopValue.value;
  startTopValue = leftTopValue.value;
  startClientX = event.clientX;
  startClientY = event.clientY;
  isMoving = true;
  emits('on-move-start', event);
};

const execHorizontal = (event) => {
  if (isPercent.value) {
    const deltaWidth = event.clientX - startClientX;
    const wrapperWidth = WrapperEl.value.offsetWidth;
    let _leftTopValue = (startLeftWidth + deltaWidth) / wrapperWidth;

    if (_leftTopValue <= 0) {
      _leftTopValue = 0;
    }
    if (_leftTopValue >= 1) {
      _leftTopValue = 1;
    }

    _leftTopValue = _leftTopValue.toFixed(6) - 0;
    const leftWidth = wrapperWidth * _leftTopValue;
    if (leftWidth <= _min) {
      _leftTopValue = (_min / wrapperWidth).toFixed(6) - 0;
    }
    if (_max > 0 && leftWidth >= _max) {
      _leftTopValue = (_max / wrapperWidth).toFixed(6) - 0;
    }

    if (wrapperWidth * (1 - _leftTopValue) <= _min2) {
      _leftTopValue = (wrapperWidth - _min2) / wrapperWidth;
    }

    leftTopValue.value = _leftTopValue;

    setRightValueStyle();
    emits('update:modelValue', leftTopValue.value);
  } else {
    const deltaWidth = event.clientX - startClientX;
    let _leftTopValue = startLeftValue + deltaWidth;
    const wrapperWidth = WrapperEl.value.offsetWidth;
    if (_leftTopValue <= 0) {
      _leftTopValue = 0;
    }
    if (_leftTopValue >= wrapperWidth) {
      _leftTopValue = wrapperWidth;
    }
    if (_leftTopValue <= _min) {
      _leftTopValue = _min;
    }
    if (_max > 0 && _leftTopValue >= _max) {
      _leftTopValue = _max;
    }

    if (wrapperWidth - _leftTopValue <= _min2) {
      _leftTopValue = wrapperWidth - _min2;
    }

    leftTopValue.value = _leftTopValue;
    setRightValueStyle();
    emits('update:modelValue', leftTopValue.value + 'px');
  }
};

const execVertical = (event) => {
  if (isPercent.value) {
    const deltaHeight = event.clientY - startClientY;
    let _leftTopValue = (startTopHeight + deltaHeight) / WrapperEl.value.offsetHeight;
    if (_leftTopValue <= 0) {
      _leftTopValue = 0;
    }
    if (_leftTopValue >= 1) {
      _leftTopValue = 1;
    }

    const wrapperHeight = WrapperEl.value.offsetHeight;
    _leftTopValue = _leftTopValue.toFixed(6) - 0;
    const topHeight = wrapperHeight * _leftTopValue;
    if (topHeight <= _min) {
      _leftTopValue = (_min / wrapperHeight).toFixed(6) - 0;
    }
    if (_max > 0 && topHeight >= _max) {
      _leftTopValue = (_max / wrapperHeight).toFixed(6) - 0;
    }
    if (wrapperHeight * (1 - _leftTopValue) <= _min2) {
      _leftTopValue = (WrapperEl.value.offsetHeight - _min2) / WrapperEl.value.offsetHeight;
    }
    leftTopValue.value = _leftTopValue;
    setRightValueStyle();
    emits('update:modelValue', leftTopValue.value);
  } else {
    const deltaHeight = event.clientY - startClientY;
    let _leftTopValue = startTopValue + deltaHeight;
    const wrapperHeight = WrapperEl.value.offsetHeight;
    if (_leftTopValue <= 0) {
      _leftTopValue = 0;
    }
    if (_leftTopValue >= wrapperHeight) {
      _leftTopValue = wrapperHeight;
    }
    if (_leftTopValue <= _min) {
      _leftTopValue = _min;
    }
    if (_max > 0 && _leftTopValue >= _max) {
      _leftTopValue = _max;
    }
    if (wrapperHeight - _leftTopValue <= _min2) {
      _leftTopValue = wrapperHeight - _min2;
    }
    leftTopValue.value = _leftTopValue;
    setRightValueStyle();
    emits('update:modelValue', leftTopValue.value + 'px');
  }
};

const onMouseMove = (event) => {
  if (isMoving) {
    if (horizontalMode.value) {
      execHorizontal(event);
    }
    if (verticalMode.value) {
      execVertical(event);
    }
    emits('on-moving', event);
  }
};
const onMouseUp = (event) => {
  if (isMoving) {
    if (horizontalMode.value) {
      execHorizontal(event);
    }
    if (verticalMode.value) {
      execVertical(event);
    }
  }
  document.removeEventListener('mousemove', onMouseMove);
  document.removeEventListener('mouseup', onMouseUp);
  isMoving = false;
  startClientX = 0;
  startLeftWidth = 0;
  emits('on-move-end', event);
};

onMounted(() => {
  setRightValueStyle();
});

onUnmounted(() => {
  document.removeEventListener('mousemove', onMouseMove);
  document.removeEventListener('mouseup', onMouseUp);
});
</script>
<style scoped lang="css">
.x-split-wrapper {
  width: 100%;
  height: 100%;
  position: relative;
}
.x-split-pane {
  position: absolute;
  z-index: 1;
}

.x-split-pane.x-pane--left {
  left: 0;
  top: 0;
  bottom: 0;
}
.x-split-pane.x-pane--top {
  left: 0;
  right: 0;
  top: 0;
}
.x-split-pane.x-pane--right {
  right: 0;
  top: 0;
  bottom: 0;
  padding-left: 6px;
}
.x-split-pane.x-pane--bottom {
  right: 0;
  left: 0;
  bottom: 0;
  padding-top: 6px;
}
.x-split-trigger-wrapper {
  position: absolute;
  /* transform: translate(-50%, -50%); */
  z-index: 10;
}
.x-split--horizontal > .x-split-trigger-wrapper {
  height: 100%;
  width: 0;
}
.x-split--vertical > .x-split-trigger-wrapper {
  height: 0;
  width: 100%;
}
.x-split-trigger {
  border: 1px solid #d7dde4;
}
.x-split-trigger--vertical {
  width: 6px;
  box-sizing: border-box;
  height: 100%;
  background: #f8f8f9;
  border-top: none;
  border-bottom: none;
  cursor: col-resize;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.x-split-trigger--vertical .x-split-trigger__bar {
  width: 4px;
  height: 1px;
  margin: 2px;
  background-color: rgba(23, 35, 61, 0.25);
}

.x-split-trigger--horizontal {
  height: 6px;
  box-sizing: border-box;
  width: 100%;
  background: #f8f8f9;
  border-left: none;
  border-right: none;
  cursor: row-resize;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
}
.x-split-trigger--horizontal .x-split-trigger__bar {
  width: 1px;
  height: 4px;
  margin: 0 2px;
  background-color: rgba(23, 35, 61, 0.25);
}
</style>

# 基础用法

<XSplit v-model="splitValue">
  <template v-slot:left>
    <div>top box</div>
  </template>
  <template v-slot:right>
    <div>bottom box</div>
  </template>
</XSplit>

# 嵌套使用

<div style="margin: 20px;width: 500px; height: 300px;border: 1px solid #eee;">
  <XSplit :modelValue="200">
    <template v-slot:left>
      <div style="height: 100%">
        <XSplit :modelValue="100"
                mode="vertical"
                @on-moving="onMoving">
          <template v-slot:top>
            <div>top box</div>
          </template>
          <template v-slot:bottom>
            <div>bottom box</div>
          </template>
        </XSplit>
      </div>
    </template>
    <template v-slot:right>
      <div>right box</div>
    </template>
  </XSplit>
</div>

# 图示

上次更新: 1/22/2025, 9:39:13 AM