feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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;
}