VbSplitPanel.vue 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. <script setup lang="ts">
  2. const props = withDefaults(
  3. defineProps<{
  4. mode?: "h" | "v" | "horizontal" | "vertical" // 布局方向
  5. isDrag?: boolean // 是否可以拖拽
  6. size?: number | string // 初始尺寸,支持像素或百分比(如 '50%')
  7. minSize?: number | string // 最小尺寸,支持像素或百分比(如 '10%')
  8. maxSize?: number | string // 最大尺寸,支持像素或百分比(如 '80%')
  9. containerSize?: string | number // 容器尺寸
  10. panelStyle?: object // 面板样式,支持内联样式
  11. panelItemStyle?: object // 面板项样式,支持内联样式
  12. firstPanelItemStyle?: object // 第1个面板项样式,支持内联样式
  13. secondPanelItemStyle?: object // 第2个面板项样式,支持内联样式
  14. handlerBg?: string // 拖拽条背景色
  15. handlerHoverBg?: string // 拖拽条悬停背景色
  16. handlerWidth?: number // 拖拽条宽度
  17. handlerMargin?: number // 拖拽条边距
  18. }>(),
  19. {
  20. mode: "h",
  21. isDrag: true,
  22. size: 200,
  23. minSize: 100,
  24. maxSize: 2000,
  25. containerSize: "100%",
  26. handlerBg: "#e5e7eb",
  27. handlerHoverBg: "#409eff",
  28. handlerWidth: 6,
  29. handlerMargin: 2
  30. }
  31. )
  32. const emits = defineEmits<{
  33. (e: "dragComplete", n1: number, n2: number): void
  34. }>()
  35. const panelRef = ref(null)
  36. const isDragging = ref(false)
  37. const parentSize = ref(0)
  38. const firstPanelSize = ref<number>(
  39. typeof props.size === "number" ? props.size : parseFloat(props.size as string) || 200
  40. )
  41. const secondPanelSize = computed(() =>
  42. Math.floor(
  43. parentSize.value - firstPanelSize.value - (props.handlerWidth || 6) - (props.handlerMargin || 2)
  44. )
  45. )
  46. const validatePositive = (value, name) => {
  47. const num = Number(value)
  48. if (!isNaN(num) && num < 0) {
  49. throw new Error(`VbSplitPanel:${name} 不能小于 0,当前值:${value}`)
  50. }
  51. }
  52. const realMode = computed(() => {
  53. const m = props.mode
  54. return m === "h" || m === "horizontal" ? "horizontal" : "vertical"
  55. })
  56. // 解析尺寸值,将百分比转换为像素值
  57. const parseSizeValue = (value: number | string, parentSize: number): number => {
  58. if (typeof value === "string" && value.endsWith("%")) {
  59. const percentage = parseFloat(value)
  60. if (!isNaN(percentage)) {
  61. if (percentage > 100) {
  62. console.warn(`VbSplitPanel:${value} 超出范围,已设置为 90%`)
  63. return Math.floor((parentSize * 90) / 100)
  64. } else if (percentage < 0) {
  65. console.warn(`VbSplitPanel:${value} 不能小于 0,已设置为 10%`)
  66. return Math.floor((parentSize * 10) / 100)
  67. } else {
  68. return Math.floor((parentSize * percentage) / 100)
  69. }
  70. }
  71. }
  72. return typeof value === "number" ? value : parseFloat(value) || 0
  73. }
  74. // 获取父元素拖拽方向尺寸
  75. const getParentSize = () => {
  76. if (!panelRef.value?.parentElement) return
  77. const parent = panelRef.value.parentElement
  78. parent.style.userSelect = "none"
  79. const size = realMode.value === "horizontal" ? parent.offsetWidth : parent.offsetHeight
  80. validatePositive(size, `拖拽父元素${realMode.value === "horizontal" ? "水平" : "垂直"}方向尺寸`)
  81. parentSize.value = Math.floor(size)
  82. }
  83. // 容器样式
  84. const containerStyle = computed(() => {
  85. const isH = realMode.value === "horizontal"
  86. const sizeProp = isH ? "width" : "height"
  87. const crossProp = isH ? "height" : "width"
  88. const val = props.containerSize
  89. validatePositive(val, "size")
  90. let finalSize
  91. if (val === "auto") {
  92. if (parentSize.value < 0) {
  93. throw new Error(`SplitPanel:size=auto,但父元素${sizeProp}为 0,无法渲染`)
  94. }
  95. finalSize = `${parentSize.value}px`
  96. } else if (!isNaN(Number(val))) {
  97. finalSize = `${val}px`
  98. } else {
  99. finalSize = val
  100. }
  101. return {
  102. ...(props.panelStyle || {}),
  103. "--split-handler-bg": props.handlerBg || "#e5e7eb",
  104. "--split-handler-hover-bg": props.isDrag
  105. ? props.handlerHoverBg || "#409eff"
  106. : "var(--split-handler-bg)",
  107. "--split-handler-width": (props.handlerWidth || 6) + "px",
  108. "--split-handler-margin": (props.handlerMargin || 2) + "px",
  109. "--split-handler-cursor": props.isDrag
  110. ? realMode.value === "horizontal"
  111. ? "col-resize"
  112. : "row-resize"
  113. : "default",
  114. [sizeProp]: finalSize,
  115. [crossProp]: "100%",
  116. display: "flex",
  117. overflow: "hidden"
  118. }
  119. })
  120. const modeClass = computed(() => realMode.value)
  121. const handlerClass = computed(() => (realMode.value === "horizontal" ? "h-handler" : "v-handler"))
  122. const firstPanelStyle = computed(() => {
  123. const pixelSize = parseSizeValue(firstPanelSize.value, parentSize.value)
  124. return {
  125. ...(props.panelItemStyle || {}),
  126. ...(props.firstPanelItemStyle || {}),
  127. ...(realMode.value === "horizontal"
  128. ? { width: `${pixelSize}px` }
  129. : { height: `${pixelSize}px` }),
  130. flex: "none"
  131. }
  132. })
  133. const secondPanelStyle = computed(() => {
  134. return {
  135. ...(props.panelItemStyle || {}),
  136. ...(props.secondPanelItemStyle || {}),
  137. ...(realMode.value === "horizontal"
  138. ? { width: `${secondPanelSize.value}px` }
  139. : { height: `${secondPanelSize.value}px` }),
  140. flex: 1
  141. }
  142. })
  143. // 拖拽逻辑
  144. const startDrag = (e) => {
  145. if (!props.isDrag) return
  146. e.preventDefault()
  147. isDragging.value = true
  148. const startPos = realMode.value === "horizontal" ? e.clientX : e.clientY
  149. // 确保 start 是数值类型
  150. const start =
  151. typeof firstPanelSize.value === "number"
  152. ? firstPanelSize.value
  153. : parseFloat(firstPanelSize.value as string) || 200
  154. const onMove = (e) => {
  155. if (!isDragging.value) return
  156. const pos = realMode.value === "horizontal" ? e.clientX : e.clientY
  157. let size = start + (pos - startPos)
  158. // 解析 minSize 和 maxSize 为像素值
  159. const minPixelSize = parseSizeValue(props.minSize, parentSize.value)
  160. const maxPixelSize = parseSizeValue(props.maxSize, parentSize.value)
  161. size = Math.max(minPixelSize, Math.min(maxPixelSize, size))
  162. size = Math.floor(Math.min(size, parentSize.value - minPixelSize))
  163. firstPanelSize.value = size
  164. }
  165. const stop = () => {
  166. isDragging.value = false
  167. document.removeEventListener("mousemove", onMove)
  168. document.removeEventListener("mouseup", stop)
  169. nextTick(() => {
  170. emits("dragComplete", firstPanelSize.value, secondPanelSize.value)
  171. })
  172. }
  173. document.addEventListener("mousemove", onMove)
  174. document.addEventListener("mouseup", stop)
  175. }
  176. onMounted(() => {
  177. getParentSize()
  178. // 在父元素尺寸获取后,重新计算初始尺寸
  179. if (parentSize.value > 0) {
  180. firstPanelSize.value = parseSizeValue(props.size, parentSize.value)
  181. }
  182. })
  183. watch(() => [props.mode, props.containerSize], getParentSize)
  184. // 监听 size 属性变化
  185. watch(
  186. () => props.size,
  187. (newSize) => {
  188. if (parentSize.value > 0) {
  189. firstPanelSize.value = parseSizeValue(newSize, parentSize.value)
  190. }
  191. }
  192. )
  193. </script>
  194. <template>
  195. <div class="split-panel" ref="panelRef" :class="modeClass" :style="containerStyle">
  196. <div class="panel-item" :style="firstPanelStyle">
  197. <slot name="first"></slot>
  198. </div>
  199. <div class="split-handler" :class="handlerClass" @mousedown="startDrag"></div>
  200. <div class="panel-item" :style="secondPanelStyle">
  201. <slot name="second"></slot>
  202. </div>
  203. </div>
  204. </template>
  205. <style scoped lang="scss">
  206. .split-panel {
  207. position: relative;
  208. background: transparent;
  209. --split-handler-bg: #e5e7eb;
  210. --split-handler-hover-bg: #409eff;
  211. --split-handler-width: 6px;
  212. --split-handler-margin: 2px;
  213. --split-handler-cursor: row-resize;
  214. &.horizontal {
  215. flex-direction: row;
  216. }
  217. &.vertical {
  218. flex-direction: column;
  219. }
  220. .panel-item {
  221. background: transparent;
  222. overflow: auto;
  223. }
  224. .split-handler {
  225. background: var(--split-handler-bg);
  226. transition: background 0.2s;
  227. z-index: 10;
  228. flex-shrink: 0;
  229. &.h-handler {
  230. width: var(--split-handler-width);
  231. height: 100%;
  232. cursor: var(--split-handler-cursor);
  233. margin: 0 var(--split-handler-margin);
  234. }
  235. &.v-handler {
  236. height: var(--split-handler-width);
  237. width: 100%;
  238. cursor: var(--split-handler-cursor);
  239. margin: var(--split-handler-margin) 0;
  240. }
  241. &:hover {
  242. background: var(--split-handler-hover-bg);
  243. }
  244. }
  245. }
  246. </style>