diff --git a/devui/shared/util/class.ts b/devui/shared/util/class.ts new file mode 100644 index 0000000000000000000000000000000000000000..046f6ce669bdd1fb103f41f94fa88a47a854be9e --- /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 0000000000000000000000000000000000000000..8ce5065bc54f92085bf349b504be3c41cfa29b1c --- /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 0000000000000000000000000000000000000000..6c473534b4971b077f25fc0ed09be6d80465b275 --- /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 0000000000000000000000000000000000000000..e7ac1e231322a5c47e9e25bc859f6b169f90a2aa --- /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 0000000000000000000000000000000000000000..f78bcb91b153ddb9bd08fb2f024cb1c0e3befc86 --- /dev/null +++ b/devui/splitter/src/splitter-bar.scss @@ -0,0 +1,242 @@ +@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, + &.next { + &.hidden { + display: none; + } + } + + &.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, + &.collapsed::after { + top: 18px; + left: 1px; + } + + &::after { + transform: rotate(70deg); + } + + &.collapsed::after { + transform: rotate(-70deg); + } + } + + &.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; + right: 1px; + } + + &::after { + transform: rotate(-70deg); + } + + &.collapsed::after { + transform: rotate(70deg); + } + } + } + } + + &-vertical { + .devui-collapse { + height: 12px; + width: 30px; + + &.prev, + &.next { + &.hidden { + display: none; + } + } + + &.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 { + 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; + } + } + + &-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 0000000000000000000000000000000000000000..b48aa1964a8540510f42dcec4080ce18d7af2194 --- /dev/null +++ b/devui/splitter/src/splitter-bar.tsx @@ -0,0 +1,197 @@ +import { + defineComponent, + ref, + watch, + nextTick, + reactive, + computed, + withDirectives, + onMounted, + inject, +} from 'vue' +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' +import './splitter-bar.scss' +import { splitterBarProps, SplitterBarProps } from './splitter-bar-type' + +export default defineComponent({ + name: 'DSplitterBar', + props: splitterBarProps, + setup(props: SplitterBarProps) { + const store: SplitterStore = inject('splitterStore') + 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( + () => store.state.panes, + () => { + if (!store.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: function ({ originalEvent }): void { + originalEvent.stopPropagation() // 按下的时候,阻止事件冒泡 + if (!store.isResizable(props.index)) return + initState = store.dragState(props.index) + coordinate.originalX = originalEvent.pageX + coordinate.originalY = originalEvent.pageY + }, + onDragEvent: function ({ originalEvent }): void { + originalEvent.stopPropagation() // 移动的时候,阻止事件冒泡 + if (!store.isResizable(props.index)) return + coordinate.pageX = originalEvent.pageX + coordinate.pageY = originalEvent.pageY + let distance + if (props.orientation === 'vertical') { + distance = coordinate.pageY - coordinate.originalY + } else { + distance = coordinate.pageX - coordinate.originalX + } + store.setSize(initState, distance) + }, + onReleaseEvent: function ({ originalEvent }): void { + originalEvent.stopPropagation() // 释放的时候,阻止事件冒泡 + if (!store.isResizable(props.index)) return + coordinate.pageX = originalEvent.pageX + coordinate.pageY = originalEvent.pageY + let distance + if (props.orientation === 'vertical') { + distance = coordinate.pageY - coordinate.originalY + } else { + distance = coordinate.pageX - coordinate.originalX + } + store.setSize(initState, distance) + }, + } + + const queryPanes = (index, nearIndex) => { + const pane = store.getPane(index) + const nearPane = store.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) + // TODO 提示文字 + + // 第一个面板或者其它面板折叠方向不是向后的, 显示操作按钮 + const showIcon = + pane?.component?.props?.collapseDirection !== 'after' || + props.index === 0 + 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) + 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?: boolean) => { + store.tooglePane(props.index, props.index + 1, lockStatus) + toggleResize() + } + + const handleCollapseNextPane = (lockStatus?: boolean) => { + store.tooglePane(props.index + 1, props.index, lockStatus) + toggleResize() + } + + const initialCollapseStatus = () => { + handleCollapsePrePane(true) + handleCollapseNextPane(true) + } + + onMounted(() => { + initialCollapseStatus() + }) + + return () => { + return withDirectives( +
+ {props.showCollapseButton ? ( +
{ + 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 new file mode 100644 index 0000000000000000000000000000000000000000..b1773e9edcfb759963d673e0d858bce188e05fd9 --- /dev/null +++ b/devui/splitter/src/splitter-pane-type.tsx @@ -0,0 +1,68 @@ +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, + }, +} 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 0000000000000000000000000000000000000000..c8cf77007b063cb9015713205905661aaf40bfc3 --- /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 0000000000000000000000000000000000000000..c588a5188d8f1bdf22ca39a46cc235de91c3c01b --- /dev/null +++ b/devui/splitter/src/splitter-pane.tsx @@ -0,0 +1,134 @@ +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 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 }) { + const store: SplitterStore = inject('splitterStore'); + const domRef = ref(); + const order = ref(); + watch( + () => order.value, + (order) => { + nextTick(() => { + 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'; + if (curSize) { + // 设置 flex-grow 和 flex-shrink + addClass(ele, paneFixedClass); + } else { + removeClass(ele, paneFixedClass); + } + }; + + watch( + () => props.size, + (newSize) => { + nextTick(() => { + setSizeStyle(newSize); + }); + }, + { immediate: true } + ); + + const orientation = inject('orientation'); + let initialSize = ''; // 记录初始化挂载传入的大小 + onMounted(() => { + initialSize = props.size; + store.setPanes({ panes: store.state.panes }); + }); + + onUpdated(() => { + store.setPanes({ panes: store.state.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(); + }); + }, + { immediate: true } + ); + + // 收起时用于改变相邻 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({ + order, + 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 0000000000000000000000000000000000000000..50787194b59fa9c1b8f3ce6d7906d893a1b40300 --- /dev/null +++ b/devui/splitter/src/splitter-store.ts @@ -0,0 +1,195 @@ +import SplitterPane from './splitter-pane' +import { reactive } 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 对象的一些关键信息 + paneCount: number + splitterContainerSize: number +} + +export class SplitterStore { + state: splitterState + constructor() { + this.state = reactive({ + panes: [], + splitterContainerSize: 0, + paneCount: 0, + }) + } + // 配置 pane 信息,panes 列表,方便后续计算使用 + 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 + } + setSplitter({ containerSize }: { containerSize: number; }): void { + this.state.splitterContainerSize = containerSize + } + + // 获取 pane,防止没有初始化的时候调用内部方法取值 + getPane(index: number): SplitterPane { + if (!this.state.panes || index < 0 || index >= this.state.panes.length) { + throw new Error('no pane can return.') + } + return this.state.panes[index] + } + + // 按下的时候计算 pane 的 size 信息 + dragState(splitbarIndex: number): DragState { + const prev = this.getPane(splitbarIndex) + const next = this.getPane(splitbarIndex + 1) + const total = prev.getPaneSize() + next.getPaneSize() + return { + prev: { + index: splitbarIndex, + initialSize: prev.getPaneSize(), + // 设置有最小值,直接取值,如果没有设置就用两个 pane 总和减去相邻 pane 的最大值,都没设置(NaN)再取0 + minSize: + this.toPixels(prev.component.props.minSize) || + total - this.toPixels(next.component.props.maxSize) || + 0, + // 设置最大值,直接取值,如果没有设置就用两个 pane 总和减去相邻 pane 的最小值,都没设置(NaN)再取两个 pane 总和 + maxSize: + this.toPixels(prev.component.props.maxSize) || + total - this.toPixels(next.component.props.minSize) || + total, + }, + next: { + index: splitbarIndex + 1, + initialSize: next.getPaneSize(), + minSize: + this.toPixels(next.component.props.minSize) || + total - this.toPixels(prev.component.props.maxSize) || + 0, + maxSize: + this.toPixels(next.component.props.maxSize) || + total - this.toPixels(prev.component.props.minSize) || + total, + }, + } + } + + // 大小限制函数,(max)小于最小值时取最小值,(min)大于最大值时取最大值 + clamp(minSize: number, maxSize: number, initialSize: number): number { + return Math.min(maxSize, Math.max(minSize, initialSize)) + } + + // resize pane的大小 + 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 (this.isPercent(pane.component.props.size)) { + size = (newSize / splitterSize) * 100 + '%' + } else { + size = newSize + 'px' + } + pane.component.props.size = size + pane.component.emit('sizeChange', size) + } + + // 判断 pane 是否可以调整大小,只要有一边设置了不可调整或者收起,相邻 pane 调整就失效 + 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 + return ( + prevPane?.component?.props?.resizable && + nextPane?.component?.props?.resizable && + !paneCollapsed + ) + } + + // 判断分割条是否是固定的,只要有一边不能调整, 就是禁用状态固定 bar + isStaticBar(splitBarIndex: number): boolean { + const prevPane = this.getPane(splitBarIndex) + const nextPane = this.getPane(splitBarIndex + 1) + return !( + prevPane?.component?.props?.resizable && + nextPane?.component?.props?.resizable + ) + } + + // 判断是不是百分比设置宽度 + isPercent(size: string) { + return /%$/.test(size) + } + + // 计算时把百分比转换为像素 + toPixels(size: string): number { + // 值不满足转换时,result 为 NaN,方便计算最小、最大宽度判断 + let result = parseFloat(size) + if (this.isPercent(size)) { + result = (this.state.splitterContainerSize * result) / 100 + } + return result + } + + // 切换 pane 展开,收起 + tooglePane( + paneIndex: number, + nearPaneIndex: number, + lockStatus?: boolean + ): 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 + : !pane?.component?.props?.collapsed + nearPane?.component?.exposed?.toggleNearPaneFlexGrow( + pane?.component?.props?.collapsed + ) + pane?.component?.emit( + 'collapsedChange', + pane?.component?.props?.collapsed + ) + } + } + + // 设置 pane 大小 + 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 + this.resize(state.prev, distance) + this.resize(state.next, -distance) + } else if (next.component.props.size) { + // 只有 next pane指定了 size,直接修改 next pane + this.resize(state.next, -distance) + } else { + // 最后都没有指定 size,直接修改 pre pane + this.resize(state.prev, distance) + } + } +} diff --git a/devui/splitter/src/splitter-types.ts b/devui/splitter/src/splitter-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..73467eb868270c6849f052e010a10ded034c235b --- /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 0000000000000000000000000000000000000000..a2f5eb8136132ec4a94d5ab93982429f5e070f68 --- /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 0000000000000000000000000000000000000000..e9afe95fc3cd7c1b306a89a67e2f3e2bc4f0f910 --- /dev/null +++ b/devui/splitter/src/splitter.tsx @@ -0,0 +1,64 @@ +import { defineComponent, reactive, ref, provide, nextTick } 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', + components: { + DSplitterBar, + }, + props: splitterProps, + emits: [], + setup(props: SplitterProps, ctx) { + const store: SplitterStore = new SplitterStore() + const state = reactive({ + panes: [], // 内嵌面板 + }) + + state.panes = ctx.slots.DSplitterPane?.() || [] + + store.setPanes({ panes: state.panes }) + + const domRef = ref() + + provide('orientation', props.orientation) + provide('splitterStore', store) + + nextTick(() => { + let containerSize = 0 + if (props.orientation === 'vertical') { + containerSize = domRef.value.clientHeight + } else { + containerSize = domRef.value.clientWidth + } + store.setSplitter({ containerSize }) + }) + + return () => { + const { splitBarSize, orientation, showCollapseButton } = props + const wrapperClass = ['devui-splitter', `devui-splitter-${orientation}`] + + return ( +
+ {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 new file mode 100644 index 0000000000000000000000000000000000000000..69dfda5acad08be35cc44ca6dc29afc0fc473399 --- /dev/null +++ b/devui/splitter/src/util/d-resize-directive.ts @@ -0,0 +1,85 @@ +import type { Directive, DirectiveBinding } from 'vue' +export interface OnResizeEvent { + (coordinateInfo: CoordinateInfo): void +} +export interface ResizeDirectiveProp { + enableResize: true // 是否允许拖动 + onPressEvent: OnResizeEvent + onDragEvent: OnResizeEvent + onReleaseEvent: OnResizeEvent +} + +export interface CoordinateInfo { + pageX: number + pageY: number + clientX: number + clientY: number + offsetX: number + offsetY: number + type: string + originalEvent: MouseEvent +} + +const resize: Directive = { + mounted(el, { value }: DirectiveBinding) { + el.$value = value + // 是否允许拖动 + if (value.enableResize) { + bindEvent(el) + } + }, + unmounted(el, { value }: 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) { + const $value = e?.target?.$value + if (!$value) return // 提前退出,避免 splitter-bar 子元素响应导致错误 + + bind(document, 'mousemove', onMousemove) + bind(document, 'mouseup', onMouseup) + $value.onPressEvent(normalizeEvent(e)) + + function onMousemove(e) { + $value.onDragEvent(normalizeEvent(e)) + } + + function onMouseup(e) { + unbind(document, 'mousemove', onMousemove) + unbind(document, 'mouseup', onMouseup) + $value.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 aca6bee619416b36d9e9e6fda5be2b21341e973a..9fe21028c8a0fe3f9cad054eda6968c1e3fae332 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/components/splitter/index.md b/docs/components/splitter/index.md new file mode 100644 index 0000000000000000000000000000000000000000..802fdf98e1dfde8fa8d922d3f3afccb2985b5090 --- /dev/null +++ b/docs/components/splitter/index.md @@ -0,0 +1,308 @@ +# Splitter 分割器 + +页面分割器。 + +**何时使用** + +需要动态调整不同页面布局区域大小的时候选择使用。 + +### 基本用法 + +:::demo + +```vue + + + + + +``` +::: + + + + +### 垂直布局用法 + +:::demo + +```vue + + + + + +``` + +::: + +### 组合布局用法 + +:::demo + +```vue + + + + + +``` + +::: + +### 指定折叠收起方向 + +:::demo + +```vue + + + + + +``` + +::: + +### 折叠收缩显示菜单【TODO】 \ No newline at end of file