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,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;

View File

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

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View 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`

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View 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"
}
}

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

View File

@@ -0,0 +1 @@
# 用的豆包库但因bot引入时候有一些库内部的错误导致引入报错暂时先copy出来了一份

View File

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

View File

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

View File

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

View File

@@ -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%);
// 只有右侧存在borderborder-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;
}
}
}
}

View File

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

View File

@@ -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',
}

View File

@@ -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模式下为负数的scrollTopchromium最低支持版本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;
};

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

View File

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

View File

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

View 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>

View 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"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
],
"exclude": ["**/*"]
}

View 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
}
}

View 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',
});