feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.handle {
|
||||
width: 1px;
|
||||
background-color: var(--coz-stroke-primary);
|
||||
}
|
||||
|
||||
.hot-zone:hover .handle, .handle-moving {
|
||||
width: 4px;
|
||||
background-color: var(--coz-stroke-hglt);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
type MouseEventHandler,
|
||||
type FC,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import s from './handle.module.less';
|
||||
|
||||
// 目前只支持水平方向,按需扩展吧
|
||||
export interface ResizableLayoutHandleProps {
|
||||
className?: string;
|
||||
hotZoneClassName?: string;
|
||||
onMove: (offset: number) => void;
|
||||
onMoveStart: () => void;
|
||||
onMoveEnd: () => void;
|
||||
}
|
||||
|
||||
interface HandleState {
|
||||
startX: number;
|
||||
moving: boolean;
|
||||
}
|
||||
|
||||
const hotZoneStyle = classnames(
|
||||
s['hot-zone'],
|
||||
'flex items-stretch justify-center',
|
||||
'cursor-col-resize',
|
||||
'z-10',
|
||||
'w-[8px] mx-[-3.5px]',
|
||||
'bg-transparent',
|
||||
);
|
||||
|
||||
const handleStyle = classnames('transition-width duration-300 ease-in-out');
|
||||
|
||||
export const ResizableLayoutHandle: FC<ResizableLayoutHandleProps> = ({
|
||||
className,
|
||||
hotZoneClassName,
|
||||
onMove,
|
||||
onMoveStart,
|
||||
onMoveEnd,
|
||||
}) => {
|
||||
const [moving, setMoving] = useState(false);
|
||||
const stateRef = useRef<HandleState>({
|
||||
startX: 0,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
const callbackRef = useRef({
|
||||
onMove,
|
||||
onMoveStart,
|
||||
onMoveEnd,
|
||||
});
|
||||
|
||||
callbackRef.current = {
|
||||
onMove,
|
||||
onMoveStart,
|
||||
onMoveEnd,
|
||||
};
|
||||
|
||||
const moveEnd = useCallback(() => {
|
||||
setMoving(false);
|
||||
stateRef.current = {
|
||||
startX: 0,
|
||||
moving: false,
|
||||
};
|
||||
offEvents();
|
||||
callbackRef.current.onMoveEnd();
|
||||
}, []);
|
||||
|
||||
const move = useCallback((e: PointerEvent) => {
|
||||
if (stateRef.current.moving) {
|
||||
callbackRef.current.onMove(e.clientX - stateRef.current.startX);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const offEvents = () => {
|
||||
window.removeEventListener('pointermove', move, false);
|
||||
// 适配移动端出现多点触控的情况
|
||||
window.removeEventListener('pointerdown', moveEnd, false);
|
||||
window.removeEventListener('pointerup', moveEnd, false);
|
||||
window.removeEventListener('pointercancel', moveEnd, false);
|
||||
};
|
||||
|
||||
const onMouseDown: MouseEventHandler<HTMLDivElement> = e => {
|
||||
stateRef.current = {
|
||||
moving: true,
|
||||
startX: e.pageX,
|
||||
};
|
||||
setMoving(true);
|
||||
callbackRef.current.onMoveStart();
|
||||
window.addEventListener('pointermove', move, false);
|
||||
// 适配移动端出现多点触控的情况
|
||||
window.addEventListener('pointerdown', moveEnd, false);
|
||||
window.addEventListener('pointerup', moveEnd, false);
|
||||
window.addEventListener('pointercancel', moveEnd, false);
|
||||
};
|
||||
// TODO hover 样式 & 热区宽度需要和 UI 对齐
|
||||
return (
|
||||
<div
|
||||
className={classnames(hotZoneStyle, hotZoneClassName)}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
s.handle,
|
||||
moving && s['handle-moving'],
|
||||
handleStyle,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResizableLayoutHandle.displayName = 'ResizableLayoutHandle';
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Children,
|
||||
type PropsWithChildren,
|
||||
useRef,
|
||||
type FC,
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { sum } from 'lodash-es';
|
||||
import classnames from 'classnames';
|
||||
import { useDebounceEffect, useSize } from 'ahooks';
|
||||
|
||||
import { type ResizableLayoutProps } from './types';
|
||||
import { ResizableLayoutHandle } from './handle';
|
||||
|
||||
interface LayoutState {
|
||||
moving: boolean;
|
||||
itemWidth: number[];
|
||||
}
|
||||
|
||||
const getDefaultState = () => ({
|
||||
moving: false,
|
||||
itemWidth: [],
|
||||
});
|
||||
|
||||
export const ResizableLayout: FC<PropsWithChildren<ResizableLayoutProps>> = ({
|
||||
className,
|
||||
children,
|
||||
handleClassName,
|
||||
hotZoneClassName,
|
||||
}) => {
|
||||
const [state, setState] = useState<LayoutState>(getDefaultState());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const childRef = useRef<HTMLElement[]>([]);
|
||||
|
||||
const size = useSize(containerRef);
|
||||
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
if (!size?.width) {
|
||||
return;
|
||||
}
|
||||
const totalSize = sum(state.itemWidth);
|
||||
// 排除还没有进行过拖拽的情况,此时本地 state 中没有记录上次分配的宽度
|
||||
if (totalSize <= 0) {
|
||||
return;
|
||||
}
|
||||
const ratio = size.width / totalSize;
|
||||
const newItemWidth = state.itemWidth.map(w => w * ratio);
|
||||
childRef.current.forEach(
|
||||
(item, index) => (item.style.width = `${newItemWidth[index]}px`),
|
||||
);
|
||||
setState({
|
||||
...state,
|
||||
itemWidth: newItemWidth,
|
||||
});
|
||||
},
|
||||
[size?.width],
|
||||
{
|
||||
wait: 20,
|
||||
maxWait: 100,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
'flex w-full items-stretch',
|
||||
className,
|
||||
state.moving && 'cursor-col-resize select-none',
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{Children.map(children, (child, index) => {
|
||||
let node: ReactNode;
|
||||
if (isValidElement(child)) {
|
||||
node = cloneElement(
|
||||
child,
|
||||
Object.assign({}, child.props, {
|
||||
ref: (target: React.ReactNode) => {
|
||||
if (target instanceof HTMLElement) {
|
||||
childRef.current[index] = target;
|
||||
} else {
|
||||
if (!IS_PROD && target) {
|
||||
throw Error(
|
||||
'children of ResizableLayout need a ref of HTMLElement',
|
||||
);
|
||||
}
|
||||
}
|
||||
// @ts-expect-error -- 跳过类型体操
|
||||
const { ref } = child;
|
||||
if (typeof ref === 'function') {
|
||||
ref(target);
|
||||
} else if (ref && typeof ref === 'object') {
|
||||
ref.current = target;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
node = (
|
||||
<div
|
||||
ref={elm => {
|
||||
if (elm) {
|
||||
childRef.current[index] = elm;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{index > 0 && (
|
||||
<ResizableLayoutHandle
|
||||
className={handleClassName}
|
||||
hotZoneClassName={hotZoneClassName}
|
||||
onMoveStart={() => {
|
||||
setState({
|
||||
moving: true,
|
||||
itemWidth: childRef.current.map(
|
||||
item => item.clientWidth ?? 0,
|
||||
),
|
||||
});
|
||||
}}
|
||||
// 相对于初始位置的偏移量
|
||||
onMove={offset => {
|
||||
const pre = index - 1;
|
||||
childRef.current[pre].style.width = `${
|
||||
state.itemWidth[pre] + offset
|
||||
}px`;
|
||||
childRef.current[index].style.width = `${
|
||||
state.itemWidth[index] - offset
|
||||
}px`;
|
||||
}}
|
||||
onMoveEnd={() => {
|
||||
setState({
|
||||
// 拖拽结束后,记录真实宽度
|
||||
itemWidth: childRef.current.map(
|
||||
item => item.clientWidth ?? 0,
|
||||
),
|
||||
moving: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface ResizableLayoutProps {
|
||||
className?: string;
|
||||
handleClassName?: string;
|
||||
hotZoneClassName?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user