feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
31
frontend/packages/components/scroll-view/.storybook/main.js
Normal file
31
frontend/packages/components/scroll-view/.storybook/main.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
|
||||
/** @type { import('@storybook/react-vite').StorybookConfig } */
|
||||
const config = {
|
||||
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
viteFinal: config =>
|
||||
mergeConfig(config, {
|
||||
plugins: [
|
||||
svgr({
|
||||
svgrOptions: {
|
||||
native: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @type { import('@storybook/react').Preview } */
|
||||
const preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
5
frontend/packages/components/scroll-view/.stylelintrc.js
Normal file
5
frontend/packages/components/scroll-view/.stylelintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { defineConfig } = require('@coze-arch/stylelint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
extends: [],
|
||||
});
|
||||
16
frontend/packages/components/scroll-view/README.md
Normal file
16
frontend/packages/components/scroll-view/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# scroll-view
|
||||
|
||||
> Project template for react component with storybook.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] eslint & ts
|
||||
- [x] esm bundle
|
||||
- [x] umd bundle
|
||||
- [x] storybook
|
||||
|
||||
## Commands
|
||||
|
||||
- init: `rush update`
|
||||
- dev: `npm run dev`
|
||||
- build: `npm run build`
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"operationSettings": [
|
||||
{
|
||||
"operationName": "test:cov",
|
||||
"outputFolderNames": ["coverage"]
|
||||
},
|
||||
{
|
||||
"operationName": "ts-check",
|
||||
"outputFolderNames": ["./dist"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const { defineConfig } = require('@coze-arch/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
packageRoot: __dirname,
|
||||
preset: 'web',
|
||||
rules: {},
|
||||
});
|
||||
43
frontend/packages/components/scroll-view/package.json
Normal file
43
frontend/packages/components/scroll-view/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@coze-common/scroll-view",
|
||||
"version": "0.0.1",
|
||||
"description": "scroll-view",
|
||||
"license": "Apache-2.0",
|
||||
"author": "liushuoyan@bytedance.com",
|
||||
"maintainers": [],
|
||||
"main": "src/index.tsx",
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"lint": "eslint ./ --cache",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:cov": "npm run test -- --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.2",
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coze-arch/eslint-config": "workspace:*",
|
||||
"@coze-arch/stylelint-config": "workspace:*",
|
||||
"@coze-arch/ts-config": "workspace:*",
|
||||
"@coze-arch/vitest-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/lodash-es": "^4.17.10",
|
||||
"@types/react": "18.2.37",
|
||||
"@types/react-dom": "18.2.15",
|
||||
"@vitest/coverage-v8": "~3.0.5",
|
||||
"react": "~18.2.0",
|
||||
"react-dom": "~18.2.0",
|
||||
"stylelint": "^15.11.0",
|
||||
"tailwindcss": "~3.3.3",
|
||||
"vite-plugin-svgr": "~3.3.0",
|
||||
"vitest": "~3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2.0",
|
||||
"react-dom": ">=18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
18
frontend/packages/components/scroll-view/src/index.tsx
Normal file
18
frontend/packages/components/scroll-view/src/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 { ScrollView } from './scroll-view';
|
||||
export { type ScrollViewController } from './scroll-view/type';
|
||||
@@ -0,0 +1 @@
|
||||
# 用的豆包库,但因bot引入时候有一些库内部的错误导致引入报错,暂时先copy出来了一份
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 const SCROLL_VIEW_ANCHOR_CONTAINER = 'scroll-view-anchor-container';
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 MutableRefObject, createContext, useContext } from 'react';
|
||||
|
||||
export const ScrollViewContentContext = createContext<
|
||||
MutableRefObject<HTMLDivElement | null>
|
||||
>({
|
||||
current: null,
|
||||
});
|
||||
|
||||
export const useScrollViewContentRef = () =>
|
||||
useContext(ScrollViewContentContext);
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { isNumber } from 'lodash-es';
|
||||
|
||||
import { isAppleWebkit } from '../utils/is-apple-webkit';
|
||||
import { supportNegativeScrollTop } from './utils';
|
||||
import { ScrollStatus, type ScrollViewController } from './type';
|
||||
import { SCROLL_VIEW_ANCHOR_CONTAINER } from './consts';
|
||||
|
||||
const SUPPORT_NEGATIVE_SCROLL_TOP = supportNegativeScrollTop();
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
export interface UseScrollViewControllerAndStateParams {
|
||||
/** 滚动方向 */
|
||||
reverse: boolean;
|
||||
/** 滚动状态,自动吸顶/吸底时依赖,当Top时自动吸顶,当Bottom时自动吸底 */
|
||||
scrollStatusRef?: RefObject<ScrollStatus>;
|
||||
}
|
||||
|
||||
export interface UseScrollViewControllerAndStateReturnValue {
|
||||
/** 注入到滚动容器的引用 */
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
/** 滚动容器外层 dom 的引用 */
|
||||
wrapperRef: RefObject<HTMLDivElement>;
|
||||
/** 控制器 */
|
||||
controller: ScrollViewController;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function, @coze-arch/max-line-per-function
|
||||
export const useScrollViewControllerAndState = ({
|
||||
reverse,
|
||||
scrollStatusRef,
|
||||
}: UseScrollViewControllerAndStateParams): UseScrollViewControllerAndStateReturnValue => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isDisableScroll = useRef<boolean>(false);
|
||||
|
||||
const getScrollViewWrapper = () => wrapperRef;
|
||||
|
||||
const getContainer = () => {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
if (!container) {
|
||||
throw Error('Not found ScrollView ref instance');
|
||||
}
|
||||
|
||||
return container;
|
||||
};
|
||||
|
||||
const _getContainerScrollTop = () => {
|
||||
const container = getContainer();
|
||||
|
||||
if (reverse && !SUPPORT_NEGATIVE_SCROLL_TOP) {
|
||||
return (
|
||||
container.scrollTop - (container.scrollHeight - container.offsetHeight)
|
||||
);
|
||||
}
|
||||
|
||||
return container.scrollTop;
|
||||
};
|
||||
|
||||
const _setContainerScrollTop = (value: number) => {
|
||||
const container = getContainer();
|
||||
|
||||
if (reverse && !SUPPORT_NEGATIVE_SCROLL_TOP) {
|
||||
container.scrollTop =
|
||||
value + (container.scrollHeight - container.offsetHeight);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = value;
|
||||
};
|
||||
|
||||
const disableScroll = () => {
|
||||
isDisableScroll.current = true;
|
||||
containerRef.current?.classList.add(styles['disable-scroll']);
|
||||
scrollTo(top => top - 1);
|
||||
};
|
||||
const enableScroll = () => {
|
||||
isDisableScroll.current = false;
|
||||
containerRef.current?.classList.remove(styles['disable-scroll']);
|
||||
};
|
||||
|
||||
const scrollTo = (update: (prev: number) => number) => {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatingScrollTop = update(_getContainerScrollTop());
|
||||
|
||||
if (isAppleWebkit()) {
|
||||
if (reverse) {
|
||||
const endingScrollTop =
|
||||
container.offsetHeight - container.scrollHeight + 1;
|
||||
|
||||
_setContainerScrollTop(Math.max(updatingScrollTop, endingScrollTop));
|
||||
} else {
|
||||
const endingScrollTop =
|
||||
container.scrollHeight - container.offsetHeight - 1;
|
||||
|
||||
_setContainerScrollTop(Math.min(updatingScrollTop, endingScrollTop));
|
||||
}
|
||||
} else {
|
||||
_setContainerScrollTop(updatingScrollTop);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPercentage = async (ratio: number) => {
|
||||
if (isDisableScroll.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: container } = containerRef;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { offsetHeight, scrollHeight } = container;
|
||||
|
||||
/** 当当前不是滚动状态时,不调整滚动进度 */
|
||||
if (scrollHeight <= offsetHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endScrollTop = reverse
|
||||
? offsetHeight - scrollHeight
|
||||
: scrollHeight - offsetHeight;
|
||||
const realRatio = reverse ? 1 - ratio : ratio;
|
||||
|
||||
_setContainerScrollTop(endScrollTop * realRatio);
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getScrollPercentage = () => {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { scrollHeight, offsetHeight } = container;
|
||||
|
||||
const scrollTop = _getContainerScrollTop();
|
||||
|
||||
const relativeRatio = Math.abs(scrollTop) / (scrollHeight - offsetHeight);
|
||||
return reverse ? 1 - relativeRatio : relativeRatio;
|
||||
};
|
||||
|
||||
const getScrollTop = () => {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { scrollHeight, offsetHeight } = container;
|
||||
|
||||
const scrollTop = _getContainerScrollTop();
|
||||
|
||||
return reverse ? scrollHeight - (offsetHeight + -scrollTop) : scrollTop;
|
||||
};
|
||||
|
||||
const getScrollBottom = () => {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return container.scrollHeight - getScrollTop() - container.offsetHeight;
|
||||
};
|
||||
|
||||
const refreshAnchor = () => {
|
||||
if (scrollStatusRef?.current === ScrollStatus.Top) {
|
||||
scrollToPercentage(0);
|
||||
} else if (scrollStatusRef?.current === ScrollStatus.Bottom) {
|
||||
scrollToPercentage(1);
|
||||
}
|
||||
};
|
||||
|
||||
const checkContentIsFull = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
console.warn('[checkContentIsFull] container not found');
|
||||
return false;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const parentNode = container.parentElement;
|
||||
const parentRect = parentNode?.getBoundingClientRect();
|
||||
return (parentRect?.height ?? 0) === rect.height;
|
||||
};
|
||||
|
||||
const getOriginScrollInfo = () => {
|
||||
const { current: container } = containerRef;
|
||||
if (!container) {
|
||||
return { scrollHeight: 0, scrollTop: 0, rect: null };
|
||||
}
|
||||
return {
|
||||
scrollHeight: container.scrollHeight,
|
||||
scrollTop: container.scrollTop,
|
||||
rect: container.getBoundingClientRect(),
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
containerRef.current?.addEventListener('touchstart', () => {
|
||||
isDisableScroll.current = true;
|
||||
});
|
||||
|
||||
containerRef.current?.addEventListener('touchend', () => {
|
||||
isDisableScroll.current = false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
wrapperRef,
|
||||
ref: containerRef,
|
||||
controller: {
|
||||
getScrollViewWrapper,
|
||||
scrollTo,
|
||||
scrollToPercentage,
|
||||
getScrollPercentage,
|
||||
getScrollTop,
|
||||
getOriginScrollInfo,
|
||||
getScrollBottom,
|
||||
refreshAnchor,
|
||||
disableScroll,
|
||||
enableScroll,
|
||||
checkContentIsFull,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export interface UseAutoAnchorWhenPrependOnSafariParams {
|
||||
/** 滚动方法 */
|
||||
scrollTo: ScrollViewController['scrollTo'];
|
||||
/** 获取当前滚动距底部距离 */
|
||||
getScrollBottom: ScrollViewController['getScrollTop'];
|
||||
/** 滚动方向 */
|
||||
reverse: boolean;
|
||||
/** 启用锚定时离边界的最小值,默认为10 */
|
||||
enableThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理y-reverse在Safari下,向下插入元素时自动锚定的问题(safari不支持overflow-anchor属性)
|
||||
*/
|
||||
export const useAutoAnchorWhenAppendOnSafari = ({
|
||||
scrollTo,
|
||||
getScrollBottom,
|
||||
reverse,
|
||||
enableThreshold = 10,
|
||||
}: UseAutoAnchorWhenPrependOnSafariParams) => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isAppleWebkit() || !reverse) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevLastChild: undefined | string;
|
||||
|
||||
let prevContainerHeight: undefined | number;
|
||||
|
||||
const scrollToKeepAnchor = () => {
|
||||
const container = document.querySelector(
|
||||
`.${SCROLL_VIEW_ANCHOR_CONTAINER}`,
|
||||
);
|
||||
|
||||
if (container) {
|
||||
const currentLastChild = container.lastElementChild?.outerHTML;
|
||||
|
||||
const currentContainerHeight = container.getBoundingClientRect().height;
|
||||
|
||||
if (prevLastChild && isNumber(prevContainerHeight)) {
|
||||
/** 末尾元素变了,同时高度变了,那就断定为末尾元素插入,但是仅当超过阈值时才锚定 */
|
||||
if (
|
||||
prevContainerHeight !== currentContainerHeight &&
|
||||
currentLastChild !== prevLastChild &&
|
||||
Math.abs(getScrollBottom()) > enableThreshold
|
||||
) {
|
||||
const heightIncrease = currentContainerHeight - prevContainerHeight;
|
||||
|
||||
scrollTo(prevScrollTop => prevScrollTop - heightIncrease);
|
||||
}
|
||||
}
|
||||
|
||||
prevContainerHeight = currentContainerHeight;
|
||||
|
||||
prevLastChild = currentLastChild ?? undefined;
|
||||
}
|
||||
|
||||
requestAnimationFrame(scrollToKeepAnchor);
|
||||
};
|
||||
|
||||
requestAnimationFrame(scrollToKeepAnchor);
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
.scroll-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
.before,
|
||||
.after {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
justify-content: flex-start;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.scrollable {
|
||||
position: relative;
|
||||
|
||||
overflow: hidden scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.scrollbar-width-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
&.reverse {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
&.disable-scroll {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.show-scrollbar,
|
||||
&.auto-show-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: block;
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(0 0 0 / 0%);
|
||||
// 只有右侧存在border,border-radius需要特别处理
|
||||
border-radius: 9px 14px 14px 9px;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
opacity: 1;
|
||||
background: var(--scrollbar-color, #ccc);
|
||||
// 滚动条留白:
|
||||
background-clip: padding-box;
|
||||
border-right: 3px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--scrollbar-hover-color, #999);
|
||||
background-clip: padding-box;
|
||||
border-right: 3px solid transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.show-scrollbar {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
&.auto-show-scrollbar {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { debounce, isFunction } from 'lodash-es';
|
||||
import cs from 'classnames';
|
||||
|
||||
import {
|
||||
type ScrollViewProps,
|
||||
type ScrollViewController,
|
||||
ScrollStatus,
|
||||
} from './type';
|
||||
import {
|
||||
useAutoAnchorWhenAppendOnSafari,
|
||||
useScrollViewControllerAndState,
|
||||
} from './hooks';
|
||||
import { ScrollViewContentContext } from './context';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
export { useScrollViewContentRef } from './context';
|
||||
|
||||
const DEBOUNCE_TIME = 100;
|
||||
|
||||
export const ScrollView = forwardRef<ScrollViewController, ScrollViewProps>(
|
||||
function ScrollView(
|
||||
{
|
||||
style,
|
||||
className,
|
||||
children,
|
||||
before,
|
||||
beforeClassName,
|
||||
after,
|
||||
innerBefore,
|
||||
reverse = false,
|
||||
reachTopThreshold,
|
||||
onReachTop,
|
||||
onLeaveTop,
|
||||
reachBottomThreshold,
|
||||
onReachBottom,
|
||||
onLeaveBottom,
|
||||
showScrollbar,
|
||||
autoShowScrollbar,
|
||||
onScroll,
|
||||
scrollbarWidthNone = true,
|
||||
},
|
||||
outerRef,
|
||||
) {
|
||||
/** 在最开始的时候,默认的滚动状态 */
|
||||
const defaultScrollStatus = reverse
|
||||
? ScrollStatus.Bottom
|
||||
: ScrollStatus.Top;
|
||||
|
||||
const scrollViewContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollStatusRef = useRef(defaultScrollStatus);
|
||||
|
||||
const { wrapperRef, ref, controller } = useScrollViewControllerAndState({
|
||||
reverse,
|
||||
scrollStatusRef,
|
||||
});
|
||||
|
||||
const { getScrollTop, getScrollBottom, scrollTo } = controller;
|
||||
|
||||
const isReachTopRef = useRef<boolean>(false);
|
||||
|
||||
const isReachBottomRef = useRef<boolean>(false);
|
||||
|
||||
useImperativeHandle(outerRef, () => controller, [controller]);
|
||||
|
||||
const handleDebounceUpdateScrollStatus = useMemo(
|
||||
() =>
|
||||
debounce((scrollStatus: ScrollStatus) => {
|
||||
scrollStatusRef.current = scrollStatus;
|
||||
}, DEBOUNCE_TIME),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
((e: React.UIEvent<HTMLElement>) => {
|
||||
if (!e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
onScroll?.(e);
|
||||
|
||||
const { offsetHeight } = e.currentTarget;
|
||||
|
||||
const topThreshold = reachTopThreshold ?? offsetHeight;
|
||||
|
||||
const bottomThreshold = reachBottomThreshold ?? offsetHeight;
|
||||
|
||||
const anchorThreshold = 0;
|
||||
|
||||
/** 滚动至事件上边界 */
|
||||
if (getScrollTop() < topThreshold) {
|
||||
if (!isReachTopRef.current) {
|
||||
isReachTopRef.current = true;
|
||||
onReachTop?.();
|
||||
}
|
||||
} else {
|
||||
if (isReachTopRef.current) {
|
||||
isReachTopRef.current = false;
|
||||
onLeaveTop?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动至事件下边界 */
|
||||
if (getScrollBottom() < bottomThreshold) {
|
||||
if (!isReachBottomRef.current) {
|
||||
isReachBottomRef.current = true;
|
||||
onReachBottom?.();
|
||||
}
|
||||
} else {
|
||||
if (isReachBottomRef.current) {
|
||||
isReachBottomRef.current = false;
|
||||
onLeaveBottom?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动至自动贴边(anchor)边界,先释放再延迟更新贴边态,防止还未滚出贴边阈值时自动贴边和滚动冲突 */
|
||||
scrollStatusRef.current = ScrollStatus.Inner;
|
||||
if (
|
||||
getScrollTop() <= anchorThreshold &&
|
||||
getScrollBottom() <= anchorThreshold
|
||||
) {
|
||||
handleDebounceUpdateScrollStatus(defaultScrollStatus);
|
||||
} else if (getScrollTop() <= anchorThreshold) {
|
||||
handleDebounceUpdateScrollStatus(ScrollStatus.Top);
|
||||
} else if (getScrollBottom() <= anchorThreshold) {
|
||||
handleDebounceUpdateScrollStatus(ScrollStatus.Bottom);
|
||||
} else {
|
||||
handleDebounceUpdateScrollStatus(ScrollStatus.Inner);
|
||||
}
|
||||
}) satisfies ScrollViewProps['onScroll'],
|
||||
[
|
||||
reachTopThreshold,
|
||||
reachBottomThreshold,
|
||||
getScrollTop,
|
||||
getScrollBottom,
|
||||
onReachTop,
|
||||
onLeaveTop,
|
||||
onReachBottom,
|
||||
onLeaveBottom,
|
||||
],
|
||||
);
|
||||
|
||||
useAutoAnchorWhenAppendOnSafari({ scrollTo, reverse, getScrollBottom });
|
||||
|
||||
return (
|
||||
<ScrollViewContentContext.Provider value={scrollViewContentRef}>
|
||||
<div
|
||||
className={cs(styles['scroll-view'], className)}
|
||||
style={style}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{before ? (
|
||||
<div className={cs(styles.before, beforeClassName)}>
|
||||
{isFunction(before) ? before?.(controller) : before}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cs(styles.content)}
|
||||
ref={scrollViewContentRef}
|
||||
data-testid="chat-area.message-content"
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
data-scroll-element="scrollable"
|
||||
className={cs(
|
||||
styles.scrollable,
|
||||
showScrollbar && styles['show-scrollbar'],
|
||||
autoShowScrollbar && styles['auto-show-scrollbar'],
|
||||
scrollbarWidthNone && styles['scrollbar-width-none'],
|
||||
{
|
||||
[styles.reverse]: reverse,
|
||||
},
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isFunction(children) ? children(controller) : children}
|
||||
{innerBefore ? (
|
||||
<div className={cs(styles.before)}>
|
||||
{isFunction(innerBefore)
|
||||
? innerBefore?.(controller)
|
||||
: innerBefore}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{after ? (
|
||||
<div className={cs(styles.after)}>
|
||||
{isFunction(after) ? after?.(controller) : after}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollViewContentContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 RefObject } from 'react';
|
||||
|
||||
export interface ScrollViewController {
|
||||
/** 滚动到 */
|
||||
scrollTo: (update: (prev: number) => number) => void;
|
||||
/** 滚动到可滚动高度的指定百分比的位置,以容器顶部为参考基线;当滚动完毕后回调callback */
|
||||
scrollToPercentage: (ratio: number) => Promise<void> | void;
|
||||
/** 获取当前滚动百分比 */
|
||||
getScrollPercentage: () => number;
|
||||
/** 获取当前滚动状态距离顶部的距离,适配y方向和y-reverse方向的情况 */
|
||||
getScrollTop: () => number;
|
||||
/** 获取原始的 scroll top 值,不做换算 */
|
||||
getOriginScrollInfo: () => {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
rect: null | DOMRect;
|
||||
};
|
||||
/** 获取当前滚动状态距离顶部的距离,适配y方向和y-reverse方向的情况 */
|
||||
getScrollBottom: () => number;
|
||||
/** 更新吸顶/吸底状态,当数据有更新时主动调用此API */
|
||||
refreshAnchor: () => void;
|
||||
/** 禁止容器滚动 */
|
||||
disableScroll: () => void;
|
||||
/** 使得容器可滚动 */
|
||||
enableScroll: () => void;
|
||||
/** 检查内容充满容器(用于初始状态高度较小的情况,防止无法触发 scroll 事件) */
|
||||
checkContentIsFull: () => boolean;
|
||||
/** 获取 scroll 外层容器的引用 */
|
||||
getScrollViewWrapper: () => RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export interface ScrollViewProps
|
||||
extends Pick<
|
||||
React.HTMLAttributes<unknown>,
|
||||
'className' | 'style' | 'onScroll'
|
||||
> {
|
||||
children:
|
||||
| ((controller: ScrollViewController) => JSX.Element)
|
||||
| React.ReactNode;
|
||||
|
||||
before?:
|
||||
| ((controller: ScrollViewController) => JSX.Element)
|
||||
| JSX.Element
|
||||
| null;
|
||||
beforeClassName?: string;
|
||||
after?: ((controller: ScrollViewController) => JSX.Element) | JSX.Element;
|
||||
innerBefore?:
|
||||
| ((controller: ScrollViewController) => JSX.Element)
|
||||
| JSX.Element;
|
||||
/** 是否反转,从下往上滚动 */
|
||||
reverse?: boolean;
|
||||
/** 剩余滚动至顶部距离小于多少时触发,默认为offsetHeight */
|
||||
reachTopThreshold?: number;
|
||||
/* 滚动到达顶部阈值 */
|
||||
onReachTop?: () => unknown;
|
||||
/* 滚动离开顶部阈值 */
|
||||
onLeaveTop?: () => unknown;
|
||||
|
||||
/** 剩余滚动至底部距离小于多少时触发,默认为offsetHeight */
|
||||
reachBottomThreshold?: number;
|
||||
/** 滚动到达底部阈值 */
|
||||
onReachBottom?: () => unknown;
|
||||
/** 滚动离开底部阈值 */
|
||||
onLeaveBottom?: () => unknown;
|
||||
/** 不管内容是否超出 container 都展示 scrollBar */
|
||||
showScrollbar?: boolean;
|
||||
/** 内容超出 container 时才展示 scrollBar,若未超出,不展示 scrollBar */
|
||||
autoShowScrollbar?: boolean;
|
||||
/** 完全隐藏 scrollbar */
|
||||
scrollbarWidthNone?: boolean;
|
||||
}
|
||||
|
||||
/** 滚动状态 */
|
||||
export enum ScrollStatus {
|
||||
/** 吸顶 */
|
||||
Top = 'top',
|
||||
/** 吸底 */
|
||||
Bottom = 'bottom',
|
||||
/** 中间可双向滚动 */
|
||||
Inner = 'inner',
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 出自:https://stackoverflow.com/questions/4900436/how-to-detect-the-installed-chrome-version
|
||||
*/
|
||||
export const getChromeVersion = () => {
|
||||
const pieces = navigator.userAgent.match(
|
||||
/Chrom(?:e|ium)\/([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)/,
|
||||
);
|
||||
const MAX_LENGTH = 5;
|
||||
if (pieces === null || pieces.length !== MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [, major, minor, build, patch] = pieces.map(piece =>
|
||||
parseInt(piece, 10),
|
||||
);
|
||||
return {
|
||||
major,
|
||||
minor,
|
||||
build,
|
||||
patch,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 是否支持在column-reverse模式下为负数的scrollTop,chromium最低支持版本83.0.4086.1(上一个版本为82.0.4082.0)
|
||||
*/
|
||||
export const supportNegativeScrollTop = () => {
|
||||
const chromeVersion = getChromeVersion();
|
||||
|
||||
if (!chromeVersion) {
|
||||
/** 假设非chromium系浏览器均支持 */
|
||||
return true;
|
||||
}
|
||||
|
||||
const { major } = chromeVersion;
|
||||
|
||||
const MAX_MAJOR = 83;
|
||||
return major >= MAX_MAJOR;
|
||||
};
|
||||
20
frontend/packages/components/scroll-view/src/typings.d.ts
vendored
Normal file
20
frontend/packages/components/scroll-view/src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
declare module '*.less' {
|
||||
const resource: { [key: string]: string };
|
||||
export = resource;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 是否是在苹果平台的Webkit内核浏览器下,
|
||||
* 注:这个判断条件不等于是在苹果设备下,因为部分苹果设备(例如Mac)可以运行非原生Webkit引擎的浏览器,例如Chromium(Blink)
|
||||
*/
|
||||
export const isAppleWebkit = () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
typeof (window as any).webkitConvertPointFromNodeToPage === 'function';
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 { DemoComponent } from '../src';
|
||||
|
||||
export default {
|
||||
title: 'Example/Demo',
|
||||
component: DemoComponent,
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'centered',
|
||||
},
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const Base = {
|
||||
args: {
|
||||
name: 'tecvan',
|
||||
},
|
||||
};
|
||||
34
frontend/packages/components/scroll-view/stories/hello.mdx
Normal file
34
frontend/packages/components/scroll-view/stories/hello.mdx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Meta } from "@storybook/blocks";
|
||||
|
||||
<Meta title="Hello world" />
|
||||
|
||||
<div className="sb-container">
|
||||
<div className='sb-section-title'>
|
||||
# Hello world
|
||||
|
||||
Hello world
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
.sb-container {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.sb-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sb-section-title {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
27
frontend/packages/components/scroll-view/tsconfig.build.json
Normal file
27
frontend/packages/components/scroll-view/tsconfig.build.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@coze-arch/ts-config/tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"types": [],
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../config/eslint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/stylelint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/ts-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/vitest-config/tsconfig.build.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
frontend/packages/components/scroll-view/tsconfig.json
Normal file
15
frontend/packages/components/scroll-view/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.misc.json"
|
||||
}
|
||||
],
|
||||
"exclude": ["**/*"]
|
||||
}
|
||||
18
frontend/packages/components/scroll-view/tsconfig.misc.json
Normal file
18
frontend/packages/components/scroll-view/tsconfig.misc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@coze-arch/ts-config/tsconfig.web.json",
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"include": ["__tests__", "stories", "vitest.config.ts", "tailwind.config.ts"],
|
||||
"exclude": ["./dist"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./",
|
||||
"outDir": "./dist",
|
||||
"types": ["vitest/globals"],
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true
|
||||
}
|
||||
}
|
||||
22
frontend/packages/components/scroll-view/vitest.config.ts
Normal file
22
frontend/packages/components/scroll-view/vitest.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { defineConfig } from '@coze-arch/vitest-config';
|
||||
|
||||
export default defineConfig({
|
||||
dirname: __dirname,
|
||||
preset: 'web',
|
||||
});
|
||||
Reference in New Issue
Block a user