From 80811390517f0581d85d1aa29880e1958068292b Mon Sep 17 00:00:00 2001 From: Jecyu Date: Fri, 17 Sep 2021 09:11:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(splitter):=20=E6=96=B0=E5=A2=9E=20spli?= =?UTF-8?q?tter=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devui/shared/util/class.ts | 44 ++++ devui/shared/util/set-style.ts | 26 +++ devui/splitter/index.ts | 18 ++ devui/splitter/src/splitter-bar-type.tsx | 34 +++ devui/splitter/src/splitter-bar.scss | 115 ++++++++++ devui/splitter/src/splitter-bar.tsx | 184 ++++++++++++++++ devui/splitter/src/splitter-pane-type.tsx | 74 +++++++ devui/splitter/src/splitter-pane.scss | 23 ++ devui/splitter/src/splitter-pane.tsx | 137 ++++++++++++ devui/splitter/src/splitter-store.ts | 200 ++++++++++++++++++ devui/splitter/src/splitter-types.ts | 29 +++ devui/splitter/src/splitter.scss | 18 ++ devui/splitter/src/splitter.tsx | 69 ++++++ devui/splitter/src/util/d-resize-directive.ts | 82 +++++++ docs/.vitepress/config/sidebar.ts | 2 +- docs/vite.config.ts | 6 +- sites/components/splitter/index.md | 98 +++++++++ 17 files changed, 1154 insertions(+), 5 deletions(-) create mode 100644 devui/shared/util/class.ts create mode 100644 devui/shared/util/set-style.ts create mode 100644 devui/splitter/index.ts create mode 100644 devui/splitter/src/splitter-bar-type.tsx create mode 100644 devui/splitter/src/splitter-bar.scss create mode 100644 devui/splitter/src/splitter-bar.tsx create mode 100644 devui/splitter/src/splitter-pane-type.tsx create mode 100644 devui/splitter/src/splitter-pane.scss create mode 100644 devui/splitter/src/splitter-pane.tsx create mode 100644 devui/splitter/src/splitter-store.ts create mode 100644 devui/splitter/src/splitter-types.ts create mode 100644 devui/splitter/src/splitter.scss create mode 100644 devui/splitter/src/splitter.tsx create mode 100644 devui/splitter/src/util/d-resize-directive.ts create mode 100644 sites/components/splitter/index.md diff --git a/devui/shared/util/class.ts b/devui/shared/util/class.ts new file mode 100644 index 00000000..046f6ce6 --- /dev/null +++ b/devui/shared/util/class.ts @@ -0,0 +1,44 @@ +/** + * 判断 DOM 中的元素是否含有某个类 + * @param el 元素 + * @param className 类名 + * @returns + */ +export function hasClass(el: HTMLElement, className: string): boolean { + if (el.classList) { + return el.classList.contains(className); + } + const originClass = el.className; + return ` ${originClass} `.indexOf(` ${className} `) > -1; +} + +/** + * 向 DOM 中的元素添加一个类 + * @param el 元素 + * @param className 类名 + */ +export function addClass(el: HTMLElement, className: string): void { + if (el.classList) { + el.classList.add(className); + } else { + if (!hasClass(el, className)) { + el.className = `${el.className} ${className}`; + } + } +} + +/** + * 从 DOM 中的元素移除一个类 + * @param el 元素 + * @param className 类名 + */ +export function removeClass(el: HTMLElement, className: string): void { + if (el.classList) { + el.classList.remove(className); + } else { + if (hasClass(el, className)) { + const originClass = el.className; + el.className = ` ${originClass} `.replace(` ${className} `, ' '); + } + } +} diff --git a/devui/shared/util/set-style.ts b/devui/shared/util/set-style.ts new file mode 100644 index 00000000..8ce5065b --- /dev/null +++ b/devui/shared/util/set-style.ts @@ -0,0 +1,26 @@ +import type { CSSProperties } from 'vue'; + +/** + * 设置元素的样式,返回上一次的样式 + * @param element + * @param style + * @returns + */ +export function setStyle( + element: HTMLElement, + style: CSSProperties, +): CSSProperties { + const oldStyle: CSSProperties = {}; + + const styleKeys = Object.keys(style); + + styleKeys.forEach((key) => { + oldStyle[key] = element.style[key]; + }); + + styleKeys.forEach((key) => { + element.style[key] = style[key]; + }); + + return oldStyle; +} \ No newline at end of file diff --git a/devui/splitter/index.ts b/devui/splitter/index.ts new file mode 100644 index 00000000..6c473534 --- /dev/null +++ b/devui/splitter/index.ts @@ -0,0 +1,18 @@ +import type { App } from 'vue' +import Splitter from './src/splitter' +import SplitterPane from './src/splitter-pane' + +Splitter.install = function (app: App): void { + app.component(Splitter.name, Splitter) + app.component(SplitterPane.name, SplitterPane) +} + +export { Splitter } + +export default { + title: 'Splitter 分割器', + category: '布局', + install(app: App) { + app.use(Splitter as any) + }, +} diff --git a/devui/splitter/src/splitter-bar-type.tsx b/devui/splitter/src/splitter-bar-type.tsx new file mode 100644 index 00000000..e7ac1e23 --- /dev/null +++ b/devui/splitter/src/splitter-bar-type.tsx @@ -0,0 +1,34 @@ +import { PropType, ExtractPropTypes } from 'vue'; +import { SplitterOrientation } from './splitter-types'; + + +export const splitterBarProps = { + /** + * 当前 pane 的索引 + */ + index: { + type: Number, + }, + /** + * 必选,指定 SplitterBar 的方向 + */ + orientation: { + type: String as PropType, + required: true, + }, + /** + * 分隔条大小 + */ + splitBarSize: { + type: String, + required: true, + }, + /** + * 是否显示展开/收缩按钮 + */ + showCollapseButton: { + type: Boolean, + }, +} as const; + +export type SplitterBarProps = ExtractPropTypes; diff --git a/devui/splitter/src/splitter-bar.scss b/devui/splitter/src/splitter-bar.scss new file mode 100644 index 00000000..bc6495b1 --- /dev/null +++ b/devui/splitter/src/splitter-bar.scss @@ -0,0 +1,115 @@ +@import '../../style/theme/color'; +@import '../../style/theme/corner'; + +.devui-splitter-bar { + background-color: $devui-dividing-line; + display: flex; + position: relative; + align-items: center; + justify-content: center; + flex-grow: 0; + flex-shrink: 0; + + .devui-collapse { + background-color: $devui-dividing-line; + position: absolute; + z-index: 15; + cursor: pointer; + + &::before, + &::after { + content: ''; + width: 10px; + height: 2px; + background: #ffffff; + display: block; + position: absolute; + } + + &:hover { + background-color: $devui-brand-hover; + } + } + + &-horizontal { + .devui-collapse { + width: 12px; + height: 30px; + + &.prev { + border-radius: + 0 $devui-border-radius-feedback + $devui-border-radius-feedback 0; + left: 100%; + + &::before, + &.collapsed::before { + top: 9px; + left: 1px; + } + + // 设置线条 + &::before { + transform: rotate(-70deg); + } + + &.collapsed::before { + transform: rotate(70deg); + } + + &::after { + transform: rotate(70deg); + } + + &.collapsed::after { + transform: rotate(-70deg); + } + + &::after { + top: 18px; + left: 1px; + } + } + } + + &.resizable { + // 修正 IE 浏览器,css 伪元素中鼠标手型无效 + cursor: col-resize; + + &::after { + // content 由下面的 :not(.none-resizable) 控制显示 + cursor: col-resize; + height: 100%; + width: 10px; + top: 0; + } + + &:not(.none-resizable) { + // 非折叠的情况下 + &:hover, + &:focus, + &:active { + background-color: $devui-brand-hover; + } + + &::after { + content: ''; + display: block; + position: absolute; + z-index: 10; + } + } + } + } + + &-vertical.resizable { + cursor: row-resize; + + &::after { + cursor: row-resize; + width: 100%; + height: 10px; + left: 0; + } + } +} diff --git a/devui/splitter/src/splitter-bar.tsx b/devui/splitter/src/splitter-bar.tsx new file mode 100644 index 00000000..51b05f18 --- /dev/null +++ b/devui/splitter/src/splitter-bar.tsx @@ -0,0 +1,184 @@ +import { + defineComponent, + ref, + watch, + nextTick, + reactive, + computed, + withDirectives, + onMounted, +} from 'vue' +import { useSplitterStore } from './splitter-store' +import { setStyle } from '../../shared/util/set-style' +import { addClass, removeClass } from '../../shared/util/class' +import dresize, { ResizeDirectiveProp } from './util/d-resize-directive' +import './splitter-bar.scss' +import { splitterBarProps, SplitterBarProps } from './splitter-bar-type' + +export default defineComponent({ + name: 'DSplitterBar', + props: splitterBarProps, + setup(props: SplitterBarProps) { + const { + splitterState, + getPane, + isStaticBar, + isResizable, + dragState, + setSize, + tooglePane, + } = useSplitterStore() + const state = reactive({ + wrapperClass: `devui-splitter-bar devui-splitter-bar-${props.orientation}`, + }) + const domRef = ref() + + watch( + () => props.splitBarSize, + (curSplitBarSize) => { + nextTick(() => { + const ele = domRef?.value + setStyle(ele, { flexBasis: curSplitBarSize }) + }) + }, + { immediate: true } + ) + + watch( + () => splitterState.panes, + () => { + if (!isStaticBar(props.index)) { + state.wrapperClass += ' resizable' + } else { + // TODO 禁用的样式处理 + // console.log(666); + } + }, + { deep: true } + ) + + // 指令输入值 + const coordinate = { pageX: 0, pageY: 0, originalX: 0, originalY: 0 } + let initState + // TODO 待优化,如何像 angular rxjs 操作一样优雅 + const resizeProp: ResizeDirectiveProp = { + enableResize: true, + onPressEvent: ({ originalEvent }) => { + originalEvent.stopPropagation() // 按下的时候,阻止事件冒泡 + if (!isResizable(props.index)) return + initState = dragState(props.index) + coordinate.originalX = originalEvent.pageX + coordinate.originalY = originalEvent.pageX + }, + onDragEvent: function ({ originalEvent }) { + originalEvent.stopPropagation() // 移动的时候,阻止事件冒泡 + if (!isResizable(props.index)) return + coordinate.pageX = originalEvent.pageX + coordinate.pageY = originalEvent.pageX + let distance + if (props.orientation === 'vertical') { + distance = coordinate.pageY - coordinate.originalY + } else { + distance = coordinate.pageX - coordinate.originalX + } + setSize(initState, distance) + }, + onReleaseEvent: function ({ originalEvent }) { + originalEvent.stopPropagation() // 释放的时候,阻止事件冒泡 + if (!isResizable(props.index)) return + coordinate.pageX = originalEvent.pageX + coordinate.pageY = originalEvent.pageX + let distance + if (props.orientation === 'vertical') { + distance = coordinate.pageY - coordinate.originalY + } else { + distance = coordinate.pageX - coordinate.originalX + } + setSize(initState, distance) + }, + } + + const queryPanes = (index, nearIndex) => { + const pane = getPane(index) + const nearPane = getPane(nearIndex) + return { + pane, + nearPane, + } + } + + // 根据当前状态生成收起按钮样式 + const generateCollapseClass = (pane, nearPane, showIcon) => { + // 是否允许收起 + const isCollapsible = pane?.component?.props?.collapsible && showIcon + // 当前收起状态 + const isCollapsed = pane?.component?.props?.collapsed + // 一个 pane 收起的时候,隐藏相邻 pane 的收起按钮 + const isNearPaneCollapsed = nearPane.collapsed + return { + 'devui-collapse': isCollapsible, + collapsed: isCollapsed, + hidden: isNearPaneCollapsed, + } + } + + // 计算前面板收起操作样式 + const prevClass = computed(() => { + const { pane, nearPane } = queryPanes(props.index, props.index + 1) + // 第一个面板或者其它面板折叠方向不是向后的, 显示操作按钮 + const showIcon = + pane?.component?.props?.collapseDirection !== 'after' || + props.index === 0 + return generateCollapseClass(pane, nearPane, showIcon) + }) + + // 切换是否允许拖拽,收起时不能拖拽 + const toggleResize = () => { + const { pane, nearPane } = queryPanes(props.index, props.index + 1) + const isCollapsed = + pane?.component?.props?.collapsed || + nearPane?.component?.props?.collapsed + if (isCollapsed) { + addClass(domRef.value, 'none-resizable') + } else { + removeClass(domRef.value, 'none-resizable') + } + } + + const handleCollapsePrePane = (lockStatus?) => { + tooglePane(props.index, props.index + 1, lockStatus) + toggleResize() + } + + const handleCollapseNextPane = () => { + /**TODO */ + } + + const initialCollapseStatus = () => { + handleCollapsePrePane(true) + // collapseNextPane(true); + } + + onMounted(() => { + initialCollapseStatus() + }) + + return () => { + return withDirectives( +
+ {props.showCollapseButton ? ( +
handleCollapsePrePane()} + >
+ ) : null} +
+ {props.showCollapseButton ? ( + + ) : null} +
, + [[dresize, resizeProp]] + ) + } + }, +}) diff --git a/devui/splitter/src/splitter-pane-type.tsx b/devui/splitter/src/splitter-pane-type.tsx new file mode 100644 index 00000000..9e366021 --- /dev/null +++ b/devui/splitter/src/splitter-pane-type.tsx @@ -0,0 +1,74 @@ +import { ExtractPropTypes, PropType } from 'vue'; +import { CollapseDirection } from './splitter-types'; + +export const splitterPaneProps = { + /** + * 可选,指定 pane 宽度,设置像素值或者百分比 + * pane初始化大小 + */ + size: { + type: String, + }, + /** + * 可选,指定 pane 最小宽度,设置像素值或者百分比 + */ + minSize: { + type: String, + }, + /** + * 可选,指定 pane 最大宽度,设置像素值或者百分比 + */ + maxSize: { + type: String, + }, + /** + * 可选,指定 pane 是否可调整大小,会影响相邻 pane + */ + resizable: { + type: Boolean, + default: true, + }, + /** + * 可选,指定 pane 是否可折叠收起 + */ + collapsible: { + type: Boolean, + default: false, + }, + /** + * 可选,指定 pane 初始化是否收起,配合 collapsible 使用 + */ + collapsed: { + type: Boolean, + default: false, + }, + /** + * 非边缘面板折叠方向,before 只生成向前折叠的按钮,after 生成向后折叠按钮,both 生成两个 + */ + collapseDirection: { + type: String as PropType, + default: 'both', + }, + /** + * 可选,是否在 pane 进行折叠后收缩 pane 宽度而非收起 + */ + shrink: { + type: Boolean, + default: false, + }, + /** + * 可选,折叠后收缩的 pane 宽度 (单位:px) + */ + shrinkWidth: { + type: Number, + default: 36, + }, + /** + * 内部排版使用,不对外提供,TODO 待优化 + */ + order: { + type: Number, + }, +} as const; + +export type SplitterPaneProps = ExtractPropTypes; diff --git a/devui/splitter/src/splitter-pane.scss b/devui/splitter/src/splitter-pane.scss new file mode 100644 index 00000000..c8cf7700 --- /dev/null +++ b/devui/splitter/src/splitter-pane.scss @@ -0,0 +1,23 @@ +.devui-splitter-pane { + position: relative; + flex: 1 1 auto; + display: block; + min-width: 0; + max-width: 100%; + min-height: 0; + max-height: 100%; + + &-fixed { + flex-grow: 0; + flex-shrink: 0; + } + + &-hidden { + flex: 0 !important; + overflow: hidden !important; + } + + &-grow { + flex-grow: 1 !important; + } +} diff --git a/devui/splitter/src/splitter-pane.tsx b/devui/splitter/src/splitter-pane.tsx new file mode 100644 index 00000000..3da126e7 --- /dev/null +++ b/devui/splitter/src/splitter-pane.tsx @@ -0,0 +1,137 @@ +import { + defineComponent, + ref, + watch, + nextTick, + inject, + onMounted, + onUpdated, +} from 'vue' +import { + addClass, + hasClass, + removeClass, +} from '../../shared/util/class' +import { setStyle } from '../../shared/util/set-style' +import { useSplitterStore } from './splitter-store' +import './splitter-pane.scss' +import { splitterPaneProps, SplitterPaneProps } from './splitter-pane-type' + +export default defineComponent({ + name: 'DSplitterPane', + props: splitterPaneProps, + emits: ['sizeChange', 'collapsedChange'], + setup(props: SplitterPaneProps, { slots, expose, emit }) { + const { setPanes } = useSplitterStore() + + const domRef = ref() + watch( + () => props.order, + (order) => { + nextTick(() => { + const ele = domRef.value + setStyle(ele, { order }) + }) + }, + { immediate: true } + ) + + // pane 初始化大小 + const setSizeStyle = (curSize) => { + const ele = domRef.value + ele.style.flexBasis = curSize + const paneFixedClass = 'devui-splitter-pane-fixed' + if (curSize) { + // 设置 flex-grow 和 flex-shrink + addClass(ele, paneFixedClass) + } else { + removeClass(ele, paneFixedClass) + } + } + + watch( + () => props.size, + (newSize) => { + nextTick(() => { + setSizeStyle(newSize) + }) + }, + { immediate: true } + ) + + const panes = inject('panes') + const orientation = inject('orientation') + let initialSize = '' // 记录初始化挂载传入的大小 + onMounted(() => { + initialSize = props.size + setPanes({ panes }) + }) + + onUpdated(() => { + setPanes({ panes }) + }) + + // 获取当前 pane大小 + const getPaneSize = (): number => { + const el = domRef?.value + if (orientation === 'vertical') { + return el.offsetHeight + } else { + return el.offsetWidth + } + } + + const toggleCollapseClass = () => { + const paneHiddenClass = 'devui-splitter-pane-hidden' + nextTick(() => { + const el = domRef.value + if (!props.collapsed) { + removeClass(el, paneHiddenClass) + } else { + addClass(el, paneHiddenClass) + } + + if (props.collapsed && props.shrink) { + removeClass(el, paneHiddenClass) + setStyle(el, { flexBasis: `${props.shrinkWidth}px` }) + } else { + setStyle(el, { flexBasis: initialSize }) + } + }) + } + watch( + () => props.collapsed, + () => { + nextTick(() => { + toggleCollapseClass() + }) + } + ) + + // 收起时用于改变相邻 pane 的 flex-grow 属性来改变非自适应 pane 的 size + const toggleNearPaneFlexGrow = (collapsed) => { + nextTick(() => { + const flexGrowClass = 'devui-splitter-pane-grow' + if (hasClass(domRef.value, flexGrowClass)) { + removeClass(domRef.value, flexGrowClass) + } else if (collapsed) { + addClass(domRef.value, flexGrowClass) + } + }) + } + + // 暴露给外部使用 + expose({ + getPaneSize, + toggleNearPaneFlexGrow, + }) + + return () => { + return ( +
+ {slots.default?.()} +
+ ) + } + }, +}) diff --git a/devui/splitter/src/splitter-store.ts b/devui/splitter/src/splitter-store.ts new file mode 100644 index 00000000..1112ea91 --- /dev/null +++ b/devui/splitter/src/splitter-store.ts @@ -0,0 +1,200 @@ +import SplitterPane from './splitter-pane' +import { reactive, readonly } from 'vue' + +export interface Pane { + getPaneSize: () => number +} + +export interface PaneState { + index: number + initialSize: number + minSize: number + maxSize: number +} + +export interface DragState { + prev: PaneState + next: PaneState +} + +type SplitterPane = typeof SplitterPane & Pane +export interface splitterState { + panes: Array // 所有 pane 对象的一些关键信息 + splitterContainerSize: number +} + +const state: splitterState = reactive({ + panes: [], + splitterContainerSize: 0, +}) + +export function useSplitterStore() { + // 配置 pane 信息,panes 列表,方便后续计算使用 + const setPanes = ({ panes }): void => { + state.panes = panes.map((pane: SplitterPane) => { + pane.getPaneSize = pane?.component?.exposed.getPaneSize + return pane + }) + } + const setSplitter = ({ containerSize }: { containerSize: number; }): void => { + state.splitterContainerSize = containerSize + } + + // 获取 pane,防止没有初始化的时候调用内部方法取值 + const getPane = (index: number): SplitterPane => { + if (!state.panes || index < 0 || index >= state.panes.length) { + throw new Error('no pane can return.') + } + return state.panes[index] + } + + // 按下的时候计算 pane 的 size 信息 + const dragState = (splitbarIndex: number): DragState => { + const prev = getPane(splitbarIndex) + const next = getPane(splitbarIndex + 1) + const total = prev.getPaneSize() + next.getPaneSize() + return { + prev: { + index: splitbarIndex, + initialSize: prev.getPaneSize(), + // 设置有最小值,直接取值,如果没有设置就用两个 pane 总和减去相邻 pane 的最大值,都没设置(NaN)再取0 + minSize: + toPixels(prev.component.props.minSize) || + total - toPixels(next.component.props.maxSize) || + 0, + // 设置最大值,直接取值,如果没有设置就用两个 pane 总和减去相邻 pane 的最小值,都没设置(NaN)再取两个 pane 总和 + maxSize: + toPixels(prev.component.props.maxSize) || + total - toPixels(next.component.props.minSize) || + total, + }, + next: { + index: splitbarIndex + 1, + initialSize: next.getPaneSize(), + minSize: + toPixels(next.component.props.minSize) || + total - toPixels(prev.component.props.maxSize) || + 0, + maxSize: + toPixels(next.component.props.maxSize) || + total - toPixels(prev.component.props.minSize) || + total, + }, + } + } + + // 大小限制函数,(max)小于最小值时取最小值,(min)大于最大值时取最大值 + const clamp = ( + minSize: number, + maxSize: number, + initialSize: number + ): number => { + return Math.min(maxSize, Math.max(minSize, initialSize)) + } + + // resize pane的大小 + const resize = (paneState: PaneState, moveSize: number): void => { + const pane = getPane(paneState.index) + const splitterSize = state.splitterContainerSize + const newSize = clamp( + paneState.minSize, + paneState.maxSize, + paneState.initialSize + moveSize + ) + let size = '' + if (isPercent(pane.component.props.size)) { + size = (100 * newSize) / splitterSize + '%' + } else { + size = newSize + 'px' + } + pane.component.props.size = size + pane.component.emit('sizeChange', size) + } + + // 判断 pane 是否可以调整大小,只要有一边设置了不可调整或者收起,相邻 pane 调整就失效 + const isResizable = (splitBarIndex: number): boolean => { + const prevPane = getPane(splitBarIndex) + const nextPane = getPane(splitBarIndex + 1) + const paneCollapsed = + prevPane?.component?.props?.collapsed || nextPane?.component?.props?.collapsed + return ( + prevPane?.component?.props?.resizable && + nextPane?.component?.props?.resizable && + !paneCollapsed + ) + } + + // 判断分割条是否是固定的,只要有一边不能调整, 就是禁用状态固定 bar + const isStaticBar = (splitBarIndex: number): boolean => { + const prevPane = getPane(splitBarIndex) + const nextPane = getPane(splitBarIndex + 1) + return !( + prevPane?.component?.props?.resizable && nextPane?.component?.props?.resizable + ) + } + + // 判断是不是百分比设置宽度 + const isPercent = (size: string) => { + return /%$/.test(size) + } + + // 计算时把百分比转换为像素 + const toPixels = (size: string): number => { + // 值不满足转换时,result 为 NaN,方便计算最小、最大宽度判断 + let result = parseFloat(size) + if (isPercent(size)) { + result = (state.splitterContainerSize * result) / 100 + } + return result + } + + // 切换 pane 展开,收起 + const tooglePane = ( + paneIndex: number, + nearPaneIndex: number, + lockStatus?: boolean + ): void => { + const pane = getPane(paneIndex) + const nearPane = getPane(nearPaneIndex) + if (pane?.component?.props?.collapsible) { + pane.component.props.collapsed = lockStatus + ? pane?.component?.props?.collapsed + : !pane?.component?.props?.collapsed + nearPane?.component?.exposed?.toggleNearPaneFlexGrow( + pane?.component?.props?.collapsed + ) + pane?.component?.emit('collapsedChange', pane?.component?.props?.collapsed) + } + } + + // 设置 pane 大小 + const setSize = (state: DragState, distance: number): void => { + const prev = getPane(state.prev.index) + const next = getPane(state.next.index) + if (prev.component.props.size && next.component.props.size) { + // 相邻的两个 pane 都指定了 size,需要同时修改 size + resize(state.prev, distance) + resize(state.next, distance) + } else if (next.component.props.size) { + // 只有 next pane指定了 size,直接修改 next pane + resize(state.next, -distance) + } else { + // 最后都没有指定 size,直接修改 pre pane + resize(state.prev, distance) + } + } + + const readonlyState = readonly(state) + + return { + setPanes, + getPane, + isStaticBar, + setSize, + setSplitter, + dragState, + isResizable, + tooglePane, + splitterState: readonlyState, + } +} diff --git a/devui/splitter/src/splitter-types.ts b/devui/splitter/src/splitter-types.ts new file mode 100644 index 00000000..73467eb8 --- /dev/null +++ b/devui/splitter/src/splitter-types.ts @@ -0,0 +1,29 @@ +import type { PropType, ExtractPropTypes } from 'vue'; +export type SplitterOrientation = 'vertical' | 'horizontal'; +export type CollapseDirection = 'before' | 'after' | 'both'; + +export const splitterProps = { + /** + * 可选,指定 Splitter 分割方向,可选值'vertical'|'horizontal' + */ + orientation: { + type: String as PropType, + default: 'horizontal', + }, + /** + * 可选,分隔条大小,默认 2px + */ + splitBarSize: { + type: String, + default: '2px', + }, + /** + * 是否显示展开/收缩按钮 + */ + showCollapseButton: { + type: Boolean, + default: true, + }, +} as const; + +export type SplitterProps = ExtractPropTypes; diff --git a/devui/splitter/src/splitter.scss b/devui/splitter/src/splitter.scss new file mode 100644 index 00000000..a2f5eb81 --- /dev/null +++ b/devui/splitter/src/splitter.scss @@ -0,0 +1,18 @@ +@import '../../style/theme/color'; +@import '../../style/theme/corner'; + +.devui-splitter { + display: flex; + width: 100%; + height: auto; + position: relative; + border-radius: $devui-border-radius; + + &.devui-splitter-horizontal { + flex-direction: row; + } + + &.devui-splitter-vertical { + flex-direction: column; + } +} diff --git a/devui/splitter/src/splitter.tsx b/devui/splitter/src/splitter.tsx new file mode 100644 index 00000000..cfa77eaa --- /dev/null +++ b/devui/splitter/src/splitter.tsx @@ -0,0 +1,69 @@ +import { defineComponent, reactive, ref, provide, onMounted } from 'vue' +import { splitterProps, SplitterProps } from './splitter-types' +import DSplitterBar from './splitter-bar' +import { useSplitterStore } from './splitter-store' +import './splitter.scss' + +export default defineComponent({ + name: 'DSplitter', + components: { + DSplitterBar, + }, + props: splitterProps, + emits: [], + setup(props: SplitterProps, ctx) { + const { setPanes, setSplitter } = useSplitterStore() + const state = reactive({ + panes: [], // 内嵌面板 + }) + + state.panes = ctx.slots.DSplitterPane?.() || [] + setPanes({ panes: state.panes }) + + const domRef = ref() + + provide('orientation', props.orientation) + provide('panes', state.panes) + + onMounted(() => { + let containerSize = 0 + if (props.orientation === 'vertical') { + containerSize = domRef.value.clientHeight + } else { + containerSize = domRef.value.clientWidth + } + setSplitter({ containerSize }) + }) + + return () => { + const { splitBarSize, orientation, showCollapseButton } = props + const wrapperClass = ['devui-splitter', `devui-splitter-${orientation}`] + + return ( +
+ {state.panes.map((pane, index) => { + // pane.props = pane.props || reactive({}) + if (pane.props) { + pane.props.order = index * 2 // props 有可能为空,如何处理 + } + return pane + })} + {state.panes + .filter((pane, index, arr) => index !== arr.length - 1) + .map((pane, index) => { + return ( + + ) + })} +
+ ) + } + }, +}) diff --git a/devui/splitter/src/util/d-resize-directive.ts b/devui/splitter/src/util/d-resize-directive.ts new file mode 100644 index 00000000..80747166 --- /dev/null +++ b/devui/splitter/src/util/d-resize-directive.ts @@ -0,0 +1,82 @@ +import type { Directive, DirectiveBinding } from 'vue' + +export class ResizeDirectiveProp { + enableResize = true // 是否允许拖动 + onPressEvent = function (...args: any[]): void { + /** */ + } + onDragEvent = function (...args: any[]): void { + /** */ + } + onReleaseEvent = function (...args: any[]): void { + /** */ + } +} + +let resizeDirectiveProp: ResizeDirectiveProp +const resize: Directive = { + mounted( + el, + { value = new ResizeDirectiveProp() }: DirectiveBinding + ) { + resizeDirectiveProp = value + // 是否允许拖动 + if (value.enableResize) { + bindEvent(el) + } + }, + unmounted( + el, + { value = new ResizeDirectiveProp() }: DirectiveBinding + ) { + if (value.enableResize) { + unbind(el, 'mousedown', onMousedown) + } + }, +} + +function bindEvent(el) { + // 绑定 mousedown 事件 + bind(el, 'mousedown', onMousedown) + // TODO 绑定触屏事件 +} + +function bind(el, event, callback) { + el.addEventListener && el.addEventListener(event, callback) +} + +function unbind(el, event, callback) { + el.removeEventListener && el.removeEventListener(event, callback) +} + +function onMousedown(e) { + bind(document, 'mousemove', onMousemove) + bind(document, 'mouseup', onMouseup) + resizeDirectiveProp.onPressEvent(normalizeEvent(e)) +} + +function onMousemove(e) { + resizeDirectiveProp.onDragEvent(normalizeEvent(e)) +} + +function onMouseup(e) { + unbind(document, 'mousemove', onMousemove) + unbind(document, 'mouseup', onMouseup) + resizeDirectiveProp.onReleaseEvent(normalizeEvent(e)) +} + +// 返回常用位置信息 +function normalizeEvent(e) { + return { + pageX: e.pageX, + pageY: e.pageY, + clientX: e.clientX, + clientY: e.clientY, + offsetX: e.offsetX, + offsetY: e.offsetY, + type: e.type, + originalEvent: e, + } +} + +export default resize diff --git a/docs/.vitepress/config/sidebar.ts b/docs/.vitepress/config/sidebar.ts index aca6bee6..9fe21028 100644 --- a/docs/.vitepress/config/sidebar.ts +++ b/docs/.vitepress/config/sidebar.ts @@ -91,7 +91,7 @@ const sidebar = { text: '布局', children: [ { text: 'Layout 布局', link: '/components/layout/' }, - { text: 'Splitter 分割器', link: '/components/splitter/' } + { text: 'Splitter 分割器', link: '/components/splitter/', status: '开发中' } ] }, ] diff --git a/docs/vite.config.ts b/docs/vite.config.ts index e605580f..d22e6552 100644 --- a/docs/vite.config.ts +++ b/docs/vite.config.ts @@ -1,12 +1,10 @@ -import path from 'path'; +import path from 'path' import { defineConfig } from 'vite'; import vueJsx from '@vitejs/plugin-vue-jsx'; export default defineConfig({ resolve: { - alias: [ - { find: '@devui', replacement: path.resolve(__dirname, '../devui') }, - ] + alias: [{ find: '@devui', replacement: path.resolve(__dirname, '../devui') }], }, plugins: [ vueJsx({}), diff --git a/sites/components/splitter/index.md b/sites/components/splitter/index.md new file mode 100644 index 00000000..95799598 --- /dev/null +++ b/sites/components/splitter/index.md @@ -0,0 +1,98 @@ +# Splitter 分割器 + +页面分割器。 + +**何时使用** + +需要动态调整不同页面布局区域大小的时候选择使用。 + +## 基本用法 + +:::demo + +```vue + + + + + +``` +::: + + + +## 组合布局用法【TODO】 + +## 指定折叠收起方向【TODO】 + +## 折叠收缩显示菜单【TODO】 + -- Gitee From 1d6ede70f25eec5d141116fb628ae36cfaba3ddc Mon Sep 17 00:00:00 2001 From: Jecyu Date: Mon, 20 Sep 2021 21:54:39 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(splitter):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=9E=82=E7=9B=B4=E5=B8=83=E5=B1=80=E7=89=B9=E6=80=A7=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E9=80=A0=20splitter-store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devui/splitter/src/splitter-bar.scss | 171 +++++++++++++++--- devui/splitter/src/splitter-bar.tsx | 79 ++++---- devui/splitter/src/splitter-pane-type.tsx | 6 - devui/splitter/src/splitter-pane.tsx | 125 +++++++------ devui/splitter/src/splitter-store.ts | 141 +++++++-------- devui/splitter/src/splitter.tsx | 57 +++--- devui/splitter/src/util/d-resize-directive.ts | 63 ++++--- {sites => docs}/components/splitter/index.md | 79 +++++++- 8 files changed, 457 insertions(+), 264 deletions(-) rename {sites => docs}/components/splitter/index.md (53%) diff --git a/devui/splitter/src/splitter-bar.scss b/devui/splitter/src/splitter-bar.scss index bc6495b1..f78bcb91 100644 --- a/devui/splitter/src/splitter-bar.scss +++ b/devui/splitter/src/splitter-bar.scss @@ -36,6 +36,13 @@ width: 12px; height: 30px; + &.prev, + &.next { + &.hidden { + display: none; + } + } + &.prev { border-radius: 0 $devui-border-radius-feedback @@ -48,7 +55,6 @@ left: 1px; } - // 设置线条 &::before { transform: rotate(-70deg); } @@ -57,6 +63,12 @@ transform: rotate(70deg); } + &::after, + &.collapsed::after { + top: 18px; + left: 1px; + } + &::after { transform: rotate(70deg); } @@ -64,41 +76,156 @@ &.collapsed::after { transform: rotate(-70deg); } + } - &::after { + &.next { + border-radius: + $devui-border-radius-feedback 0 0 + $devui-border-radius-feedback; + right: 100%; + + &::before, + &.collapsed::before { + top: 9px; + right: 1px; + } + + &::before { + transform: rotate(70deg); + } + + &.collapsed::before { + transform: rotate(-70deg); + } + + &::after, + &.collapsed::after { top: 18px; - left: 1px; + right: 1px; + } + + &::after { + transform: rotate(-70deg); + } + + &.collapsed::after { + transform: rotate(70deg); } } } + } - &.resizable { - // 修正 IE 浏览器,css 伪元素中鼠标手型无效 - cursor: col-resize; + &-vertical { + .devui-collapse { + height: 12px; + width: 30px; - &::after { - // content 由下面的 :not(.none-resizable) 控制显示 - cursor: col-resize; - height: 100%; - width: 10px; - top: 0; + &.prev, + &.next { + &.hidden { + display: none; + } } - &:not(.none-resizable) { - // 非折叠的情况下 - &:hover, - &:focus, - &:active { - background-color: $devui-brand-hover; + &.prev { + border-radius: + 0 0 $devui-border-radius-feedback + $devui-border-radius-feedback; + top: 100%; + + &::before, + &.collapsed::before { + bottom: 5px; + left: 5px; + } + + &::before { + transform: rotate(-20deg); + } + + &.collapsed::before { + transform: rotate(20deg); + } + + &::after, + &.collapsed::after { + bottom: 5px; + left: 14px; } &::after { - content: ''; - display: block; - position: absolute; - z-index: 10; + transform: rotate(20deg); + } + + &.collapsed::after { + transform: rotate(-20deg); } } + + &.next { + border-radius: + $devui-border-radius-feedback + $devui-border-radius-feedback 0 0; + bottom: 100%; + + &::before, + &.collapsed::before { + top: 5px; + left: 5px; + } + + &::before { + transform: rotate(20deg); + } + + &.collapsed::before { + transform: rotate(-20deg); + top: 5px; + left: 5px; + } + + &::after, + &.collapsed::after { + top: 5px; + left: 14px; + } + + &::after { + transform: rotate(-20deg); + } + + &.collapsed::after { + transform: rotate(20deg); + } + } + } + } + + &-horizontal.resizable:not(.none-resizable), + &-vertical.resizable:not(.none-resizable) { + &:hover, + &:focus, + &:active { + background-color: $devui-brand-hover; + } + + &::after { + content: ''; + display: block; + position: absolute; + z-index: 10; + } + } + + &-horizontal.resizable { + // 修正IE浏览器,css伪元素中鼠标手型无效 + cursor: col-resize; + + &::after { + cursor: col-resize; + height: 100%; + width: 10px; + top: 0; } } diff --git a/devui/splitter/src/splitter-bar.tsx b/devui/splitter/src/splitter-bar.tsx index 51b05f18..b48aa196 100644 --- a/devui/splitter/src/splitter-bar.tsx +++ b/devui/splitter/src/splitter-bar.tsx @@ -7,8 +7,9 @@ import { computed, withDirectives, onMounted, + inject, } from 'vue' -import { useSplitterStore } from './splitter-store' +import type { SplitterStore } from './splitter-store' import { setStyle } from '../../shared/util/set-style' import { addClass, removeClass } from '../../shared/util/class' import dresize, { ResizeDirectiveProp } from './util/d-resize-directive' @@ -19,15 +20,7 @@ export default defineComponent({ name: 'DSplitterBar', props: splitterBarProps, setup(props: SplitterBarProps) { - const { - splitterState, - getPane, - isStaticBar, - isResizable, - dragState, - setSize, - tooglePane, - } = useSplitterStore() + const store: SplitterStore = inject('splitterStore') const state = reactive({ wrapperClass: `devui-splitter-bar devui-splitter-bar-${props.orientation}`, }) @@ -45,9 +38,9 @@ export default defineComponent({ ) watch( - () => splitterState.panes, + () => store.state.panes, () => { - if (!isStaticBar(props.index)) { + if (!store.isStaticBar(props.index)) { state.wrapperClass += ' resizable' } else { // TODO 禁用的样式处理 @@ -63,44 +56,44 @@ export default defineComponent({ // TODO 待优化,如何像 angular rxjs 操作一样优雅 const resizeProp: ResizeDirectiveProp = { enableResize: true, - onPressEvent: ({ originalEvent }) => { + onPressEvent: function ({ originalEvent }): void { originalEvent.stopPropagation() // 按下的时候,阻止事件冒泡 - if (!isResizable(props.index)) return - initState = dragState(props.index) + if (!store.isResizable(props.index)) return + initState = store.dragState(props.index) coordinate.originalX = originalEvent.pageX - coordinate.originalY = originalEvent.pageX + coordinate.originalY = originalEvent.pageY }, - onDragEvent: function ({ originalEvent }) { + onDragEvent: function ({ originalEvent }): void { originalEvent.stopPropagation() // 移动的时候,阻止事件冒泡 - if (!isResizable(props.index)) return + if (!store.isResizable(props.index)) return coordinate.pageX = originalEvent.pageX - coordinate.pageY = originalEvent.pageX + coordinate.pageY = originalEvent.pageY let distance if (props.orientation === 'vertical') { distance = coordinate.pageY - coordinate.originalY } else { distance = coordinate.pageX - coordinate.originalX } - setSize(initState, distance) + store.setSize(initState, distance) }, - onReleaseEvent: function ({ originalEvent }) { + onReleaseEvent: function ({ originalEvent }): void { originalEvent.stopPropagation() // 释放的时候,阻止事件冒泡 - if (!isResizable(props.index)) return + if (!store.isResizable(props.index)) return coordinate.pageX = originalEvent.pageX - coordinate.pageY = originalEvent.pageX + coordinate.pageY = originalEvent.pageY let distance if (props.orientation === 'vertical') { distance = coordinate.pageY - coordinate.originalY } else { distance = coordinate.pageX - coordinate.originalX } - setSize(initState, distance) + store.setSize(initState, distance) }, } const queryPanes = (index, nearIndex) => { - const pane = getPane(index) - const nearPane = getPane(nearIndex) + const pane = store.getPane(index) + const nearPane = store.getPane(nearIndex) return { pane, nearPane, @@ -125,6 +118,8 @@ export default defineComponent({ // 计算前面板收起操作样式 const prevClass = computed(() => { const { pane, nearPane } = queryPanes(props.index, props.index + 1) + // TODO 提示文字 + // 第一个面板或者其它面板折叠方向不是向后的, 显示操作按钮 const showIcon = pane?.component?.props?.collapseDirection !== 'after' || @@ -132,6 +127,18 @@ export default defineComponent({ return generateCollapseClass(pane, nearPane, showIcon) }) + // 计算相邻面板收起操作样式 + const nextClass = computed(() => { + const { pane, nearPane } = queryPanes(props.index + 1, props.index) + // TODO 提示文字 + + // 最后一个面板或者其它面板折叠方向不是向前的显示操作按钮 + const showIcon = + pane?.component?.props?.collapseDirection !== 'before' || + props.index + 1 === store.state.paneCount - 1 + return generateCollapseClass(pane, nearPane, showIcon) + }) + // 切换是否允许拖拽,收起时不能拖拽 const toggleResize = () => { const { pane, nearPane } = queryPanes(props.index, props.index + 1) @@ -145,18 +152,19 @@ export default defineComponent({ } } - const handleCollapsePrePane = (lockStatus?) => { - tooglePane(props.index, props.index + 1, lockStatus) + const handleCollapsePrePane = (lockStatus?: boolean) => { + store.tooglePane(props.index, props.index + 1, lockStatus) toggleResize() } - const handleCollapseNextPane = () => { - /**TODO */ + const handleCollapseNextPane = (lockStatus?: boolean) => { + store.tooglePane(props.index + 1, props.index, lockStatus) + toggleResize() } const initialCollapseStatus = () => { handleCollapsePrePane(true) - // collapseNextPane(true); + handleCollapseNextPane(true) } onMounted(() => { @@ -169,12 +177,17 @@ export default defineComponent({ {props.showCollapseButton ? (
handleCollapsePrePane()} + onClick={() => { + handleCollapsePrePane() + }} >
) : null}
{props.showCollapseButton ? ( - +
handleCollapseNextPane()} + >
) : null} , [[dresize, resizeProp]] diff --git a/devui/splitter/src/splitter-pane-type.tsx b/devui/splitter/src/splitter-pane-type.tsx index 9e366021..b1773e9e 100644 --- a/devui/splitter/src/splitter-pane-type.tsx +++ b/devui/splitter/src/splitter-pane-type.tsx @@ -63,12 +63,6 @@ export const splitterPaneProps = { type: Number, default: 36, }, - /** - * 内部排版使用,不对外提供,TODO 待优化 - */ - order: { - type: Number, - }, } as const; export type SplitterPaneProps = ExtractPropTypes; diff --git a/devui/splitter/src/splitter-pane.tsx b/devui/splitter/src/splitter-pane.tsx index 3da126e7..c588a518 100644 --- a/devui/splitter/src/splitter-pane.tsx +++ b/devui/splitter/src/splitter-pane.tsx @@ -6,132 +6,129 @@ import { inject, onMounted, onUpdated, -} from 'vue' -import { - addClass, - hasClass, - removeClass, -} from '../../shared/util/class' -import { setStyle } from '../../shared/util/set-style' -import { useSplitterStore } from './splitter-store' -import './splitter-pane.scss' -import { splitterPaneProps, SplitterPaneProps } from './splitter-pane-type' +} from 'vue'; +import { addClass, hasClass, removeClass } from '../../shared/util/class'; +import { setStyle } from '../../shared/util/set-style'; +import type { SplitterStore } from './splitter-store'; +import { splitterPaneProps, SplitterPaneProps } from './splitter-pane-type'; +import './splitter-pane.scss'; export default defineComponent({ name: 'DSplitterPane', props: splitterPaneProps, emits: ['sizeChange', 'collapsedChange'], - setup(props: SplitterPaneProps, { slots, expose, emit }) { - const { setPanes } = useSplitterStore() - - const domRef = ref() + setup(props: SplitterPaneProps, { slots, expose }) { + const store: SplitterStore = inject('splitterStore'); + const domRef = ref(); + const order = ref(); watch( - () => props.order, + () => order.value, (order) => { nextTick(() => { - const ele = domRef.value - setStyle(ele, { order }) - }) - }, - { immediate: true } - ) + const ele = domRef.value; + setStyle(ele, { order }); + }); + } + ); // pane 初始化大小 const setSizeStyle = (curSize) => { - const ele = domRef.value - ele.style.flexBasis = curSize - const paneFixedClass = 'devui-splitter-pane-fixed' + const ele = domRef.value; + ele.style.flexBasis = curSize; + const paneFixedClass = 'devui-splitter-pane-fixed'; if (curSize) { // 设置 flex-grow 和 flex-shrink - addClass(ele, paneFixedClass) + addClass(ele, paneFixedClass); } else { - removeClass(ele, paneFixedClass) + removeClass(ele, paneFixedClass); } - } + }; watch( () => props.size, (newSize) => { nextTick(() => { - setSizeStyle(newSize) - }) + setSizeStyle(newSize); + }); }, { immediate: true } - ) + ); - const panes = inject('panes') - const orientation = inject('orientation') - let initialSize = '' // 记录初始化挂载传入的大小 + const orientation = inject('orientation'); + let initialSize = ''; // 记录初始化挂载传入的大小 onMounted(() => { - initialSize = props.size - setPanes({ panes }) - }) + initialSize = props.size; + store.setPanes({ panes: store.state.panes }); + }); onUpdated(() => { - setPanes({ panes }) - }) + store.setPanes({ panes: store.state.panes }); + }); // 获取当前 pane大小 const getPaneSize = (): number => { - const el = domRef?.value + const el = domRef?.value; if (orientation === 'vertical') { - return el.offsetHeight + return el.offsetHeight; } else { - return el.offsetWidth + return el.offsetWidth; } - } + }; const toggleCollapseClass = () => { - const paneHiddenClass = 'devui-splitter-pane-hidden' + const paneHiddenClass = 'devui-splitter-pane-hidden'; nextTick(() => { - const el = domRef.value + const el = domRef.value; if (!props.collapsed) { - removeClass(el, paneHiddenClass) + removeClass(el, paneHiddenClass); } else { - addClass(el, paneHiddenClass) + addClass(el, paneHiddenClass); } if (props.collapsed && props.shrink) { - removeClass(el, paneHiddenClass) - setStyle(el, { flexBasis: `${props.shrinkWidth}px` }) + removeClass(el, paneHiddenClass); + setStyle(el, { flexBasis: `${props.shrinkWidth}px` }); } else { - setStyle(el, { flexBasis: initialSize }) + setStyle(el, { flexBasis: initialSize }); } - }) - } + }); + }; + watch( () => props.collapsed, () => { nextTick(() => { - toggleCollapseClass() - }) - } - ) + toggleCollapseClass(); + }); + }, + { immediate: true } + ); // 收起时用于改变相邻 pane 的 flex-grow 属性来改变非自适应 pane 的 size const toggleNearPaneFlexGrow = (collapsed) => { nextTick(() => { - const flexGrowClass = 'devui-splitter-pane-grow' + const flexGrowClass = 'devui-splitter-pane-grow'; if (hasClass(domRef.value, flexGrowClass)) { - removeClass(domRef.value, flexGrowClass) + removeClass(domRef.value, flexGrowClass); } else if (collapsed) { - addClass(domRef.value, flexGrowClass) + addClass(domRef.value, flexGrowClass); } - }) - } + }); + }; // 暴露给外部使用 expose({ + order, getPaneSize, toggleNearPaneFlexGrow, - }) + }); return () => { return (
{slots.default?.()}
- ) - } + ); + }; }, -}) +}); diff --git a/devui/splitter/src/splitter-store.ts b/devui/splitter/src/splitter-store.ts index 1112ea91..e41a9bf3 100644 --- a/devui/splitter/src/splitter-store.ts +++ b/devui/splitter/src/splitter-store.ts @@ -1,5 +1,5 @@ import SplitterPane from './splitter-pane' -import { reactive, readonly } from 'vue' +import { reactive } from 'vue' export interface Pane { getPaneSize: () => number @@ -20,38 +20,46 @@ export interface DragState { type SplitterPane = typeof SplitterPane & Pane export interface splitterState { panes: Array // 所有 pane 对象的一些关键信息 + paneCount: number splitterContainerSize: number } -const state: splitterState = reactive({ - panes: [], - splitterContainerSize: 0, -}) - -export function useSplitterStore() { +export class SplitterStore { + state: splitterState + constructor() { + this.state = reactive({ + panes: [], + splitterContainerSize: 0, + paneCount: 0, + }) + } // 配置 pane 信息,panes 列表,方便后续计算使用 - const setPanes = ({ panes }): void => { - state.panes = panes.map((pane: SplitterPane) => { + setPanes({ panes }): void { + this.state.panes = panes.map((pane: SplitterPane, index: number) => { + if (pane.component) { + pane.component.exposed.order.value = index * 2 + } pane.getPaneSize = pane?.component?.exposed.getPaneSize return pane }) + this.state.paneCount = panes.length } - const setSplitter = ({ containerSize }: { containerSize: number; }): void => { - state.splitterContainerSize = containerSize + setSplitter({ containerSize }: { containerSize: number; }): void { + this.state.splitterContainerSize = containerSize } // 获取 pane,防止没有初始化的时候调用内部方法取值 - const getPane = (index: number): SplitterPane => { - if (!state.panes || index < 0 || index >= state.panes.length) { + getPane(index: number): SplitterPane { + if (!this.state.panes || index < 0 || index >= this.state.panes.length) { throw new Error('no pane can return.') } - return state.panes[index] + return this.state.panes[index] } // 按下的时候计算 pane 的 size 信息 - const dragState = (splitbarIndex: number): DragState => { - const prev = getPane(splitbarIndex) - const next = getPane(splitbarIndex + 1) + dragState(splitbarIndex: number): DragState { + const prev = this.getPane(splitbarIndex) + const next = this.getPane(splitbarIndex + 1) const total = prev.getPaneSize() + next.getPaneSize() return { prev: { @@ -59,50 +67,46 @@ export function useSplitterStore() { initialSize: prev.getPaneSize(), // 设置有最小值,直接取值,如果没有设置就用两个 pane 总和减去相邻 pane 的最大值,都没设置(NaN)再取0 minSize: - toPixels(prev.component.props.minSize) || - total - toPixels(next.component.props.maxSize) || + this.toPixels(prev.component.props.minSize) || + total - this.toPixels(next.component.props.maxSize) || 0, // 设置最大值,直接取值,如果没有设置就用两个 pane 总和减去相邻 pane 的最小值,都没设置(NaN)再取两个 pane 总和 maxSize: - toPixels(prev.component.props.maxSize) || - total - toPixels(next.component.props.minSize) || + this.toPixels(prev.component.props.maxSize) || + total - this.toPixels(next.component.props.minSize) || total, }, next: { index: splitbarIndex + 1, initialSize: next.getPaneSize(), minSize: - toPixels(next.component.props.minSize) || - total - toPixels(prev.component.props.maxSize) || + this.toPixels(next.component.props.minSize) || + total - this.toPixels(prev.component.props.maxSize) || 0, maxSize: - toPixels(next.component.props.maxSize) || - total - toPixels(prev.component.props.minSize) || + this.toPixels(next.component.props.maxSize) || + total - this.toPixels(prev.component.props.minSize) || total, }, } } // 大小限制函数,(max)小于最小值时取最小值,(min)大于最大值时取最大值 - const clamp = ( - minSize: number, - maxSize: number, - initialSize: number - ): number => { + clamp(minSize: number, maxSize: number, initialSize: number): number { return Math.min(maxSize, Math.max(minSize, initialSize)) } // resize pane的大小 - const resize = (paneState: PaneState, moveSize: number): void => { - const pane = getPane(paneState.index) - const splitterSize = state.splitterContainerSize - const newSize = clamp( + resize(paneState: PaneState, moveSize: number): void { + const pane = this.getPane(paneState.index) + const splitterSize = this.state.splitterContainerSize + const newSize = this.clamp( paneState.minSize, paneState.maxSize, paneState.initialSize + moveSize ) let size = '' - if (isPercent(pane.component.props.size)) { + if (this.isPercent(pane.component.props.size)) { size = (100 * newSize) / splitterSize + '%' } else { size = newSize + 'px' @@ -112,11 +116,12 @@ export function useSplitterStore() { } // 判断 pane 是否可以调整大小,只要有一边设置了不可调整或者收起,相邻 pane 调整就失效 - const isResizable = (splitBarIndex: number): boolean => { - const prevPane = getPane(splitBarIndex) - const nextPane = getPane(splitBarIndex + 1) + isResizable(splitBarIndex: number): boolean { + const prevPane = this.getPane(splitBarIndex) + const nextPane = this.getPane(splitBarIndex + 1) const paneCollapsed = - prevPane?.component?.props?.collapsed || nextPane?.component?.props?.collapsed + prevPane?.component?.props?.collapsed || + nextPane?.component?.props?.collapsed return ( prevPane?.component?.props?.resizable && nextPane?.component?.props?.resizable && @@ -125,37 +130,38 @@ export function useSplitterStore() { } // 判断分割条是否是固定的,只要有一边不能调整, 就是禁用状态固定 bar - const isStaticBar = (splitBarIndex: number): boolean => { - const prevPane = getPane(splitBarIndex) - const nextPane = getPane(splitBarIndex + 1) + isStaticBar(splitBarIndex: number): boolean { + const prevPane = this.getPane(splitBarIndex) + const nextPane = this.getPane(splitBarIndex + 1) return !( - prevPane?.component?.props?.resizable && nextPane?.component?.props?.resizable + prevPane?.component?.props?.resizable && + nextPane?.component?.props?.resizable ) } // 判断是不是百分比设置宽度 - const isPercent = (size: string) => { + isPercent(size: string) { return /%$/.test(size) } // 计算时把百分比转换为像素 - const toPixels = (size: string): number => { + toPixels(size: string): number { // 值不满足转换时,result 为 NaN,方便计算最小、最大宽度判断 let result = parseFloat(size) - if (isPercent(size)) { - result = (state.splitterContainerSize * result) / 100 + if (this.isPercent(size)) { + result = (this.state.splitterContainerSize * result) / 100 } return result } // 切换 pane 展开,收起 - const tooglePane = ( + tooglePane( paneIndex: number, nearPaneIndex: number, lockStatus?: boolean - ): void => { - const pane = getPane(paneIndex) - const nearPane = getPane(nearPaneIndex) + ): void { + const pane = this.getPane(paneIndex) + const nearPane = this.getPane(nearPaneIndex) if (pane?.component?.props?.collapsible) { pane.component.props.collapsed = lockStatus ? pane?.component?.props?.collapsed @@ -163,38 +169,27 @@ export function useSplitterStore() { nearPane?.component?.exposed?.toggleNearPaneFlexGrow( pane?.component?.props?.collapsed ) - pane?.component?.emit('collapsedChange', pane?.component?.props?.collapsed) + pane?.component?.emit( + 'collapsedChange', + pane?.component?.props?.collapsed + ) } } // 设置 pane 大小 - const setSize = (state: DragState, distance: number): void => { - const prev = getPane(state.prev.index) - const next = getPane(state.next.index) + setSize(state: DragState, distance: number): void { + const prev = this.getPane(state.prev.index) + const next = this.getPane(state.next.index) if (prev.component.props.size && next.component.props.size) { // 相邻的两个 pane 都指定了 size,需要同时修改 size - resize(state.prev, distance) - resize(state.next, distance) + this.resize(state.prev, distance) + this.resize(state.next, -distance) } else if (next.component.props.size) { // 只有 next pane指定了 size,直接修改 next pane - resize(state.next, -distance) + this.resize(state.next, -distance) } else { // 最后都没有指定 size,直接修改 pre pane - resize(state.prev, distance) + this.resize(state.prev, distance) } } - - const readonlyState = readonly(state) - - return { - setPanes, - getPane, - isStaticBar, - setSize, - setSplitter, - dragState, - isResizable, - tooglePane, - splitterState: readonlyState, - } } diff --git a/devui/splitter/src/splitter.tsx b/devui/splitter/src/splitter.tsx index cfa77eaa..b9f2413e 100644 --- a/devui/splitter/src/splitter.tsx +++ b/devui/splitter/src/splitter.tsx @@ -1,8 +1,8 @@ -import { defineComponent, reactive, ref, provide, onMounted } from 'vue' -import { splitterProps, SplitterProps } from './splitter-types' -import DSplitterBar from './splitter-bar' -import { useSplitterStore } from './splitter-store' -import './splitter.scss' +import { defineComponent, reactive, ref, provide, onMounted } from 'vue'; +import { splitterProps, SplitterProps } from './splitter-types'; +import DSplitterBar from './splitter-bar'; +import { SplitterStore } from './splitter-store'; +import './splitter.scss'; export default defineComponent({ name: 'DSplitter', @@ -12,58 +12,53 @@ export default defineComponent({ props: splitterProps, emits: [], setup(props: SplitterProps, ctx) { - const { setPanes, setSplitter } = useSplitterStore() + const store: SplitterStore = new SplitterStore(); const state = reactive({ panes: [], // 内嵌面板 - }) + }); - state.panes = ctx.slots.DSplitterPane?.() || [] - setPanes({ panes: state.panes }) + state.panes = ctx.slots.DSplitterPane?.() || []; - const domRef = ref() + store.setPanes({ panes: state.panes }); - provide('orientation', props.orientation) - provide('panes', state.panes) + const domRef = ref(); + + provide('orientation', props.orientation); + provide('splitterStore', store); onMounted(() => { - let containerSize = 0 + let containerSize = 0; if (props.orientation === 'vertical') { - containerSize = domRef.value.clientHeight + containerSize = domRef.value.clientHeight; } else { - containerSize = domRef.value.clientWidth + containerSize = domRef.value.clientWidth; } - setSplitter({ containerSize }) - }) + store.setSplitter({ containerSize }); + }); return () => { - const { splitBarSize, orientation, showCollapseButton } = props - const wrapperClass = ['devui-splitter', `devui-splitter-${orientation}`] + const { splitBarSize, orientation, showCollapseButton } = props; + const wrapperClass = ['devui-splitter', `devui-splitter-${orientation}`]; return (
- {state.panes.map((pane, index) => { - // pane.props = pane.props || reactive({}) - if (pane.props) { - pane.props.order = index * 2 // props 有可能为空,如何处理 - } - return pane - })} + {state.panes} {state.panes .filter((pane, index, arr) => index !== arr.length - 1) .map((pane, index) => { return ( - ) + ); })}
- ) - } + ); + }; }, -}) +}); diff --git a/devui/splitter/src/util/d-resize-directive.ts b/devui/splitter/src/util/d-resize-directive.ts index 80747166..69dfda5a 100644 --- a/devui/splitter/src/util/d-resize-directive.ts +++ b/devui/splitter/src/util/d-resize-directive.ts @@ -1,34 +1,34 @@ import type { Directive, DirectiveBinding } from 'vue' +export interface OnResizeEvent { + (coordinateInfo: CoordinateInfo): void +} +export interface ResizeDirectiveProp { + enableResize: true // 是否允许拖动 + onPressEvent: OnResizeEvent + onDragEvent: OnResizeEvent + onReleaseEvent: OnResizeEvent +} -export class ResizeDirectiveProp { - enableResize = true // 是否允许拖动 - onPressEvent = function (...args: any[]): void { - /** */ - } - onDragEvent = function (...args: any[]): void { - /** */ - } - onReleaseEvent = function (...args: any[]): void { - /** */ - } +export interface CoordinateInfo { + pageX: number + pageY: number + clientX: number + clientY: number + offsetX: number + offsetY: number + type: string + originalEvent: MouseEvent } -let resizeDirectiveProp: ResizeDirectiveProp const resize: Directive = { - mounted( - el, - { value = new ResizeDirectiveProp() }: DirectiveBinding - ) { - resizeDirectiveProp = value + mounted(el, { value }: DirectiveBinding) { + el.$value = value // 是否允许拖动 if (value.enableResize) { bindEvent(el) } }, - unmounted( - el, - { value = new ResizeDirectiveProp() }: DirectiveBinding - ) { + unmounted(el, { value }: DirectiveBinding) { if (value.enableResize) { unbind(el, 'mousedown', onMousedown) } @@ -50,19 +50,22 @@ function unbind(el, event, callback) { } function onMousedown(e) { + const $value = e?.target?.$value + if (!$value) return // 提前退出,避免 splitter-bar 子元素响应导致错误 + bind(document, 'mousemove', onMousemove) bind(document, 'mouseup', onMouseup) - resizeDirectiveProp.onPressEvent(normalizeEvent(e)) -} + $value.onPressEvent(normalizeEvent(e)) -function onMousemove(e) { - resizeDirectiveProp.onDragEvent(normalizeEvent(e)) -} + function onMousemove(e) { + $value.onDragEvent(normalizeEvent(e)) + } -function onMouseup(e) { - unbind(document, 'mousemove', onMousemove) - unbind(document, 'mouseup', onMouseup) - resizeDirectiveProp.onReleaseEvent(normalizeEvent(e)) + function onMouseup(e) { + unbind(document, 'mousemove', onMousemove) + unbind(document, 'mouseup', onMouseup) + $value.onReleaseEvent(normalizeEvent(e)) + } } // 返回常用位置信息 diff --git a/sites/components/splitter/index.md b/docs/components/splitter/index.md similarity index 53% rename from sites/components/splitter/index.md rename to docs/components/splitter/index.md index 95799598..de7e6c33 100644 --- a/sites/components/splitter/index.md +++ b/docs/components/splitter/index.md @@ -6,7 +6,7 @@ 需要动态调整不同页面布局区域大小的时候选择使用。 -## 基本用法 +### 基本用法 :::demo @@ -15,7 +15,7 @@