diff --git a/CHANGELOG.md b/CHANGELOG.md index a01e2637fee8208337f94b8883bf6f073496d403..180346edd5886b663558d2a894f262b2018fefbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - 数据选择、地址栏编辑器参数增强相关 - 支持实体多项数据选择视图(左右关系) +- 支持分割面板容器 ### Changed diff --git a/src/common/index.ts b/src/common/index.ts index ea67b2fa301718aefb3c5f7860979474c1448f9a..0dd78d96823e9336a7f7ce79eb227f89dcb88c5c 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -10,6 +10,8 @@ import { IBizViewShell } from './view-shell/view-shell'; import { IBizRawItem } from './rawitem/rawitem'; import { IBizCodeList } from './code-list/code-list'; import { IBizNoData } from './no-data/no-data'; +import { IBizSplit } from './split/split'; +import { IBizSplitTrigger } from './split-trigger/split-trigger'; export * from './icon/icon'; export * from './router-view/router-view'; @@ -23,6 +25,8 @@ export * from './rawitem/rawitem'; export * from './code-list/code-list'; export * from './code-list/code-list'; export * from './control-loading-placeholder/control-loading-placeholder'; +export * from './split/split'; +export * from './split-trigger/split-trigger'; export const IBizCommonComponents = { install: (v: App): void => { @@ -37,6 +41,8 @@ export const IBizCommonComponents = { v.component(IBizRawItem.name, IBizRawItem); v.component(IBizCodeList.name, IBizCodeList); v.component(IBizNoData.name, IBizNoData); + v.component(IBizSplit.name, IBizSplit); + v.component(IBizSplitTrigger.name, IBizSplitTrigger); }, }; diff --git a/src/common/split-trigger/split-trigger.scss b/src/common/split-trigger/split-trigger.scss new file mode 100644 index 0000000000000000000000000000000000000000..76e6781ab887103e2a307c4f68aceb106f6d4f1e --- /dev/null +++ b/src/common/split-trigger/split-trigger.scss @@ -0,0 +1,62 @@ +$trigger-bar-background: getCssVar(color, border); +$trigger-width: 6px; +$trigger-bar-width: 4px; +$trigger-bar-offset: ($trigger-width - $trigger-bar-width) / 2; +$trigger-bar-interval: 3px; +$trigger-bar-weight: 1px; +$trigger-bar-con-height: ($trigger-bar-weight + $trigger-bar-interval) * 8; + +@include b(split-trigger) { + border: 1px solid #{getCssVar(color, border)}; + + @include m(vertical) { + width: $trigger-width; + height: 100%; + cursor: col-resize; + border-top: none; + border-bottom: none; + + @include b(split-trigger-bar) { + float: left; + width: $trigger-bar-width; + height: 1px; + margin-top: $trigger-bar-interval; + background: $trigger-bar-background; + } + } + + @include m(horizontal) { + width: 100%; + height: $trigger-width; + cursor: row-resize; + border-right: none; + border-left: none; + + @include b(split-trigger-bar) { + float: left; + width: 1px; + height: $trigger-bar-width; + margin-right: $trigger-bar-interval; + background: $trigger-bar-background; + } + } +} + +@include b(split-trigger-bar-con) { + position: absolute; + overflow: hidden; + + @include m(vertical) { + top: 50%; + left: $trigger-bar-offset; + height: $trigger-bar-con-height; + transform: translate(0, -50%); + } + + @include m(horizontal) { + top: $trigger-bar-offset; + left: 50%; + width: $trigger-bar-con-height; + transform: translate(-50%, 0); + } +} \ No newline at end of file diff --git a/src/common/split-trigger/split-trigger.tsx b/src/common/split-trigger/split-trigger.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f51282a6ae5d30dd6177334b36ee8d9e2deddf1 --- /dev/null +++ b/src/common/split-trigger/split-trigger.tsx @@ -0,0 +1,42 @@ +import { useNamespace } from '@ibiz-template/vue3-util'; +import { computed, defineComponent } from 'vue'; +import './split-trigger.scss'; + +export const IBizSplitTrigger = defineComponent({ + name: 'IBizSplitTrigger', + props: { + mode: String, + }, + setup(prop) { + const ns = useNamespace('split-trigger'); + const isVertical = computed(() => prop.mode === 'vertical'); + const classes = computed(() => [ + ns.b(), + isVertical.value ? ns.m('vertical') : ns.m('horizontal'), + ]); + const barConClasses = computed(() => [ + ns.b('bar-con'), + isVertical.value + ? ns.bm('bar-con', 'vertical') + : ns.bm('bar-con', 'horizontal'), + ]); + const items = Array(8).fill(0); + return { + ns, + classes, + barConClasses, + items, + }; + }, + render() { + return ( +
+
+ {this.items.map((_item, i) => ( + + ))} +
+
+ ); + }, +}); diff --git a/src/common/split/split.scss b/src/common/split/split.scss new file mode 100644 index 0000000000000000000000000000000000000000..335cd7c8d1a5ee3ecd00ee6ec36f4151c745f837 --- /dev/null +++ b/src/common/split/split.scss @@ -0,0 +1,66 @@ +@include b(split-wrapper) { + position: relative; + width: 100%; + height: 100%; + + @include when(no-select) { + -webkit-touch-callout: none; + user-select: none; + } +} + +@include b(split) { + @include m(horizontal) { + @include b(split-trigger-con) { + top: 50%; + width: 0; + height: 100%; + } + } + + @include m(vertical) { + @include b(split-trigger-con) { + left: 50%; + width: 100%; + height: 0; + } + } +} + +@include b(split-pane) { + position: absolute; + + @include m(left) { + top: 0; + bottom: 0; + left: 0; + } + + @include m(right) { + top: 0; + right: 0; + bottom: 0; + } + + @include m(top) { + top: 0; + right: 0; + left: 0; + } + + @include m(bottom) { + right: 0; + bottom: 0; + left: 0; + } + + @include m(moving) { + user-select: none; + } +} + +@include b(split-trigger-con) { + position: absolute; + z-index: 10; + transform: translate(-50%, -50%); +} \ No newline at end of file diff --git a/src/common/split/split.tsx b/src/common/split/split.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4028a1e3c0c896f6b3926907f53d43606481d95 --- /dev/null +++ b/src/common/split/split.tsx @@ -0,0 +1,252 @@ +import { useNamespace } from '@ibiz-template/vue3-util'; +import { + Ref, + computed, + defineComponent, + nextTick, + onBeforeUnmount, + onMounted, + ref, + watch, +} from 'vue'; +import './split.scss'; + +export const IBizSplit = defineComponent({ + name: 'IBizSplit', + props: { + modelValue: { + type: [Number, String], + default: 0.5, + }, + mode: { + validator: (value: string) => { + return ['horizontal', 'vertical'].includes(value); + }, + default: 'horizontal', + }, + min: { + type: [Number, String], + default: '30px', + }, + max: { + type: [Number, String], + default: '30px', + }, + }, + emits: ['update:modelValue', 'on-move-start', 'on-moving', 'on-move-end'], + setup(props, { emit }) { + const ns = useNamespace('split'); + + const outerWrapper: Ref = ref(null); + + const offset = ref(0); + const oldOffset: Ref = ref(0); + const isMoving = ref(false); + const computedMin: Ref = ref(0); + const computedMax: Ref = ref(0); + const currentValue: Ref = ref(0.5); + const initOffset = ref(0); + + const wrapperClasses = computed(() => [ + ns.b('wrapper'), + ns.is('no-select', isMoving.value), + ]); + const paneClasses = computed(() => [ + ns.b('pane'), + isMoving.value ? ns.bm('pane', 'moving') : '', + ]); + const isHorizontal = computed(() => props.mode === 'horizontal'); + const anotherOffset = computed(() => 100 - offset.value); + const valueIsPx = computed(() => typeof props.modelValue === 'string'); + const offsetSize = computed(() => + isHorizontal.value ? 'offsetWidth' : 'offsetHeight', + ); + + const px2percent = (numerator: string, denominator: string) => { + return parseFloat(numerator) / parseFloat(denominator); + }; + + const getComputedThresholdValue = (type: 'min' | 'max') => { + const size = outerWrapper.value![offsetSize.value]; + if (valueIsPx.value) { + return typeof props[type] === 'string' + ? props[type] + : size * (props[type] as number); + } + return typeof props[type] === 'string' + ? px2percent(props[type] as string, size as unknown as string) + : props[type]; + }; + + const getMax = (value1: string | number, value2: string | number) => { + if (valueIsPx.value) + return `${Math.max( + parseFloat(value1 as string), + parseFloat(value2 as string), + )}px`; + return Math.max(value1 as number, value2 as number); + }; + + const getAnotherOffset = (value: string | number) => { + let res: string | number = 0; + if (valueIsPx.value) + res = `${ + outerWrapper.value![offsetSize.value] - parseFloat(value as string) + }px`; + else res = 1 - (value as number); + return res; + }; + + const handleMove = (e: MouseEvent) => { + const pageOffset = isHorizontal.value ? e.pageX : e.pageY; + const moveOffset = pageOffset - initOffset.value; + const outerWidth = outerWrapper.value![offsetSize.value]; + let value = valueIsPx.value + ? `${parseFloat(oldOffset.value as string) + moveOffset}px` + : px2percent( + (outerWidth * (oldOffset.value as number) + + moveOffset) as unknown as string, + outerWidth as unknown as string, + ); + const anotherValue = getAnotherOffset(value); + if ( + parseFloat(value as string) <= parseFloat(computedMin.value as string) + ) { + value = getMax(value, computedMin.value); + } + if ( + parseFloat(anotherValue as string) <= + parseFloat(computedMax.value as string) + ) { + value = getAnotherOffset(getMax(anotherValue, computedMax.value)); + } + Object.assign(e, { + atMin: props.modelValue === computedMin.value, + atMax: valueIsPx.value + ? getAnotherOffset(props.modelValue) === computedMax.value + : (getAnotherOffset(props.modelValue) as number).toFixed(5) === + (computedMax.value as number).toFixed(5), + }); + emit('update:modelValue', value); + emit('on-moving', e); + }; + + const handleUp = () => { + isMoving.value = false; + document.removeEventListener('mousemove', handleMove); + document.removeEventListener('mouseup', handleUp); + emit('on-move-end'); + }; + + const handleMousedown = (e: MouseEvent) => { + initOffset.value = isHorizontal.value ? e.pageX : e.pageY; + oldOffset.value = props.modelValue; + isMoving.value = true; + document.addEventListener('mousemove', handleMove); + document.addEventListener('mouseup', handleUp); + emit('on-move-start'); + }; + + const computeOffset = () => { + nextTick(() => { + computedMin.value = getComputedThresholdValue('min'); + computedMax.value = getComputedThresholdValue('max'); + offset.value = + (((valueIsPx.value + ? px2percent( + props.modelValue as string, + outerWrapper.value![offsetSize.value] as unknown as string, + ) + : props.modelValue) as number) * + 10000) / + 100; + }); + }; + + watch( + () => props.modelValue, + (val: string | number) => { + if (val !== currentValue.value) { + currentValue.value = val; + computeOffset(); + } + }, + ); + + onMounted(() => { + nextTick(() => { + computeOffset(); + }); + + window.addEventListener('resize', computeOffset); + }); + + onBeforeUnmount(() => { + window.removeEventListener('resize', computeOffset); + }); + + return { + ns, + outerWrapper, + offset, + wrapperClasses, + paneClasses, + isHorizontal, + anotherOffset, + handleMousedown, + }; + }, + render() { + return ( +
+ {this.isHorizontal ? ( +
+
+ {this.$slots.left?.()} +
+
this.handleMousedown(e)} + > + {this.$slots.trigger?.() || } +
+
+ {this.$slots.right?.()} +
+
+ ) : ( +
+
+ {this.$slots.top?.()} +
+
this.handleMousedown(e)} + > + {this.$slots.trigger?.() || ( + + )} +
+
+ {this.$slots.bottom?.()} +
+
+ )} +
+ ); + }, +}); diff --git a/src/panel-component/index.ts b/src/panel-component/index.ts index 961585f57cc0fecd70fbcc8c65f66a3c189079d7..42f566a4d7f610a4f8cd61dfd51fb11cccca4aa6 100644 --- a/src/panel-component/index.ts +++ b/src/panel-component/index.ts @@ -17,6 +17,7 @@ import IBizGridContainer from './grid-container'; import IBizPanelViewContent from './panel-view-content'; import IBizPanelTabPanel from './panel-tab-panel'; import IBizPanelTabPage from './panel-tab-page'; +import IBizSplitContainer from './split-container'; export * from './panel-container'; export * from './panel-ctrl-pos'; @@ -31,6 +32,7 @@ export * from './panel-field'; export * from './panel-rawitem'; export * from './panel-tab-panel'; export * from './panel-tab-page'; +export * from './split-container'; export const IBizPanelComponents = { install: (v: App): void => { @@ -52,6 +54,7 @@ export const IBizPanelComponents = { v.use(IBizGridContainer); v.use(IBizPanelTabPanel); v.use(IBizPanelTabPage); + v.use(IBizSplitContainer); }, }; diff --git a/src/panel-component/split-container/index.ts b/src/panel-component/split-container/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd6e4cd571fb599ffc654f5865ac4cde62c26dd1 --- /dev/null +++ b/src/panel-component/split-container/index.ts @@ -0,0 +1,25 @@ +import { App } from 'vue'; +import { withInstall } from '@ibiz-template/vue3-util'; +import { registerPanelItemProvider } from '@ibiz-template/runtime'; +import { SplitContainer } from './split-container'; +import { SplitContainerProvider } from './split-container.provider'; +import { SplitContainerController } from './split-container.controller'; + +export { SplitContainerProvider, SplitContainerController }; + +export const IBizSplitContainer = withInstall( + SplitContainer, + function (v: App) { + v.component(SplitContainer.name, SplitContainer); + registerPanelItemProvider( + 'CONTAINER_CONTAINER_H_SPLIT', + () => new SplitContainerProvider(), + ); + registerPanelItemProvider( + 'CONTAINER_CONTAINER_V_SPLIT', + () => new SplitContainerProvider(), + ); + }, +); + +export default IBizSplitContainer; diff --git a/src/panel-component/split-container/split-container.controller.ts b/src/panel-component/split-container/split-container.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..048fe4c1903076b57460f5df19a59074ac7ff597 --- /dev/null +++ b/src/panel-component/split-container/split-container.controller.ts @@ -0,0 +1,73 @@ +import { IPanelContainer } from '@ibiz/model-core'; +import { PanelItemController } from '../../control'; + +/** + * 分割面板容器控制器 + * + * @author zhanghengfeng + * @date 2023-08-22 17:08:37 + * @export + * @class SplitContainerController + * @extends {PanelItemController} + */ +export class SplitContainerController extends PanelItemController { + /** + * 分割面板模式 + * + * @author zhanghengfeng + * @date 2023-08-22 17:08:24 + * @type {('horizontal' | 'vertical')} + */ + splitMode: 'horizontal' | 'vertical' = 'horizontal'; + + /** + * 默认分割值 + * + * @author zhanghengfeng + * @date 2023-08-22 17:08:38 + * @type {(number | string)} + */ + splitValue: number | string = 0.5; + + /** + * 初始化默认分割值 + * + * @author zhanghengfeng + * @date 2023-08-22 17:08:13 + * @param {number} value + * @param {string} mode + */ + initSplitValue(value: number, mode: string): void { + if (mode === 'PX') { + this.splitValue = `${value}px`; + } + if (mode === 'PERCENTAGE') { + this.splitValue = value / 100; + } + } + + protected async onInit(): Promise { + await super.onInit(); + const { predefinedType, panelItems } = this.model; + this.splitMode = + predefinedType === 'CONTAINER_V_SPLIT' ? 'vertical' : 'horizontal'; + if (Array.isArray(panelItems) && panelItems.length) { + const panelItem = panelItems[0]; + const layoutPos = panelItem.layoutPos; + if (layoutPos) { + if (this.splitMode === 'horizontal') { + const { width, widthMode } = layoutPos; + if (width != null && widthMode != null) { + this.initSplitValue(width, widthMode); + } + } + if (this.splitMode === 'vertical') { + const { height, heightMode } = layoutPos; + if (height != null && heightMode != null) { + this.initSplitValue(height, heightMode); + } + } + } + } + } +} diff --git a/src/panel-component/split-container/split-container.provider.ts b/src/panel-component/split-container/split-container.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c6934cd904024f866338577ca866a2ff471feb9 --- /dev/null +++ b/src/panel-component/split-container/split-container.provider.ts @@ -0,0 +1,27 @@ +import { IPanelItemProvider } from '@ibiz-template/runtime'; +import { IPanelContainer } from '@ibiz/model-core'; +import { PanelController, PanelItemController } from '../../control'; +import { SplitContainerController } from './split-container.controller'; + +/** + * 分割面板容器适配器 + * + * @author zhanghengfeng + * @date 2023-08-22 17:08:20 + * @export + * @class SplitContainerProvider + * @implements {IPanelItemProvider} + */ +export class SplitContainerProvider implements IPanelItemProvider { + component: string = 'IBizSplitContainer'; + + async createController( + panelItem: IPanelContainer, + panel: PanelController, + parent: PanelItemController | undefined, + ): Promise { + const c = new SplitContainerController(panelItem, panel, parent); + await c.init(); + return c; + } +} diff --git a/src/panel-component/split-container/split-container.scss b/src/panel-component/split-container/split-container.scss new file mode 100644 index 0000000000000000000000000000000000000000..fe035b2ed28f5042e0e2e19bd06458d81fb192f6 --- /dev/null +++ b/src/panel-component/split-container/split-container.scss @@ -0,0 +1,9 @@ +@include b(split-container) { + width: 100%; + height: 100%; + overflow: auto; + + @include when(hidden) { + display: none; + } +} \ No newline at end of file diff --git a/src/panel-component/split-container/split-container.tsx b/src/panel-component/split-container/split-container.tsx new file mode 100644 index 0000000000000000000000000000000000000000..594409de0ca7853e80206b98ef8fd818d126f846 --- /dev/null +++ b/src/panel-component/split-container/split-container.tsx @@ -0,0 +1,56 @@ +import { useNamespace } from '@ibiz-template/vue3-util'; +import { PropType, VNode, computed, defineComponent, ref } from 'vue'; +import { IPanelContainer } from '@ibiz/model-core'; +import { SplitContainerController } from './split-container.controller'; +import './split-container.scss'; + +export const SplitContainer = defineComponent({ + name: 'IBizSplitContainer', + props: { + modelData: { + type: Object as PropType, + required: true, + }, + controller: { + type: SplitContainerController, + required: true, + }, + }, + setup(props) { + const ns = useNamespace('split-container'); + const { id } = props.modelData; + + const classArr = computed(() => { + let result: Array = [ns.b(), ns.m(id)]; + result = [ + ...result, + ...props.controller.containerClass, + ns.is('hidden', !props.controller.state.visible), + ]; + return result; + }); + + const splitValue = ref(props.controller.splitValue); + + return { + ns, + classArr, + splitValue, + }; + }, + render() { + const defaultSlots: VNode[] = this.$slots.default?.() || []; + return ( +
+ + {{ + left: () => defaultSlots[0], + right: () => defaultSlots[1], + top: () => defaultSlots[0], + bottom: () => defaultSlots[1], + }} + +
+ ); + }, +});