diff --git a/demo/app.tsx b/demo/app.tsx index df65dcd..9c77fa6 100644 --- a/demo/app.tsx +++ b/demo/app.tsx @@ -1,34 +1,69 @@ -import React, { useState } from 'react'; +import React, { useContext, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { FlexibleWorkbench, IWBConfigData, WBUtil } from '../src'; +import { WBContext, Workbench, WBUtil } from '../src'; + +const LayoutData = { + Normal: WBUtil.createSplit([ + WBUtil.createComponent('A'), + WBUtil.createSplit([WBUtil.createComponent('B'), WBUtil.createSplit([WBUtil.createComponent('C'), WBUtil.createComponent('D')])]), + ]), + One: WBUtil.createComponent('A'), + Four: WBUtil.createSplit( + [ + WBUtil.createComponent('A'), + WBUtil.createComponent('B'), + WBUtil.createSplit( + [WBUtil.createComponent('C'), WBUtil.createComponent('D')], + [{ type: 'fixed', size: 100 }, { type: 'flex' }], + 'vertical' + ), + ], + [{ type: 'fixed', size: 100 }, { type: 'flex' }, { type: 'flex' }] + ), +}; + +const _createComponent = (name: string, color: string) => { + return () => { + const renderCnt = useRef(0); + renderCnt.current++; + + const ctx = useContext(WBContext); + + return ( +
+

{name}

+

Render Count: {renderCnt.current}

+

+ {ctx.width} x {ctx.height} +

+
+ ); + }; +}; + +const components = { + A: _createComponent('A', 'red'), + B: _createComponent('B', 'green'), + C: _createComponent('C', 'blue'), + D: _createComponent('D', 'yellow'), +}; const App = () => { + const [dataName, setDataName] = useState('Normal'); + return (
-
A
, - B: () =>
B
, - C: () =>
C
, - D: () =>
D
, - }} - config={{ - key: 'root', - layout: WBUtil.createSplit( - [ - WBUtil.createComponent('A'), - WBUtil.createSplit( - [WBUtil.createComponent('B'), WBUtil.createSplit([WBUtil.createComponent('C'), WBUtil.createComponent('D')])], - 'vertical', - 0.5 - ), - ], - 'horizontal', - 0.5 - ), - }} - onConfigChange={function (config: IWBConfigData, skipRefresh?: boolean | undefined) {}} - /> +
+ +
+ +
); }; diff --git a/src/FWContext.ts b/src/FWContext.ts deleted file mode 100644 index 03c40fc..0000000 --- a/src/FWContext.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createContext } from 'react'; -import { EventBus } from 'ah-event-bus'; -import { IWBConfigData, IWBLayout, IWBLayoutComponent } from './IWBLayout'; - -export type IFWEvt = { - reflow: null; - afterConfigChange: { source?: IWBLayout; skipRefresh?: boolean }; - afterEnterPanel: { item: IWBLayoutComponent }; -}; - -export type IFWContext = { - config: IWBConfigData; - components: Record; - event: EventBus; - - currentLayout: IWBLayout; - - renderTitle: (layout: IWBLayout) => any; - renderIcon?: (layout: IWBLayout) => any; - onComponentProps?: (layout: IWBLayoutComponent) => any; -}; - -export const FWContext = createContext({ - config: null as any, - components: null as any, - event: new EventBus(), - currentLayout: null as any, - renderTitle: null as any, -}); diff --git a/src/FlexibleComponent.tsx b/src/FlexibleComponent.tsx deleted file mode 100644 index 41e0ced..0000000 --- a/src/FlexibleComponent.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useContext, useLayoutEffect, useRef, useState } from 'react'; -import cx from 'classnames'; -import { FWContext } from './FWContext'; -import { Button, Space, Typography } from 'antd'; -import { CloseOutlined, VerticalLeftOutlined } from '@ant-design/icons'; -import { ErrorBoundary } from './ErrorBoundary'; -import { WBUtil } from './WBUtil'; - -export interface IFlexibleComponentProps { - className?: string; - style?: React.CSSProperties; -} - -const NotFoundComponent = () => ( - - 面板未定义 - -); - -export const FlexibleComponent = ({ className, style }: IFlexibleComponentProps) => { - const ctx = useContext(FWContext); - const ref = useRef(null); - const [domReady, setDomReady] = useState(); - - useLayoutEffect(() => { - setDomReady(true); - }, []); - - const layout = ctx.currentLayout; - if (layout.type !== 'Component') return null; - - const InnerComponent = ctx.components[layout.component] || NotFoundComponent; - - const isSingleComponent = ctx.config.layout.type === 'Component'; - const _hideHeaderWidget = ctx.config.hideHeaderWidget || (isSingleComponent && ctx.config.hideHeaderWhenSingleComponent); - - const renderHeader = () => { - // useFWHeaderSlot() 依赖这个 div - const _slotEle =
; - const title = ctx.renderTitle(layout) || InnerComponent.displayText || layout.key; - - if (_hideHeaderWidget) { - return ( -
-
-
{title}
- {_slotEle} -
-
- ); - } - - return ( -
-
-
{title}
- {_slotEle} -
- - -
- ); - }; - - const _extraProps = ctx.onComponentProps?.(layout); - - return ( -
{ - ctx.event.emit('afterEnterPanel', { item: layout }); - }} - style={style} - > - {renderHeader()} -
- {domReady && } -
-
- ); -}; diff --git a/src/FlexibleLayout.tsx b/src/FlexibleLayout.tsx deleted file mode 100644 index de05793..0000000 --- a/src/FlexibleLayout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { useContext } from 'react'; -import { FlexibleComponent } from './FlexibleComponent'; -import { FlexibleSplit } from './FlexibleSplit'; -import { FWContext } from './FWContext'; - -export interface IFlexibleLayoutProps { - className?: string; - style?: React.CSSProperties; -} - -export const FlexibleLayout = ({ className, style }: IFlexibleLayoutProps) => { - const ctx = useContext(FWContext); - const layout = ctx.currentLayout; - - if (layout.type === 'Component') return ; - if (layout.type === 'Split') return ; - - return null; -}; diff --git a/src/FlexibleSidePanelContainer.tsx b/src/FlexibleSidePanelContainer.tsx deleted file mode 100644 index 29182bd..0000000 --- a/src/FlexibleSidePanelContainer.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useContext, useRef } from 'react'; -import cx from 'classnames'; -import { FWContext } from './FWContext'; -import { IWBSidePanel } from './IWBLayout'; -import { FlexibleLayout } from './FlexibleLayout'; -import { AppstoreOutlined } from '@ant-design/icons'; -import { useForceUpdate } from './useForceUpdate'; - -export interface IFlexibleSidePanelContainerProps { - className?: string; - style?: React.CSSProperties; -} - -export const FlexibleSidePanelContainer = ({ className, style }: IFlexibleSidePanelContainerProps) => { - const ctx = useContext(FWContext); - const fu = useForceUpdate(); - - const ref = useRef(null); - - const { sidePanel } = ctx.config; - if (!sidePanel) return null; // 侧边栏不可见 - - const activePanel: IWBSidePanel | undefined = sidePanel.list[sidePanel.activeIdx] as any; - - const handleResize = (ev: React.MouseEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - if (!activePanel) return; - - const _container = ref.current; - if (!_container) return; - - const startX = ev.clientX; - const startWidth = sidePanel.width; - - const _handleMouseMove = (ev: MouseEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - const deltaX = ev.clientX - startX; - const newWidth = startWidth + deltaX; - - _container.style.width = newWidth + 'px'; - sidePanel.width = newWidth; - - ctx.event.emit('afterConfigChange', { source: activePanel.layout, skipRefresh: true }); - }; - - const _handleMouseUp = (ev: MouseEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - document.removeEventListener('mousemove', _handleMouseMove); - document.removeEventListener('mouseup', _handleMouseUp); - - ctx.event.emit('afterConfigChange', { source: activePanel.layout }); - ctx.event.emit('reflow', null); - }; - - document.addEventListener('mousemove', _handleMouseMove); - document.addEventListener('mouseup', _handleMouseUp); - }; - - const handleSwitchPanel = (idx: number) => { - if (idx === sidePanel.activeIdx) sidePanel.activeIdx = -1; - else sidePanel.activeIdx = idx; - - fu.update(); - - ctx.event.emit('afterConfigChange', { skipRefresh: true }); - - // 由于切换面板会导致布局变化,所以需要延迟触发刷新(等待 react 渲染完成) - setTimeout(() => { - ctx.event.emit('reflow', null); - }, 0); - }; - - return ( -
-
    -
  • handleSwitchPanel(-1)}> - -
  • - - {sidePanel.list.map((panel, idx) => { - if (panel.layout.type !== 'Component') return null; - - const title = ctx.renderTitle(panel.layout); - - return ( -
  • handleSwitchPanel(idx)}> - {title} -
  • - ); - })} -
- - {activePanel && ( - <> - - - -
- - )} -
- ); -}; diff --git a/src/FlexibleSplit.tsx b/src/FlexibleSplit.tsx deleted file mode 100644 index fb88360..0000000 --- a/src/FlexibleSplit.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useContext, useLayoutEffect, useRef } from 'react'; -import cx from 'classnames'; -import { FWContext } from './FWContext'; -import { IWBLayoutSplit } from './IWBLayout'; -import { FlexibleLayout } from './FlexibleLayout'; - -export interface IFlexibleSplitProps { - className?: string; - style?: React.CSSProperties; -} - -const calcStyle = (direction: IWBLayoutSplit['direction'], ratio: number) => { - const sizeA = `calc((100% - ${GUTTER_SIZE}px) * ${ratio})`; - const sizeB = `calc((100% - ${GUTTER_SIZE}px) * ${1 - ratio})`; - - const ret: Record<'ca' | 'cb' | 'gutter', Record> = { ca: {}, gutter: {}, cb: {} }; - - if (direction === 'vertical') { - ret.ca.height = sizeA; - ret.cb.height = sizeB; - ret.gutter.top = sizeA; - } - - if (direction === 'horizontal') { - ret.ca.width = sizeA; - ret.cb.width = sizeB; - ret.gutter.left = sizeA; - } - - return ret; -}; - -const applyDraggingStyle = ( - direction: IWBLayoutSplit['direction'], - ratio: number, - dom: { container: HTMLDivElement; ca: HTMLDivElement; cb: HTMLDivElement; gutter: HTMLDivElement } -) => { - const sty = calcStyle(direction, ratio); - Object.assign(dom.ca.style, sty.ca); - Object.assign(dom.cb.style, sty.cb); - Object.assign(dom.gutter.style, sty.gutter); -}; - -interface IDraggingInfo { - containerSize: { width: number; height: number }; - startX: number; - startY: number; - startRatio: number; -} - -const GUTTER_SIZE = 8; // .less 里也在用 -const minChildSize = 48; - -export const FlexibleSplit = ({ className, style }: IFlexibleSplitProps) => { - const TypedFWContext = FWContext; - const ctx = useContext(TypedFWContext); - - const layout = ctx.currentLayout; - if (layout.type !== 'Split') return null; - - // const evBusRef = useRef(ctx._evBus); - // evBusRef.current = ctx._evBus; - - const ref = { - container: useRef(null), - ca: useRef(null), - cb: useRef(null), - gutter: useRef(null), - }; - - useLayoutEffect(() => { - if (ref.container.current && ref.ca.current && ref.cb.current && ref.gutter.current) { - const dom = { - container: ref.container.current, - ca: ref.ca.current, - cb: ref.cb.current, - gutter: ref.gutter.current, - }; - - let currentRatio = layout.ratio || 0.5; - let rInfo: IDraggingInfo | undefined = undefined; - - const handleGutterMouseDown = (ev: MouseEvent) => { - // 记录初始信息 - rInfo = { - containerSize: dom.container.getBoundingClientRect(), - startX: ev.clientX, - startY: ev.clientY, - startRatio: currentRatio, - }; - }; - - // mouse move 的时候处理拖拽逻辑 - const handleContainerMouseMove = (ev: MouseEvent) => { - if (!rInfo) return; - - ev.stopPropagation(); - ev.preventDefault(); - - const { width, height } = rInfo.containerSize; - - const totalLength = layout.direction === 'vertical' ? height : width; - const delta = layout.direction === 'vertical' ? ev.clientY - rInfo.startY : ev.clientX - rInfo.startX; - - let dragPos = totalLength * rInfo.startRatio + delta; - - // 钳制拖拽范围 - if (dragPos < minChildSize) dragPos = minChildSize; - if (totalLength - minChildSize < dragPos) dragPos = totalLength - minChildSize; - - currentRatio = dragPos / totalLength; - - applyDraggingStyle(layout.direction, currentRatio, dom); - ctx.event.emit('reflow', null); - }; - - const handleContainerMouseUp = (ev: MouseEvent) => { - if (!rInfo) return; - - ev.stopPropagation(); - ev.preventDefault(); - rInfo = undefined; - - layout.ratio = currentRatio; - ctx.event.emit('afterConfigChange', { source: layout }); - }; - - const handleContainerMouseLeave = (ev: MouseEvent) => { - handleContainerMouseUp(ev); - }; - - dom.gutter.addEventListener('mousedown', handleGutterMouseDown); - dom.container.addEventListener('mousemove', handleContainerMouseMove); - dom.container.addEventListener('mouseup', handleContainerMouseUp); - dom.container.addEventListener('mouseleave', handleContainerMouseLeave); - - return () => { - dom.gutter.removeEventListener('mousedown', handleGutterMouseDown); - dom.container.removeEventListener('mousemove', handleContainerMouseMove); - dom.container.removeEventListener('mouseup', handleContainerMouseUp); - dom.container.removeEventListener('mouseleave', handleContainerMouseLeave); - }; - } - }, [layout.ratio, layout]); - - const sty = calcStyle(layout.direction, layout.ratio || 0.5); - - return ( -
-
- - - -
-
-
- - - -
-
- ); -}; diff --git a/src/FlexibleWorkbench.tsx b/src/FlexibleWorkbench.tsx deleted file mode 100644 index 810edae..0000000 --- a/src/FlexibleWorkbench.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useRef } from 'react'; -import { IWBConfigData, IWBLayout, IWBLayoutComponent } from './IWBLayout'; -import cx from 'classnames'; -import './style.less'; -import { FWContext, IFWContext, IFWEvt } from './FWContext'; -import _ from 'lodash'; -import { EventBus } from 'ah-event-bus'; -import { FlexibleLayout } from './FlexibleLayout'; -import { useForceUpdate } from './useForceUpdate'; -import { useListen } from './useListen'; -import { useHandler } from './useHandler'; -import { theme } from 'antd'; -import { FlexibleSidePanelContainer } from './FlexibleSidePanelContainer'; - -export interface IFlexibleWorkbenchProps { - className?: string; - style?: React.CSSProperties; - - renderIcon?: (layout: IWBLayout) => any; - renderTitle?: (layout: IWBLayout) => any; - - onComponentProps?: (layout: IWBLayoutComponent) => any; - - components: Record; - config: IWBConfigData; - onConfigChange: (config: IWBConfigData, skipRefresh?: boolean) => any; - onEnterPanel?: (layout: IWBLayoutComponent) => any; -} - -export const DefaultRenderTitle = (layout: IWBLayout) => { - if (layout.type === 'Component') return layout.component; - if (layout.type === 'Split') return layout.key; - return ''; -}; - -export const FlexibleWorkbench = ({ - className, - style, - config, - onConfigChange, - renderIcon, - components, - renderTitle = DefaultRenderTitle, - onComponentProps, - onEnterPanel = () => {}, -}: IFlexibleWorkbenchProps) => { - const eventRef = useRef(new EventBus()); - const { token } = theme.useToken(); - - const fu = useForceUpdate(); - - const handleConfigChange = useHandler(onConfigChange); - const handleOverPanel = useHandler(onEnterPanel); - - useListen(eventRef.current, 'afterConfigChange', ev => { - if (!ev.skipRefresh) { - fu.update(); - } - - handleConfigChange({ ...config }, ev.skipRefresh); - }); - - useListen(eventRef.current, 'afterEnterPanel', ev => { - handleOverPanel(ev.item); - }); - - const ctx: IFWContext = { - components, - config, - event: eventRef.current, - currentLayout: config.layout, - renderTitle, - renderIcon, - onComponentProps, - }; - - return ( -
- - - - -
- ); -}; diff --git a/src/IWBLayout.tsx b/src/IWBLayout.tsx index 01b7fd2..37fd856 100644 --- a/src/IWBLayout.tsx +++ b/src/IWBLayout.tsx @@ -1,35 +1,19 @@ -export type IWBLayoutComponent = { key: string; type: 'Component'; component: string; query?: any }; +export type IWBComponent = { key: string; type: 'Component'; component: string; query?: any }; -export type IWBSplitDirection = 'vertical' | 'horizontal'; +export type IWBSplitDir = 'vertical' | 'horizontal'; -export type IWBLayoutSplit = { - key: string; - type: 'Split'; - ratio?: number; - direction?: IWBSplitDirection; - children: IWBLayout[]; -}; - -export type IWBLayout = IWBLayoutComponent | IWBLayoutSplit; +export type IWBSplitConfig_Fixed = { type: 'fixed'; size: number }; +export type IWBSplitConfig_Flex = { type: 'flex' }; -export type IWBSidePanel = { - layout: IWBLayout; -}; +export type IWBSplitConfig = IWBSplitConfig_Fixed | IWBSplitConfig_Flex; -export type IWBConfigData = { +export type IWBSplit = { key: string; + type: 'Split'; + direction: IWBSplitDir; - layout: IWBLayout; - - // 侧边栏 - sidePanel?: { - width: number; // 侧边栏宽度 - activeIdx: number; // 当前激活的侧边栏 - list: IWBSidePanel[]; // 侧边栏列表 - }; - - /** 当仅有一个组件时,隐藏 header widget */ - hideHeaderWhenSingleComponent?: boolean; - - hideHeaderWidget?: boolean; + children: IWBLayout[]; + config: IWBSplitConfig[]; }; + +export type IWBLayout = IWBComponent | IWBSplit; diff --git a/src/WBComponent.tsx b/src/WBComponent.tsx new file mode 100644 index 0000000..358e87d --- /dev/null +++ b/src/WBComponent.tsx @@ -0,0 +1,52 @@ +import React, { useMemo, useRef } from 'react'; +import cx from 'classnames'; +import { Typography } from 'antd'; +import { ErrorBoundary } from './ErrorBoundary'; +import { IWBComponent, IWBLayout } from './IWBLayout'; +import { WBContext } from './WBContext'; + +export interface IWBComponentProps { + className?: string; + style?: React.CSSProperties; + + components: Record; + current: IWBComponent; + parent?: IWBLayout; + width: number; + height: number; + left: number; + top: number; +} + +const NotFoundComponent = () => ( + + 面板未定义 + +); + +export const WBComponent = ({ className, style, components, current, parent, width, height, left, top }: IWBComponentProps) => { + const ref = useRef(null); + + const layout = current; + const InnerComponent = components[layout.component] || NotFoundComponent; + + const cachedChildren = useMemo(() => { + return ( + + + + ); + }, [width, height, current.key, current.component]); + + return ( +
{}} + style={{ width, height, left, top, ...style }} + > + {cachedChildren} +
+ ); +}; diff --git a/src/WBContext.ts b/src/WBContext.ts new file mode 100644 index 0000000..95063a1 --- /dev/null +++ b/src/WBContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; +import { IWBComponent } from './IWBLayout'; + +export type IWBContext = { + current: IWBComponent; + width: number; + height: number; +}; + +export const WBContext = createContext(null as any); diff --git a/src/WBLayout.tsx b/src/WBLayout.tsx new file mode 100644 index 0000000..156a500 --- /dev/null +++ b/src/WBLayout.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { WBComponent } from './WBComponent'; +import { WBSplit } from './WBSplit'; +import { IWBLayout } from './IWBLayout'; + +export interface IWBProps { + components: Record; + current: IWBLayout; + parent?: IWBLayout; + width: number; + height: number; + left: number; + top: number; + + onLayoutChange: (layout: IWBLayout) => void; +} + +export const WBLayout = ({ components, current, parent, width, height, left, top, onLayoutChange }: IWBProps) => { + if (current.type === 'Component') { + return ( + + ); + } + + if (current.type === 'Split') { + return ( + + ); + } + + return null; +}; diff --git a/src/WBSplit.tsx b/src/WBSplit.tsx new file mode 100644 index 0000000..70a6631 --- /dev/null +++ b/src/WBSplit.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import cx from 'classnames'; +import { WBLayout } from './WBLayout'; +import { cloneDeep } from 'lodash'; +import { WBUtil } from './WBUtil'; +import { IWBLayout, IWBSplit } from './IWBLayout'; + +export interface IWBSplitProps { + className?: string; + style?: React.CSSProperties; + + components: Record; + current: IWBSplit; + parent?: IWBLayout; + width: number; + height: number; + left: number; + top: number; + + onLayoutChange: (layout: IWBLayout) => void; +} + +const GUTTER_SIZE = 8; + +export const WBSplit = ({ className, style, components, current, parent, width, height, left, top, onLayoutChange }: IWBSplitProps) => { + const sizeType = current.direction === 'vertical' ? 'height' : 'width'; + const offsetType = current.direction === 'vertical' ? 'top' : 'left'; + const totalSize = (sizeType === 'height' ? height : width) - (current.children.length - 1) * GUTTER_SIZE; + + const handleGutterMouseDown = (ev: React.MouseEvent, index: number) => { + ev.preventDefault(); + ev.stopPropagation(); + + const ele = ev.currentTarget; + ele.classList.add('active'); + + const startOffset = ev[current.direction === 'vertical' ? 'clientY' : 'clientX']; + const startConfig = cloneDeep(current.config); + + const _mouseMove = (_ev: MouseEvent) => { + const endOffset = _ev[current.direction === 'vertical' ? 'clientY' : 'clientX']; + const movement = endOffset - startOffset; + + current.config = cloneDeep(startConfig); + WBUtil.moveSplit(current.config, index, totalSize, movement); + + onLayoutChange({ ...current }); + }; + + const _mouseUp = () => { + document.removeEventListener('mousemove', _mouseMove); + document.removeEventListener('mouseup', _mouseUp); + + ele.classList.remove('active'); + }; + + document.addEventListener('mousemove', _mouseMove); + document.addEventListener('mouseup', _mouseUp); + }; + + const renderChildren = () => { + const sizeList = WBUtil.calcSplitSize(current.config, totalSize); + const offsetList: number[] = Array(current.children.length).fill(0); + + // 计算面板的偏移量 + for (let i = 1; i < current.children.length; i++) { + offsetList[i] = offsetList[i - 1] + sizeList[i - 1] + GUTTER_SIZE; + } + + const elements: any[] = []; + + for (let i = 0; i < current.children.length; i++) { + const _c = current.children[i]; + const _size = sizeList[i]; + const _offset = offsetList[i]; + + elements.push( + { + current.children[i] = _c2; + onLayoutChange({ ...current }); + }} + /> + ); + + if (i < current.children.length - 1) { + elements.push( +
handleGutterMouseDown(ev, i)} + /> + ); + } + } + + return elements; + }; + + return ( +
+ {renderChildren()} +
+ ); +}; diff --git a/src/WBUtil.ts b/src/WBUtil.ts index 21167b9..b7c2869 100644 --- a/src/WBUtil.ts +++ b/src/WBUtil.ts @@ -1,5 +1,5 @@ -import _ from 'lodash'; -import { IWBConfigData, IWBLayout, IWBLayoutComponent, IWBLayoutSplit, IWBSplitDirection } from './IWBLayout'; +import { sum } from 'lodash'; +import { IWBLayout, IWBComponent, IWBSplit, IWBSplitDir, IWBSplitConfig } from './IWBLayout'; let uid = 0; @@ -28,63 +28,73 @@ export const WBUtil = { } }, - getAllLayouts(config: IWBConfigData) { - const list: IWBLayout[] = []; - - this.walkLayout(config.layout, cur => { - list.push(cur); - }); - - // side panel - if (config.sidePanel) { - for (const _sp of config.sidePanel.list) { - this.walkLayout(_sp.layout, cur => { - list.push(cur); - }); - } - } - - return list; + createSplit( + children: IWBLayout[], + config: IWBSplitConfig[] = children.map(c => ({ type: 'flex' })), + direction: IWBSplitDir = 'horizontal' + ): IWBSplit { + return { type: 'Split', key: this.randomID(), direction, children, config }; }, - createSplit(children: IWBLayout[], direction: IWBSplitDirection = 'horizontal', ratio = 0.5): IWBLayoutSplit { - return { type: 'Split', key: this.randomID(), ratio, direction, children }; - }, - - createComponent(component: string, query?: any): IWBLayoutComponent { + createComponent(component: string, query?: any): IWBComponent { return { type: 'Component', key: this.randomID(), component, query }; }, - resetLayout(layout: IWBLayout, newData: IWBLayout) { - // 原地修改 layout,保持引用 - Object.keys(layout).forEach(k => delete (layout as any)[k]); - Object.assign(layout, newData); - }, + calcSplitSize(config: IWBSplit['config'], totalSize: number) { + const sizeList: number[] = Array(config.length).fill(0); + + // 1. 填充 fixed size + for (let i = 0; i < config.length; i++) { + const c = config[i]; + if (c.type === 'fixed') sizeList[i] = c.size; + } - splitPanel(layout: IWBLayoutComponent, direction: IWBSplitDirection, lay2?: IWBLayout) { - if (!lay2) lay2 = this.createComponent(layout.component, _.cloneDeep(layout.query)); - if (lay2.key === layout.key) throw new Error('duplicated key: ' + lay2.key); + // 2. 填充 flex size + const flexCount = sizeList.filter(s => s === 0).length; + const flexPerSize = (totalSize - sum(sizeList)) / flexCount; + for (let i = 0; i < config.length; i++) { + const c = config[i]; + if (c.type === 'flex') sizeList[i] = flexPerSize; + } - const newLayout = this.createSplit([_.cloneDeep(layout), lay2], direction); - this.resetLayout(layout, newLayout); + return sizeList; }, - closePanel(config: IWBConfigData, closeKey: string) { - let closeParent: IWBLayout | undefined; + moveSplit(config: IWBSplit['config'], index: number, totalSize: number, movement: number) { + const c1 = config[index]; + const c2 = config[index + 1]; - this.walkLayout(config.layout, (_cur, _parent) => { - if (_cur.key === closeKey) { - closeParent = _parent; - return 'stop'; - } - }); + const minSize = 32; + + const sizeList = this.calcSplitSize(config, totalSize); + const size1 = sizeList[index]; + const size2 = sizeList[index + 1]; + + let replaceC1: IWBSplitConfig | undefined = undefined; - if (!closeParent) throw new Error('missing closeParent'); - if (closeParent.type !== 'Split') throw new Error('closeParent.type error'); + if (c1.type === 'fixed' && c2.type === 'fixed') { + const _m = movement > 0 ? Math.min(movement, size2 - minSize) : Math.max(movement, minSize - size1); // 防止超量 - const toKeepLayout = closeParent.children.find(c => c.key !== closeKey); - if (!toKeepLayout) throw new Error('missing toKeepLayout'); + c1.size = size1 + _m; + c2.size = size2 - _m; + } + // [fixed, flex] + else if (c1.type === 'fixed' && c2.type === 'flex') { + const _m = Math.max(movement, minSize - size1); // 防止超量 + c1.size = size1 + _m; + } + // [flex, fixed] + else if (c1.type === 'flex' && c2.type === 'fixed') { + const _m = Math.min(movement, size2 - minSize); // 防止超量 + c2.size = size2 - _m; + } + // [flex, flex] + else { + // 上一个变为 fixed + const _m = Math.max(movement, minSize - size1); // 防止超量 + replaceC1 = { type: 'fixed', size: size1 + _m }; + } - this.resetLayout(closeParent, toKeepLayout); + if (replaceC1) config[index] = replaceC1; }, }; diff --git a/src/Workbench.tsx b/src/Workbench.tsx new file mode 100644 index 0000000..1e13c21 --- /dev/null +++ b/src/Workbench.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { IWBLayout, IWBComponent } from './IWBLayout'; +import cx from 'classnames'; +import './style.less'; +import _ from 'lodash'; +import { WBLayout } from './WBLayout'; + +export interface IWorkbenchProps { + className?: string; + style?: React.CSSProperties; + + components: Record; + layout: IWBLayout; + onChange?: (layout: IWBLayout) => any; + onEnterPanel?: (layout: IWBComponent) => any; +} + +export const Workbench = ({ className, style, layout, onChange, components }: IWorkbenchProps) => { + const container = useRef(null); + const [bounding, setBounding] = useState<{ width: number; height: number }>(); + + const [stash, setStash] = useState(layout); + + useEffect(() => { + setStash(layout); + }, [layout]); + + const reloadBounding = () => { + if (!container.current) return; + + const { width, height } = container.current.getBoundingClientRect(); + setBounding({ width, height }); + }; + + useLayoutEffect(() => { + if (!container.current) return; + + reloadBounding(); + + // 监听 container 的 resize 事件 + const ro = new ResizeObserver(() => reloadBounding()); + ro.observe(container.current); + + return () => { + ro.disconnect(); + }; + }, []); + + const handleLayoutChange = (layout: IWBLayout) => { + setStash(layout); + onChange && onChange(layout); + }; + + const renderInner = () => { + if (!bounding) return null; + + return ( +
+ +
+ ); + }; + + return ( +
+ {renderInner()} +
+ ); +}; diff --git a/src/index.ts b/src/index.ts index e03e874..ff9fa82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,7 @@ -export * from './FWContext'; -export * from './FlexibleComponent'; -export * from './FlexibleLayout'; -export * from './FlexibleSplit'; -export * from './FlexibleWorkbench'; +export * from './WBContext'; +export * from './WBComponent'; +export * from './WBLayout'; +export * from './WBSplit'; +export * from './Workbench'; export * from './IWBLayout'; export * from './WBUtil'; -export * from './useFWHeaderSlot'; -export * from './useFWPanelSize'; -export * from './useFWQuery'; diff --git a/src/style.less b/src/style.less index 921f6ec..019046e 100644 --- a/src/style.less +++ b/src/style.less @@ -11,6 +11,11 @@ @component-header-height: 38px; .FW { + &-wrapper { + width: 100%; + height: 100%; + } + position: relative; width: 100%; height: 100%; @@ -21,12 +26,6 @@ position: relative; height: 100%; - > .ca { - position: absolute; - top: 0; - left: 0; - } - > .gutter { position: absolute; box-sizing: border-box; @@ -45,22 +44,20 @@ transition: opacity 0.1s ease-in-out; } - &:hover { + &:hover, + &.active { &::after { opacity: 1; } } } - > .cb { + > .FW-component { position: absolute; - right: 0; - bottom: 0; } &.vertical { - > .ca, - > .cb { + > .FW-component { width: 100%; } > .gutter { @@ -76,8 +73,7 @@ } &.horizontal { - > .ca, - > .cb { + > .FW-component { height: 100%; } > .gutter { @@ -96,169 +92,7 @@ // 容器内部的内容 &-component { height: 100%; + width: 100%; box-sizing: border-box; - - > .header { - display: flex; - padding: 0 12px; - height: @component-header-height; - align-items: center; - justify-content: space-between; - border: 1px solid @colorBorderSecondary; - box-sizing: border-box; - user-select: none; - color: @colorText; - background: @colorBgContainer; - border-top-left-radius: @component-border-radius; - border-top-right-radius: @component-border-radius; - - > .title { - display: flex; - width: 1px; - flex: 1; - align-items: center; - height: @component-header-height; - box-sizing: border-box; - - > .main { - height: @component-header-height; - line-height: @component-header-height; - font-size: 14px; - font-weight: bolder; - } - - > .internal-slot { - margin-left: 12px; - width: 1px; - flex: 1; - overflow: auto; - - &::-webkit-scrollbar { - width: 0; - height: 0; - } - } - } - - > .extra { - margin-left: 12px; - } - - &.no-extra { - > .title { - > .main { - flex: 1; - } - - > .internal-slot { - flex: unset; - width: unset; - } - } - } - } - - > .body { - position: relative; - padding: 8px 12px; - width: 100%; - height: calc(100% - @component-header-height); - background: @colorBgContainer; - border-bottom-left-radius: @component-border-radius; - border-bottom-right-radius: @component-border-radius; - box-sizing: border-box; - - border: 1px solid @colorBorderSecondary; - border-top: none; - - overflow: auto; - - &::-webkit-scrollbar { - width: 0; - height: 0; - } - } - } - - // side panel - &-SidePanelContainer { - position: relative; - width: 42px; - - &.expanded { - &::after { - position: absolute; - content: ''; - width: calc(100% - 6px); - height: 100%; - left: 0; - top: 0; - border-top: 1px solid @colorBorder; - border-right: 1px solid @colorBorder; - border-bottom: 1px solid @colorBorder; - pointer-events: none; - border-radius: 4px; - } - } - - &-side { - position: absolute; - left: 0; - top: 0; - width: 36px; - height: 100%; - margin: 0; - padding: 0; - - color: @colorText; - list-style: none; - - > li { - margin-bottom: 4px; - width: 36px; - height: 36px; - cursor: pointer; - border-radius: 4px; - overflow: hidden; - - display: flex; - align-items: center; - justify-content: center; - - &:hover { - background-color: @colorBgElevated; - } - - &.active { - background-color: @colorPrimary; - } - } - } - - &-main { - margin: 0 6px 0 42px; - height: 100%; - - color: @colorText; - background: @colorBgContainer; - border-radius: 4px; - - overflow: auto; - - &::-webkit-scrollbar { - width: 0; - height: 0; - } - } - - &-resizer { - position: absolute; - right: 0; - top: 0; - width: 6px; - height: 100%; - - cursor: col-resize; - } } } diff --git a/src/useFWHeaderSlot.ts b/src/useFWHeaderSlot.ts deleted file mode 100644 index 1318805..0000000 --- a/src/useFWHeaderSlot.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useContext } from 'react'; -import ReactDOM from 'react-dom'; -import { FWContext } from './FWContext'; - -export const useFWHeaderSlot = () => { - const ctx = useContext(FWContext); - const key = ctx.currentLayout.key; - - return (node: React.ReactNode) => { - const slotEle = document.getElementById(`FW-internal-slot-${key}`); - if (!slotEle) throw new Error('missing slot: ' + key); - - return ReactDOM.createPortal(node, slotEle); - }; -}; diff --git a/src/useFWPanelSize.ts b/src/useFWPanelSize.ts deleted file mode 100644 index b1aa7d4..0000000 --- a/src/useFWPanelSize.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useContext, useEffect, useLayoutEffect, useState } from 'react'; -import { useListen } from './useListen'; -import { FWContext } from './FWContext'; - -export const useFWPanelSize = () => { - const ctx = useContext(FWContext); - - const calcSize = () => { - const bodyEle = document.getElementById(`FW-component-body-${ctx.currentLayout.key}`); - if (!bodyEle) throw new Error('missing bodyEle: ' + ctx.currentLayout.key); - - const _bodySize = bodyEle.getBoundingClientRect(); - return { width: _bodySize.width, height: _bodySize.height }; - }; - - const [size, setSize] = useState<{ width: number; height: number }>(calcSize()); - - useListen(ctx.event, 'reflow', () => setSize(calcSize())); - useLayoutEffect(() => setSize(calcSize()), []); - - return size; -}; diff --git a/src/useFWQuery.ts b/src/useFWQuery.ts deleted file mode 100644 index 4de95c2..0000000 --- a/src/useFWQuery.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useContext } from 'react'; -import { FWContext } from './FWContext'; -import { useForceUpdate } from './useForceUpdate'; - -export const useFWQuery = (updatable?: boolean) => { - const ctx = useContext(FWContext); - const fu = useForceUpdate(); - - const layout = ctx.currentLayout; - - const getQuery = (): T | undefined => { - return layout.type === 'Component' ? layout.query : undefined; - }; - const query = getQuery(); - - const setQuery = (q: T | undefined) => { - if (layout.type === 'Component') { - layout.query = q; - ctx.event.emit('afterConfigChange', { source: layout, skipRefresh: true }); - - if (updatable) fu.update(); - } - }; - - const mergeQuery = (q: Partial | undefined) => { - setQuery({ ...getQuery(), ...q } as any); - }; - - return { query, setQuery, getQuery, mergeQuery }; -}; diff --git a/src/useHandler.ts b/src/useHandler.ts deleted file mode 100644 index 63ee0ae..0000000 --- a/src/useHandler.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useRef } from 'react'; - -export const useHandler = any>(fn: T) => { - const cc = useRef(fn); - cc.current = fn; // 始终指向最新闭包函数 - - const handlerRef = useRef((...args: any[]) => cc.current(...args)); - return handlerRef.current as T; -};