|
@@ -0,0 +1,246 @@
|
|
|
+<template>
|
|
|
+ <section class="resizable-layout" :style="styleObj" ref="layoutRef">
|
|
|
+ <slot></slot>
|
|
|
+ </section>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts">
|
|
|
+import { defineComponent, provide, PropType } from 'vue';
|
|
|
+import mitt, { Emitter } from 'mitt';
|
|
|
+import ResizableLayoutItem from './ResizableLayoutItem.vue';
|
|
|
+import { EmitterType } from '@/constants/emitter';
|
|
|
+import ProvideInject from '@/constants/provideInject';
|
|
|
+
|
|
|
+interface DOMElement extends HTMLElement {
|
|
|
+ [key: string]: any;
|
|
|
+}
|
|
|
+
|
|
|
+type Direction = 'horizontal' | 'vertical';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 由于 Composition Api 的限制,通过slot动态插入的子组件难以直接访问父组件定义在 setup 中的方法(父组件也难以直接访问子组件定义在 setup 中的方法),
|
|
|
+ * 故此组件(ResizableLayout)以及子组件(ResizableLayoutItem)使用传统的 Options API 开发。
|
|
|
+ * 事实上,通过控制台打印,发现通过setup中的getCurrentInstance()获取到的实例中有一个 parent 属性和 ctx 属性,分别可以直接获取父组件实例和setup中定义的方法,
|
|
|
+ * 使用时类似于这种方式 (getCurrentInstance().parent as any).ctx 和 (getCurrentInstance() as any).ctx。
|
|
|
+ * 本组件第一版就是通过这种方式实现父子组件的通信,但是 Vue 官方文档中没有找到任何相关信息,且官方文档中说明了:文档中没说明的功能,代表它不存在。
|
|
|
+ * 更重要的一点是:项目在 build 上线之后(即NODE_ENV=production), (getCurrentInstance() as any).ctx这种方式就失效了,需要通过(getCurrentInstance() as any).ctx._.setupState才能获取,
|
|
|
+ * 为了避免之后 Vue 版本更新可能改变内部 api 导致组件失效,因此决定使用Options API
|
|
|
+ */
|
|
|
+
|
|
|
+export default defineComponent({
|
|
|
+ name: 'resizable-layout',
|
|
|
+ props: {
|
|
|
+ // 布局的方向:横向或纵向
|
|
|
+ direction: {
|
|
|
+ type: String as PropType<Direction>,
|
|
|
+ default: 'horizontal',
|
|
|
+ validator: (direction: Direction) => {
|
|
|
+ return ['horizontal', 'vertical'].includes(direction);
|
|
|
+ },
|
|
|
+ },
|
|
|
+ // 防抖函数中需要延迟的毫秒数
|
|
|
+ debounce: {
|
|
|
+ type: Number,
|
|
|
+ default: 100,
|
|
|
+ },
|
|
|
+ // 防抖函数中允许被延迟的最大值
|
|
|
+ maxWait: {
|
|
|
+ type: Number,
|
|
|
+ default: Number.MAX_SAFE_INTEGER,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ layoutItemElements: [] as Array<DOMElement>, // 子组件 **(resizable-layout-item)** 的 DOM 实例数组
|
|
|
+ layoutItemInstances: [] as any[], // 子组件的Vue实例数组
|
|
|
+ layoutItemsMinSize: [] as number[], // 子组件的最小尺寸数组
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ // 将 props 中的 direction 转为 css3 中 flex-direction 属性对应的值
|
|
|
+ flexDirection(): string {
|
|
|
+ return this.direction === 'vertical' ? 'column' : 'row';
|
|
|
+ },
|
|
|
+ styleObj(): { flexDirection: string } {
|
|
|
+ return {
|
|
|
+ flexDirection: this.flexDirection,
|
|
|
+ };
|
|
|
+ },
|
|
|
+ },
|
|
|
+ setup(props, context) {
|
|
|
+ const { slots } = context;
|
|
|
+ if (slots.default) {
|
|
|
+ const children = slots.default();
|
|
|
+ if (
|
|
|
+ children.some(
|
|
|
+ child => child.type !== ResizableLayoutItem && !child.type.toString().startsWith('Symbol(') // Symbol(Comment) 代表注释
|
|
|
+ )
|
|
|
+ ) {
|
|
|
+ console.error('<resizable-layout> 中只能直接包含 <resizable-layout-item> 子组件', children);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() {
|
|
|
+ this.init();
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.setHtmlDataAttribute();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ // 初始化
|
|
|
+ init() {
|
|
|
+ const self = this as any;
|
|
|
+ provide(ProvideInject.DIRECTION, self.flexDirection);
|
|
|
+ provide(ProvideInject.DEBOUNCE, self.debounce);
|
|
|
+ provide(ProvideInject.MAX_WAIT, self.maxWait);
|
|
|
+ // provide(ProvideInject.BAR_WIDTH, this.barWidth);
|
|
|
+ // provide(ProvideInject.DOTTED, this.dotted);
|
|
|
+
|
|
|
+ // 事件监听器
|
|
|
+ self.emitter = mitt() as Emitter;
|
|
|
+
|
|
|
+ self.currentMoveId = null; // symbol 类型,当前move事件的唯一标志
|
|
|
+
|
|
|
+ // 拖拽条移动事件监听
|
|
|
+ if (self.flexDirection === 'column') {
|
|
|
+ self.emitter.on(EmitterType.DRAG_BAR_MOVE, self.verticalHandler);
|
|
|
+ } else {
|
|
|
+ self.emitter.on(EmitterType.DRAG_BAR_MOVE, self.horizontalHandler);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 给子组件设置 html 的 data- 属性
|
|
|
+ // data-index 子组件(resizable-layout-item)的索引,即位于父组件中第几个(从 0 开始)
|
|
|
+ // data-size 子组件(resizable-layout-item)在鼠标刚刚按下的时候的尺寸
|
|
|
+ setHtmlDataAttribute() {
|
|
|
+ const parentElement = this.$refs.layoutRef as HTMLElement;
|
|
|
+ const childElements = parentElement.children;
|
|
|
+ for (let i = 0; i < childElements.length; i += 1) {
|
|
|
+ const element = childElements[i] as HTMLElement;
|
|
|
+ element.setAttribute('data-index', `${i}`);
|
|
|
+ element.setAttribute('data-size', `${element.offsetWidth}`);
|
|
|
+ this.layoutItemElements[i] = element;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 约定大写字母开头表示为供外界调用的方法
|
|
|
+ // 当布局改变时(如子组件通过 v-if 动态添加或删除)刷新布局
|
|
|
+ Refresh() {
|
|
|
+ setTimeout(() => {
|
|
|
+ this.layoutItemElements.length = 0;
|
|
|
+ // 重新设置 html 的 data- 属性
|
|
|
+ this.setHtmlDataAttribute();
|
|
|
+
|
|
|
+ this.layoutItemInstances.map(instance => {
|
|
|
+ // 在 ResizableLayoutItem 中的 unmounted 函数中添加了 $unmounted 字段,
|
|
|
+ // 标记该对象已经 unmounted
|
|
|
+ if (!instance.$unmounted) {
|
|
|
+ instance.RequestEmitter();
|
|
|
+ }
|
|
|
+ return instance;
|
|
|
+ });
|
|
|
+ // 清空子组件的Vue实例数组
|
|
|
+ this.layoutItemInstances.length = 0;
|
|
|
+ // 清空子组件的最小尺寸数组
|
|
|
+ this.layoutItemsMinSize.length = 0;
|
|
|
+ }, 100);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 约定大写字母开头表示为供外界调用的方法
|
|
|
+ // 供子组件调用,给子组件返回 emitter
|
|
|
+ GiveEmitter({ instance, index, minSize }: Comp.ResizableLayout.IDataParam) {
|
|
|
+ instance.RegisterEmitter((this as any).emitter);
|
|
|
+ this.layoutItemInstances[index] = instance;
|
|
|
+ this.layoutItemsMinSize[index] = minSize;
|
|
|
+ },
|
|
|
+
|
|
|
+ // 返回 data-size
|
|
|
+ getSize(element: HTMLElement) {
|
|
|
+ return parseInt(element.getAttribute('data-size') || '0', 10);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 重新分配子组件的 flex-grow
|
|
|
+ assignChildrenFlexGrow(index: number, distance: number) {
|
|
|
+ // 此时仍然是当前的move事件,即鼠标未松开
|
|
|
+
|
|
|
+ // 需要调整大小的子组件是否都满足 min-width(min-height) 的要求,初始值设为 true,假设都满足
|
|
|
+ let allFitMinWidth = true;
|
|
|
+ // 需要调整大小的子组件
|
|
|
+ for (let i = index, sign = 1; i <= index + 1; i += 1, sign = -sign) {
|
|
|
+ const flexGrow = this.getSize(this.layoutItemElements[i]) + sign * distance;
|
|
|
+ if (flexGrow < this.layoutItemsMinSize[i]) {
|
|
|
+ allFitMinWidth = false;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // allFitMinWidth为false,则终止重新分配
|
|
|
+ if (!allFitMinWidth) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 需要调整大小的子组件的前面的子组件
|
|
|
+ // 他们保持原来的尺寸就好
|
|
|
+ for (let i = 0; i < index; i += 1) {
|
|
|
+ this.layoutItemInstances[i].AdjustFlexGrow(this.getSize(this.layoutItemElements[i]));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 需要调整大小的子组件
|
|
|
+ for (let i = index, sign = 1; i <= index + 1; i += 1, sign = -sign) {
|
|
|
+ const flexGrow = this.getSize(this.layoutItemElements[i]) + sign * distance;
|
|
|
+ // 通知子组件调整自身大小
|
|
|
+ this.layoutItemInstances[i].AdjustFlexGrow(flexGrow);
|
|
|
+ // 通知子组件触发 resize 事件
|
|
|
+ this.layoutItemInstances[i].EmitResize();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 需要调整大小的子组件的后面的子组件
|
|
|
+ // 他们保持原来的尺寸就好
|
|
|
+ for (let i = index + 2; i < this.layoutItemInstances.length; i += 1) {
|
|
|
+ this.layoutItemInstances[i].AdjustFlexGrow(this.getSize(this.layoutItemElements[i]));
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 拖拽事件监听处理器
|
|
|
+ moveHandler(sizeProperty: string, moveParam: Comp.ResizableLayout.IMoveParam) {
|
|
|
+ const { distance, index, moveId } = moveParam;
|
|
|
+ if ((this as any).currentMoveId !== moveId) {
|
|
|
+ // 一个新的move事件,即鼠标松开了之后重新按下之后的move事件
|
|
|
+ (this as any).currentMoveId = moveId;
|
|
|
+ // 设置子组件的 data-size,用于记录在 "重新分配子组件的 flex-grow" 之前,各子组件的宽度
|
|
|
+ this.layoutItemElements.map(element => {
|
|
|
+ element.setAttribute('data-size', `${element[sizeProperty]}`);
|
|
|
+ return element;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.assignChildrenFlexGrow(index, distance);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 水平布局拖拽监听
|
|
|
+ horizontalHandler(moveParam: Comp.ResizableLayout.IMoveParam) {
|
|
|
+ this.moveHandler('offsetWidth', moveParam);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 垂直布局拖拽监听
|
|
|
+ verticalHandler(moveParam: Comp.ResizableLayout.IMoveParam) {
|
|
|
+ this.moveHandler('offsetHeight', moveParam);
|
|
|
+ },
|
|
|
+ },
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.resizable-layout {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+
|
|
|
+ ::v-deep > .resizable-layout-item {
|
|
|
+ &:last-child {
|
|
|
+ > .drag-bar {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|