feat(map): 工具箱完成

This commit is contained in:
NICE CODE BY DEV 2024-05-21 19:07:15 +08:00
parent e3e3c05ae0
commit 2e753a7259
62 changed files with 1968 additions and 94 deletions

View File

@ -1,40 +1,81 @@
---
hero:
title: lambo
description: 致力于提升前端开发效率与规范
description: 致力于提升前端开发效率与规范(开发前请先阅读开发流程)
actions:
- text: 快速上手
link: /bizs
features:
- title: biz
emoji: 🍑
description: 业务库
- title: hooks
emoji: 💎
description: hooks
- title: func
emoji: 🌈
description: 常用函数库
- title: meta
emoji: ☀️
description: 原子组件库
- title: constants
emoji: 🈶️
description: 静态定义库
- title: request
emoji: 🥣
description: 网络请求库
- title: types
emoji: 🈸
description: typescript 声明库
- title: material
emoji: 🥱
description: 物料库
- title: cli
emoji: 🐔
description: 脚手架
# features:
# - title: biz
# emoji: 🍑
# description: 业务库
# - title: hooks
# emoji: 💎
# description: hooks
# - title: func
# emoji: 🌈
# description: 常用函数库
# - title: meta
# emoji: ☀️
# description: 原子组件库
# - title: constants
# emoji: 🈶️
# description: 静态定义库
# - title: request
# emoji: 🥣
# description: 网络请求库
# - title: types
# emoji: 🈸
# description: typescript 声明库
# - title: material
# emoji: 🥱
# description: 物料库
# - title: cli
# emoji: 🐔
# description: 脚手架
---
## 开发流程
### 1. 确定需求
从 gitlab 上的 [issue](http://10.0.0.88/web-project/zhst-lambo/boards) 模块找到对应的需求。将 Assignee 负责人指派为自己(如果多人协同开发可以将需求拆分为多个需求,分别指派),然后将 Labels 标签改为 doing 状态。(截止日期选填)
> issuse 命名规则:@zhst/{包名} - {模块名},然后在详情页描述对应需求。
### 2. 创建 git 分支
按照 git flow 规范从 [master](http://10.0.0.88/web-project/zhst-lambo) 上创建分支, 分支的命名规则参考:
1. feat/XXX: 需求新增
2. hotfix/XXX: bug 修复
### 3. 开始开发
进入项目文件夹,在 packages 下找到对应的 npm 包, 然后在 src 目录下按已有的格式进行开发,如果是功能变更就找到对应的页面进行修改
### 4. 提交代码,并提交 mr 到 develop 分支
完成开发后,给代码提交 commit格式参考 ${行为}(${影响范围}): ${变更内容} 例如:
> feat(package.json): 修改版本号
> fix(app.ts): 修改环境变量
对应的变更会在最终的 npm 包版本号体现a.b.c - a 对应重构(一般用不上) - b 对应 feat功能新增 - c 对应 hotfix一般是 bug 修复)
push 完代码之后,在 gitlab 上提交一个 mr 到 develop 分支,指定给对应的人员审核(@江志雄),合并成功之后,将 [issue](http://10.0.0.88/web-project/zhst-lambo/boards) 对应的 Labels 状态改为 waittingPublish。
### 5. 发布成功
发布成功之后,会有两个行为:
1. 在钉钉群通知发布成功。
2. 生成线上预览[说明文档](http://10.0.0.204:30080)
一旦触发了钉钉通知,则需要去到 [issue](http://10.0.0.88/web-project/zhst-lambo/boards) 板块将对应的需求 **close** 掉。
这就是 npm 包整个开发链路。
## 目录结构
<Tree>

View File

@ -37,7 +37,21 @@
"registry": "http://10.0.0.77:4874"
},
"dependencies": {
"react-map-gl": "^7.1.7",
"mapbox-gl": "^2.15.0"
"@mapbox/mapbox-gl-draw": "^1.4.3",
"@mapbox/mapbox-gl-draw-static-mode": "^1.0.1",
"@turf/turf": "^6.5.0",
"@turf/union": "^6.5.0",
"@zhst/hooks": "workspace:^0.13.1",
"@zhst/icon": "workspace:^0.5.0",
"@zhst/meta": "workspace:^",
"classnames": "^2.5.1",
"mapbox-gl": "^2.15.0",
"mapbox-gl-draw-circle": "^1.1.2",
"mapbox-gl-draw-geodesic": "^2.3.1",
"mapbox-gl-draw-rectangle-mode": "^1.0.4",
"react-map-gl": "^7.1.7"
},
"devDependencies": {
"@types/mapbox__mapbox-gl-draw": "^1.4.6"
}
}

View File

@ -1,10 +1,22 @@
import 'mapbox-gl/dist/mapbox-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import Map from 'react-map-gl';
import './index.less';
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import {
// MapboxMap,
MapRef,
// MapStyle
} from "react-map-gl";
import classnames from 'classnames'
import Tools from './components/tools'
import DrawControl, { DrawControlProps, DrawControlRefProps } from './components/drawControl';
import { MapProps } from './interface';
import { merge } from './utils';
import { MAP_CENTER, defaultMapConfig } from './constants';
import { MAP_CENTER, defaultMapConfig } from './utils/constants';
import './index.less';
import mapboxDrawStyle from './utils/drawStyle';
const componentName = 'zhst-map'
export interface MapRefProps {
@ -20,23 +32,102 @@ const MapBox = forwardRef<MapRefProps, MapProps>((props, ref) => {
width = '100%',
...others
} = props || {};
const mapRef = useRef(null)
const mapRef = useRef<MapRef>(null)
const drawControlRef = useRef<DrawControlRefProps>(null)
// 默认绘制配置
const [drawConfig, setConfig] = useState<DrawControlProps>({
displayControlsDefault: false,
position: 'top-left',
styles: mapboxDrawStyle,
// Select which mapbox-gl-draw control buttons to add to the map.
controls: {
polygon: true,
trash: true
},
// The user does not have to click the polygon control button first.
defaultMode: 'draw_polygon',
})
const handleDrawCreate = e => {
console.log('handleDrawCreate', e)
}
const handleDrawUpdate = e => {
console.log('handleDrawUpdate', e)
}
const handleDrawDelete = e => {
console.log('handleDrawDelete', e)
}
useEffect(() => {
console.log('drawControlRef', drawControlRef.current?.drawer?.deleteAll())
}, [])
useImperativeHandle(ref, () => ({}))
return (
//@ts-ignore
<Map
ref={mapRef}
initialViewState={{ ...mapCenter, zoom: 10 }}
onLoad={(e) => {
onLoad && onLoad(e);
}}
style={{ width: width, height: height, ...style }}
{...merge(defaultMapConfig, others)}
>
{children}
</Map>
<div className={classnames(`${componentName}`)}>
<Tools
buttonList={[
{
label: '圆形框选',
key: 'circle',
icon: 'icon-yuan',
onClick: () => drawControlRef.current?.drawer?.changeMode?.('draw_circle')
},
{
label: '矩形框选',
key: 'rect',
icon: 'icon-fang',
onClick: () => drawControlRef.current?.drawer?.changeMode?.('draw_rect')
},
{
label: '多边形框选',
key: 'more',
icon: 'icon-duobianxing',
onClick: () => drawControlRef.current?.drawer?.changeMode?.('draw_polygon')
},
{
label: '路径框选',
key: 'path',
icon: 'icon-lujingkuangxuannor',
onClick: () => drawControlRef.current?.drawer?.changeMode?.('draw_line_string')
},
{
label: '清除',
key: 'clear',
icon: 'icon-cuo',
onClick: () => drawControlRef.current?.drawer?.deleteAll()
}
]}
/>
<Map
ref={mapRef}
className={classnames(`${componentName}-container`)}
initialViewState={{ ...mapCenter, zoom: 10 }}
onLoad={(e) => {
onLoad && onLoad(e);
}}
style={{ width: width, height: height, ...style }}
{...merge(defaultMapConfig, others)}
>
{/* <CustomOverlay
>
<Button></Button>
</CustomOverlay> */}
<DrawControl
ref={drawControlRef}
onCreate={handleDrawCreate}
onUpdate={handleDrawUpdate}
onDelete={handleDrawDelete}
{...drawConfig}
/>
{children}
</Map>
</div>
);
});

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { useState, cloneElement } from 'react';
import { useControl } from 'react-map-gl';
import { createPortal } from 'react-dom';
import type { MapboxMap, IControl } from 'react-map-gl';
// Based on template in https://docs.mapbox.com/mapbox-gl-js/api/markers/#icontrol
class OverlayControl implements IControl {
_map: MapboxMap | undefined;
_container: HTMLDivElement | undefined;
_redraw: () => void;
constructor(redraw: () => void) {
this._redraw = redraw;
}
onAdd(map: MapboxMap) {
this._map = map;
map.on('move', this._redraw);
/* global document */
this._container = document.createElement('div');
this._redraw();
return this._container;
}
onRemove() {
this._container?.remove();
this._map?.off('move', this._redraw);
this._map = undefined;
}
getMap() {
return this._map;
}
getElement() {
return this._container;
}
}
/**
*
*/
const CustomOverlay = (props: {children: React.ReactElement}) => {
const [, setVersion] = useState(0);
const customControl = useControl<OverlayControl>(() => {
const forceUpdate = () => setVersion(v => v + 1);
return new OverlayControl(forceUpdate);
});
const map = customControl.getMap();
// @ts-ignore
return map && createPortal(cloneElement(props.children, { map }), customControl.getElement());
}
export default React.memo(CustomOverlay);

View File

@ -0,0 +1,6 @@
/**
* Created by jiangzhixiong on 2024/05/21
*/
import CustomOverlay from './CustomOverlay'
export default CustomOverlay

View File

@ -0,0 +1,89 @@
/**
* Created by jiangzhixiong on 2024/05/21
*/
import { forwardRef, useImperativeHandle, useRef, } from 'react'
import MapboxDraw, { MapboxDrawOptions } from '@mapbox/mapbox-gl-draw';
import {
CircleMode,
DragCircleMode,
DirectMode,
SimpleSelectMode
} from 'mapbox-gl-draw-circle'
import drawRectMode from 'mapbox-gl-draw-rectangle-mode'
import drawStaticMode from '@mapbox/mapbox-gl-draw-static-mode'
// import * as MapboxDrawGeodesic from 'mapbox-gl-draw-geodesic';
import { useControl } from 'react-map-gl';
import type { ControlPosition } from 'react-map-gl';
import { MapContextValue } from 'react-map-gl/dist/esm/components/map';
type DrawControlProps = ConstructorParameters<typeof MapboxDraw>[0] & {
position?: ControlPosition;
onCreate?: (evt: {features: object[]}) => void;
onUpdate?: (evt: {features: object[]; action: string}) => void;
onDelete?: (evt: {features: object[]}) => void;
};
export interface DrawControlRefProps {
drawer?: MapboxDraw & {
modes: MapboxDraw.Modes | 'static' | 'draw_rect' | 'drag_circle' | 'draw_circle' | 'direct_select' | 'simple_select'
}
}
const DrawControl = forwardRef<DrawControlRefProps, DrawControlProps>((props, ref) => {
const drawRef = useRef<MapboxDrawOptions>(null)
useControl<MapboxDraw>(
() => {
let draw = new MapboxDraw(
{
modes: {
...MapboxDraw.modes,
// draw_line_select: drawLineSelectMode,
draw_rect: drawRectMode,
drag_circle: DragCircleMode,
draw_circle : CircleMode,
direct_select: DirectMode,
simple_select: SimpleSelectMode,
static: drawStaticMode,
},
...props
}
)
// 修改模式
// drawIns?.changeMode('simple_select');
drawRef.current = draw
return draw
},
(context: MapContextValue) => {
const { map } = context
map.on('draw.create', props.onCreate);
map.on('draw.update', props.onUpdate);
map.on('draw.delete', props.onDelete);
},
(context: MapContextValue) => {
const { map } = context
map.off('draw.create', props.onCreate);
map.off('draw.update', props.onUpdate);
map.off('draw.delete', props.onDelete);
},
{
position: props.position
}
);
useImperativeHandle(ref, () => ({
drawer: drawRef.current
}))
return null;
})
DrawControl.defaultProps = {
onCreate: () => {},
onUpdate: () => {},
onDelete: () => {}
};
export default DrawControl

View File

@ -0,0 +1,8 @@
/**
* Created by jiangzhixiong on 2024/05/21
*/
import DrawControl from './DrawControl'
export type { DrawControlProps, DrawControlRefProps } from './DrawControl'
export default DrawControl

View File

@ -0,0 +1,69 @@
/**
* Created by jiangzhixiong on 2024/05/21
*/
import React, { forwardRef, ReactNode, useContext, useImperativeHandle } from 'react'
import { Button, ConfigProvider } from '@zhst/meta'
import { useToggle } from '@zhst/hooks'
import classNames from 'classnames'
import { IconFont } from '@zhst/icon'
import './index.less'
const { ConfigContext } = ConfigProvider
export interface ToolsProps {
prefixCls?: string
defaultValue?: boolean;
buttonList?: {
label: string
key: string
icon?: ReactNode | string
onClick?: () => void
}[]
}
export interface ToolsRefProps {
}
const Tools = forwardRef<ToolsRefProps, ToolsProps>((props, ref) => {
const {
prefixCls: customizePrefixCls,
buttonList = [],
defaultValue
} = props
const { getPrefixCls } = useContext(ConfigContext)
const componentName = getPrefixCls('map-tools', customizePrefixCls)
const [state, { toggle }] = useToggle(defaultValue)
useImperativeHandle(ref, () => ({
}))
return (
<div className={componentName}>
<ul className={classNames(`${componentName}-navs`, { [`${componentName}-navs_active`]: state })}>
{buttonList.map((item) => (
<>
<li onClick={item.onClick} className={classNames(`${componentName}-navs-item`)} key={item.key}>
{typeof item.icon === 'string' ? (
<IconFont className={classNames(`${componentName}-navs-item-icon`)} size={24} icon={item.icon} />
) : (
item.icon
)}
{item.label}
</li>
</>
))}
</ul>
<Button
size='large'
shape="circle"
type="primary"
onClick={toggle}
icon={<IconFont icon={state ? 'icon-guanbi' : 'icon-kuangxuangongju'} />}
/>
</div>
)
})
export default Tools

View File

@ -0,0 +1,64 @@
.zhst-map-tools {
position: absolute;
display: flex;
align-items: center;
top: 16px;
right: 16px;
z-index: 1;
&-navs {
display: none;
position: relative;
left: 24px;
margin: 0;
margin-right: 12px;
padding-left: 0;
list-style: none;
font-size: 0;
background-color: #fff;
border: 1px solid #09f;
border-radius: 3px;
opacity: 0;
transition: .3s ease all;
&_active {
display: block;
left: 0;
opacity: 1;
}
&-item {
position: relative;
padding: 6px 12px;
display: inline-flex;
align-items: center;
font-size: 12px;
cursor: pointer;
&:hover {
color: #09f;
}
&::after {
position: absolute;
content: '';
width: 1px;
height: 36%;
right: 0;
top: 50%;
background-color: #f0f0f0;
transform: translateY(-50%);
}
&:last-child {
&::after {
display: none;
}
}
&-icon {
font-size: 24px;
}
}
}
}

View File

@ -0,0 +1,8 @@
/**
* Created by jiangzhixiong on 2024/05/21
*/
import Tools from './Tools'
export type { ToolsProps, ToolsRefProps } from './Tools'
export default Tools

View File

View File

@ -1,3 +1,9 @@
.mapboxgl-ctrl-attrib-button {
display: none;
.zhst-map {
position: relative;
width: auto;
height: auto;
.mapboxgl-ctrl-logo {
display: none;
}
}

View File

@ -25,3 +25,5 @@ title: 快速上手
[官方文档](https://docs.mapbox.com/mapbox-gl-js/example)
[react-map-gl](https://visgl.github.io/react-map-gl/examples)
[mapbox-gl-draw](https://github.com/mapbox/mapbox-gl-draw)
[draw 绘制模式插件](https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/MODES.md#available-custom-modes)
[mapboxJS](https://www.naivemap.com/mapbox-gl-js-cookbook/starter/handlers/control.html)

View File

@ -1,35 +0,0 @@
const getRawType = (val: any) => {
return Object.prototype.toString.call(val).slice(8, -1)
}
const isPlainObjectOrArray = (val: any) => {
return isPlainObject(val) || Array.isArray(val)
}
const isPlainObject = (val: any) => {
return getRawType(val) === 'Object'
}
export const merge = (object: any, ...sources: any) => {
for(const source of sources) {
for(const key in source) {
if(source[key] === undefined && key in object) {
continue
}
if(isPlainObjectOrArray(source[key])) {
if(getRawType(object[key] === getRawType(source[key]))) {
if(isPlainObject(object[key])) {
merge(object[key], source[key])
} else {
object[key] = object[key].concat(source[key])
}
} else {
object[key] = source[key]
}
} else {
object[key] = source[key]
}
}
}
return object;
}

View File

@ -1,15 +1,58 @@
import * as turf from '@turf/turf';
import union from '@turf/union';
// 获取2个点的距离
export const getDistance = (point1, point2) => {
// 判断参数类型
const getRawType = (val: any) => {
return Object.prototype.toString.call(val).slice(8, -1)
}
/**
*
* @param val
* @returns
*/
const isPlainObjectOrArray = (val: any) => {
return isPlainObject(val) || Array.isArray(val)
}
// 判断参数是否为对象
const isPlainObject = (val: any) => {
return getRawType(val) === 'Object'
}
export const merge = (object: any, ...sources: any) => {
for(const source of sources) {
for(const key in source) {
if(source[key] === undefined && key in object) {
continue
}
if(isPlainObjectOrArray(source[key])) {
if(getRawType(object[key] === getRawType(source[key]))) {
if(isPlainObject(object[key])) {
merge(object[key], source[key])
} else {
object[key] = object[key].concat(source[key])
}
} else {
object[key] = source[key]
}
} else {
object[key] = source[key]
}
}
}
return object;
}
// 获取 2 个点的距离
export const getDistance = (point1: { x: any; y: any }, point2: { x: any; y: any }) => {
const { x: x1, y: y1 } = point1;
const { x: x2, y: y2 } = point2;
return Math.abs(Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)));
};
//获取两点(格式例如:[120,30])间物理像素距离
export function getPixelDistance(map, pointA, pointB) {
export function getPixelDistance(map: { transform: { locationPoint: (arg0: any) => any } }, pointA: any, pointB: any) {
const pointAPixel = map.transform.locationPoint(pointA); // A点的像素坐标位置
const pointBPixel = map.transform.locationPoint(pointB); // B点的像素坐标位置
// A、B两点的像素坐标距离
@ -17,19 +60,19 @@ export function getPixelDistance(map, pointA, pointB) {
}
//判断是否是0值 给一个偏移量
export const isZero = (floatValue) => {
export const isZero = (floatValue: number) => {
return floatValue > -0.00001 && floatValue < 0.00001;
};
//判断2个向量是否平行
export const isParallel = (vectorA, vectorB) => {
export const isParallel = (vectorA: { x: any; y: any }, vectorB: { x: any; y: any }) => {
const { x: x1, y: y1 } = vectorA;
const { x: x2, y: y2 } = vectorB;
return isZero(x1 * y2 - y1 * x2);
};
//判断一个点是否在线段上 判断向量叉乘是否为0 且x坐标在线段2端点间
export const isPointOnLine = (vectorA, path) => {
export const isPointOnLine = (vectorA: { x: number; y: number }, path: any[]) => {
const [vectorB, vectorC] = path;
//与端点重合
@ -84,8 +127,8 @@ export const isPointOnLine = (vectorA, path) => {
//获取线段重合部分,如果重合返回合并后的线段 如果不重合 返回传入的线段
//see https://blog.csdn.net/qq_39108767/article/details/81673921
//see https://www.cnblogs.com/tuyang1129/p/9390376.html
export const getLineCoincide = (path1, path2) => {
let paths = [];
export const getLineCoincide = (path1: [any, any], path2: [any, any]) => {
let paths: string | any[] = [];
const [vectorA, vectorB] = path1;
const [vectorC, vectorD] = path2;
@ -125,10 +168,10 @@ export const getLineCoincide = (path1, path2) => {
};
// 计算与纬线的角度,正方向向上
const calcAng = (point, p) => (Math.atan2(point[1] - p[1], point[0] - p[0]) * 180) / Math.PI + 180;
const calcAng = (point: number[], p: number[]) => (Math.atan2(point[1] - p[1], point[0] - p[0]) * 180) / Math.PI + 180;
// 多段折线转polygon,直线左右默认范围50米
export const lineToPoly = (geojson, r = 50) => {
export const lineToPoly = (geojson: { geometry: { coordinates: string | any[] } }, r = 50) => {
const linesPolygon = [];
const circlePolygon = [];
for (let i = 0; i < geojson.geometry.coordinates.length - 1; i++) {
@ -160,3 +203,35 @@ export const lineToPoly = (geojson, r = 50) => {
}
return _union;
};
/**
* @description node node
* @param {string} className
* @param {node} container node
* @param {string} code data-code
* @param {func} fn click
* @returns {node}
*/
export function createNode(
node: keyof HTMLElementTagNameMap,
className: string,
textContent: string,
container: HTMLElement,
code?: string,
fn?: EventListener
): HTMLElement {
let a = document.createElement(node)
a.className = className
a.textContent = textContent
if (code) a.setAttribute('data-code', code)
if (fn) a.addEventListener('click', fn)
container.appendChild(a)
return a
}
export function removeNode(node: { remove: () => void; removeEventListener: (arg0: string, arg1: any) => void; }, fn: any) {
node.remove()
if (fn) node.removeEventListener('click', fn)
}

View File

@ -0,0 +1,107 @@
import React, { useContext, useEffect, useState } from 'react';
import VerticalAlignTopOutlined from '@ant-design/icons/VerticalAlignTopOutlined';
import classNames from 'classnames';
import CSSMotion from 'rc-motion';
import { composeRef } from 'rc-util/lib/ref';
import getScroll from '../_util/getScroll';
import scrollTo from '../_util/scrollTo';
import throttleByAnimationFrame from '../_util/throttleByAnimationFrame';
import type { ConfigConsumerProps } from '../config-provider';
import { ConfigContext } from '../config-provider';
import FloatButtonGroupContext from './context';
import FloatButton, { floatButtonPrefixCls } from './FloatButton';
import type {
BackTopProps,
FloatButtonElement,
FloatButtonProps,
FloatButtonRef,
FloatButtonShape,
} from './interface';
const BackTop = React.forwardRef<FloatButtonRef, BackTopProps>((props, ref) => {
const {
prefixCls: customizePrefixCls,
className,
type = 'default',
shape = 'circle',
visibilityHeight = 400,
icon = <VerticalAlignTopOutlined />,
target,
onClick,
duration = 450,
...restProps
} = props;
const [visible, setVisible] = useState<boolean>(visibilityHeight === 0);
const internalRef = React.useRef<FloatButtonRef['nativeElement']>(null);
React.useImperativeHandle(ref, () => ({
nativeElement: internalRef.current,
}));
const getDefaultTarget = (): HTMLElement | Document | Window =>
internalRef.current && internalRef.current.ownerDocument
? internalRef.current.ownerDocument
: window;
const handleScroll = throttleByAnimationFrame(
(e: React.UIEvent<HTMLElement, UIEvent> | { target: any }) => {
const scrollTop = getScroll(e.target, true);
setVisible(scrollTop >= visibilityHeight);
},
);
useEffect(() => {
const getTarget = target || getDefaultTarget;
const container = getTarget();
handleScroll({ target: container });
container?.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
container?.removeEventListener('scroll', handleScroll);
};
}, [target]);
const scrollToTop: React.MouseEventHandler<FloatButtonElement> = (e) => {
scrollTo(0, { getContainer: target || getDefaultTarget, duration });
onClick?.(e);
};
const { getPrefixCls } = useContext<ConfigConsumerProps>(ConfigContext);
const prefixCls = getPrefixCls(floatButtonPrefixCls, customizePrefixCls);
const rootPrefixCls = getPrefixCls();
const groupShape = useContext<FloatButtonShape | undefined>(FloatButtonGroupContext);
const mergedShape = groupShape || shape;
const contentProps: FloatButtonProps = {
prefixCls,
icon,
type,
shape: mergedShape,
...restProps,
};
return (
<CSSMotion visible={visible} motionName={`${rootPrefixCls}-fade`}>
{({ className: motionClassName }, setRef) => (
<FloatButton
ref={composeRef(internalRef, setRef)}
{...contentProps}
onClick={scrollToTop}
className={classNames(className, motionClassName)}
/>
)}
</CSSMotion>
);
});
if (process.env.NODE_ENV !== 'production') {
BackTop.displayName = 'BackTop';
}
export default BackTop;

View File

@ -0,0 +1,126 @@
import React, { useContext, useMemo } from 'react';
import classNames from 'classnames';
import omit from 'rc-util/lib/omit';
import { devUseWarning } from '../_util/warning';
import Badge from '../badge';
import type { ConfigConsumerProps } from '../config-provider';
import { ConfigContext } from '../config-provider';
import useCSSVarCls from '../config-provider/hooks/useCSSVarCls';
import Tooltip from '../tooltip';
import type BackTop from './BackTop';
import FloatButtonGroupContext from './context';
import Content from './FloatButtonContent';
import type FloatButtonGroup from './FloatButtonGroup';
import type {
FloatButtonBadgeProps,
FloatButtonContentProps,
FloatButtonElement,
FloatButtonProps,
FloatButtonShape,
} from './interface';
import type PurePanel from './PurePanel';
import useStyle from './style';
export const floatButtonPrefixCls = 'float-btn';
const InternalFloatButton = React.forwardRef<FloatButtonElement, FloatButtonProps>((props, ref) => {
const {
prefixCls: customizePrefixCls,
className,
rootClassName,
type = 'default',
shape = 'circle',
icon,
description,
tooltip,
badge = {},
...restProps
} = props;
const { getPrefixCls, direction } = useContext<ConfigConsumerProps>(ConfigContext);
const groupShape = useContext<FloatButtonShape | undefined>(FloatButtonGroupContext);
const prefixCls = getPrefixCls(floatButtonPrefixCls, customizePrefixCls);
const rootCls = useCSSVarCls(prefixCls);
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls, rootCls);
const mergedShape = groupShape || shape;
const classString = classNames(
hashId,
cssVarCls,
rootCls,
prefixCls,
className,
rootClassName,
`${prefixCls}-${type}`,
`${prefixCls}-${mergedShape}`,
{
[`${prefixCls}-rtl`]: direction === 'rtl',
},
);
// 虽然在 ts 中已经 omit 过了,但是为了防止多余的属性被透传进来,这里再 omit 一遍,以防万一
const badgeProps = useMemo<FloatButtonBadgeProps>(
() => omit(badge, ['title', 'children', 'status', 'text'] as any[]),
[badge],
);
const contentProps = useMemo<FloatButtonContentProps>(
() => ({ prefixCls, description, icon, type }),
[prefixCls, description, icon, type],
);
let buttonNode = (
<div className={`${prefixCls}-body`}>
<Content {...contentProps} />
</div>
);
if ('badge' in props) {
buttonNode = <Badge {...badgeProps}>{buttonNode}</Badge>;
}
if ('tooltip' in props) {
buttonNode = (
<Tooltip title={tooltip} placement={direction === 'rtl' ? 'right' : 'left'}>
{buttonNode}
</Tooltip>
);
}
if (process.env.NODE_ENV !== 'production') {
const warning = devUseWarning('FloatButton');
warning(
!(shape === 'circle' && description),
'usage',
'supported only when `shape` is `square`. Due to narrow space for text, short sentence is recommended.',
);
}
return wrapCSSVar(
props.href ? (
<a ref={ref} {...restProps} className={classString}>
{buttonNode}
</a>
) : (
<button ref={ref} {...restProps} className={classString} type="button">
{buttonNode}
</button>
),
);
});
type CompoundedComponent = typeof InternalFloatButton & {
Group: typeof FloatButtonGroup;
BackTop: typeof BackTop;
_InternalPanelDoNotUseOrYouWillBeFired: typeof PurePanel;
};
const FloatButton = InternalFloatButton as CompoundedComponent;
if (process.env.NODE_ENV !== 'production') {
FloatButton.displayName = 'FloatButton';
}
export default FloatButton;

View File

@ -0,0 +1,34 @@
import React, { memo } from 'react';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import classNames from 'classnames';
import type { FloatButtonContentProps } from './interface';
const FloatButtonContent: React.FC<FloatButtonContentProps> = (props) => {
const { icon, description, prefixCls, className } = props;
const defaultElement = (
<div className={`${prefixCls}-icon`}>
<FileTextOutlined />
</div>
);
return (
<div
onClick={props.onClick}
onFocus={props.onFocus}
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
className={classNames(className, `${prefixCls}-content`)}
>
{icon || description ? (
<>
{icon && <div className={`${prefixCls}-icon`}>{icon}</div>}
{description && <div className={`${prefixCls}-description`}>{description}</div>}
</>
) : (
defaultElement
)}
</div>
);
};
export default memo(FloatButtonContent);

View File

@ -0,0 +1,141 @@
import React, { memo, useCallback, useContext, useEffect } from 'react';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import classNames from 'classnames';
import CSSMotion from 'rc-motion';
import useMergedState from 'rc-util/lib/hooks/useMergedState';
import { devUseWarning } from '../_util/warning';
import type { ConfigConsumerProps } from '../config-provider';
import { ConfigContext } from '../config-provider';
import useCSSVarCls from '../config-provider/hooks/useCSSVarCls';
import { FloatButtonGroupProvider } from './context';
import FloatButton, { floatButtonPrefixCls } from './FloatButton';
import type { FloatButtonGroupProps, FloatButtonRef } from './interface';
import useStyle from './style';
const FloatButtonGroup: React.FC<FloatButtonGroupProps> = (props) => {
const {
prefixCls: customizePrefixCls,
className,
style,
shape = 'circle',
type = 'default',
icon = <FileTextOutlined />,
closeIcon,
description,
trigger,
children,
onOpenChange,
open: customOpen,
...floatButtonProps
} = props;
const { direction, getPrefixCls, floatButtonGroup } =
useContext<ConfigConsumerProps>(ConfigContext);
const mergedCloseIcon = closeIcon ?? floatButtonGroup?.closeIcon ?? <CloseOutlined />;
const prefixCls = getPrefixCls(floatButtonPrefixCls, customizePrefixCls);
const rootCls = useCSSVarCls(prefixCls);
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls, rootCls);
const groupPrefixCls = `${prefixCls}-group`;
const groupCls = classNames(groupPrefixCls, hashId, cssVarCls, rootCls, className, {
[`${groupPrefixCls}-rtl`]: direction === 'rtl',
[`${groupPrefixCls}-${shape}`]: shape,
[`${groupPrefixCls}-${shape}-shadow`]: !trigger,
});
const wrapperCls = classNames(hashId, `${groupPrefixCls}-wrap`);
const [open, setOpen] = useMergedState(false, { value: customOpen });
const floatButtonGroupRef = React.useRef<HTMLDivElement>(null);
const floatButtonRef = React.useRef<FloatButtonRef['nativeElement']>(null);
const hoverAction = React.useMemo<React.DOMAttributes<HTMLDivElement>>(() => {
const hoverTypeAction = {
onMouseEnter() {
setOpen(true);
onOpenChange?.(true);
},
onMouseLeave() {
setOpen(false);
onOpenChange?.(false);
},
};
return trigger === 'hover' ? hoverTypeAction : {};
}, [trigger]);
const handleOpenChange = () => {
setOpen((prevState) => {
onOpenChange?.(!prevState);
return !prevState;
});
};
const onClick = useCallback(
(e: MouseEvent) => {
if (floatButtonGroupRef.current?.contains(e.target as Node)) {
if (floatButtonRef.current?.contains(e.target as Node)) {
handleOpenChange();
}
return;
}
setOpen(false);
onOpenChange?.(false);
},
[trigger],
);
useEffect(() => {
if (trigger === 'click') {
document.addEventListener('click', onClick);
return () => {
document.removeEventListener('click', onClick);
};
}
}, [trigger]);
// =================== Warning =====================
if (process.env.NODE_ENV !== 'production') {
const warning = devUseWarning('FloatButton.Group');
warning(
!('open' in props) || !!trigger,
'usage',
'`open` need to be used together with `trigger`',
);
}
return wrapCSSVar(
<FloatButtonGroupProvider value={shape}>
<div ref={floatButtonGroupRef} className={groupCls} style={style} {...hoverAction}>
{trigger && ['click', 'hover'].includes(trigger) ? (
<>
<CSSMotion visible={open} motionName={`${groupPrefixCls}-wrap`}>
{({ className: motionClassName }) => (
<div className={classNames(motionClassName, wrapperCls)}>{children}</div>
)}
</CSSMotion>
<FloatButton
ref={floatButtonRef}
type={type}
shape={shape}
icon={open ? mergedCloseIcon : icon}
description={description}
aria-label={props['aria-label']}
{...floatButtonProps}
/>
</>
) : (
children
)}
</div>
</FloatButtonGroupProvider>,
);
};
export default memo(FloatButtonGroup);

View File

@ -0,0 +1,46 @@
/* eslint-disable react/no-array-index-key */
import * as React from 'react';
import classNames from 'classnames';
import { ConfigContext } from '../config-provider';
import BackTop from './BackTop';
import FloatButton, { floatButtonPrefixCls } from './FloatButton';
import FloatButtonGroup from './FloatButtonGroup';
import type { FloatButtonGroupProps, FloatButtonProps } from './interface';
export interface PureFloatButtonProps extends Omit<FloatButtonProps, 'target'> {
backTop?: boolean;
}
export interface PurePanelProps
extends PureFloatButtonProps,
Omit<FloatButtonGroupProps, 'children'> {
/** Convert to FloatGroup when configured */
items?: PureFloatButtonProps[];
}
const PureFloatButton: React.FC<PureFloatButtonProps> = ({ backTop, ...props }) =>
backTop ? <BackTop {...props} visibilityHeight={0} /> : <FloatButton {...props} />;
/** @private Internal Component. Do not use in your production. */
const PurePanel: React.FC<PurePanelProps> = ({ className, items, ...props }) => {
const { prefixCls: customizePrefixCls } = props;
const { getPrefixCls } = React.useContext(ConfigContext);
const prefixCls = getPrefixCls(floatButtonPrefixCls, customizePrefixCls);
const pureCls = `${prefixCls}-pure`;
if (items) {
return (
<FloatButtonGroup className={classNames(className, pureCls)} {...props}>
{items.map((item, index) => (
<PureFloatButton key={index} {...item} />
))}
</FloatButtonGroup>
);
}
return <PureFloatButton className={classNames(className, pureCls)} {...props} />;
};
export default PurePanel;

View File

@ -0,0 +1,9 @@
import React from 'react';
import type { FloatButtonShape } from './interface';
const FloatButtonGroupContext = React.createContext<FloatButtonShape | undefined>(undefined);
export const { Provider: FloatButtonGroupProvider } = FloatButtonGroupContext;
export default FloatButtonGroupContext;

View File

@ -0,0 +1,7 @@
## zh-CN
返回页面顶部的操作按钮。
## en-US
`BackTop` makes it easy to go back to the top of the page.

View File

@ -0,0 +1,17 @@
import React from 'react';
import { FloatButton } from 'antd';
const App: React.FC = () => (
<div style={{ height: '300vh', padding: 10 }}>
<div>Scroll to bottom</div>
<div>Scroll to bottom</div>
<div>Scroll to bottom</div>
<div>Scroll to bottom</div>
<div>Scroll to bottom</div>
<div>Scroll to bottom</div>
<div>Scroll to bottom</div>
<FloatButton.BackTop />
</div>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
调试使用。
## en-US
debug use.

View File

@ -0,0 +1,24 @@
import React, { useState } from 'react';
import { ConfigProvider, FloatButton, Slider } from 'antd';
import type { ConfigProviderProps, GetProp } from 'antd';
type AliasToken = GetProp<ConfigProviderProps, 'theme'>['token'];
const App: React.FC = () => {
const [radius, setRadius] = useState<number>(0);
const token: Partial<AliasToken> = {
borderRadius: radius,
};
return (
<>
<Slider min={0} max={20} style={{ margin: 16 }} onChange={setRadius} />
<ConfigProvider theme={{ token }}>
<FloatButton shape="square" badge={{ dot: true }} />
</ConfigProvider>
</>
);
};
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
右上角附带圆形徽标数字的悬浮按钮。
## en-US
FloatButton with Badge.

View File

@ -0,0 +1,24 @@
import React from 'react';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { FloatButton } from 'antd';
const App: React.FC = () => (
<>
<FloatButton shape="circle" badge={{ dot: true }} style={{ right: 24 + 70 + 70 }} />
<FloatButton.Group shape="circle" style={{ right: 24 + 70 }}>
<FloatButton
href="https://ant.design/index-cn"
tooltip={<div>custom badge color</div>}
badge={{ count: 5, color: 'blue' }}
/>
<FloatButton badge={{ count: 5 }} />
</FloatButton.Group>
<FloatButton.Group shape="circle">
<FloatButton badge={{ count: 12 }} icon={<QuestionCircleOutlined />} />
<FloatButton badge={{ count: 123, overflowCount: 999 }} />
<FloatButton.BackTop visibilityHeight={0} />
</FloatButton.Group>
</>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
最简单的用法。
## en-US
The most basic usage.

View File

@ -0,0 +1,6 @@
import React from 'react';
import { FloatButton } from 'antd';
const App: React.FC = () => <FloatButton onClick={() => console.log('onClick')} />;
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
通过 `open` 设置组件为受控模式,需要配合 trigger 一起使用。
## en-US
Set the component to controlled mode through `open`, which need to be used together with trigger.

View File

@ -0,0 +1,28 @@
import React, { useState } from 'react';
import { CommentOutlined, CustomerServiceOutlined } from '@ant-design/icons';
import { FloatButton, Switch } from 'antd';
const App: React.FC = () => {
const [open, setOpen] = useState(true);
const onChange = (checked: boolean) => {
setOpen(checked);
};
return (
<>
<FloatButton.Group
open={open}
trigger="click"
style={{ right: 24 }}
icon={<CustomerServiceOutlined />}
>
<FloatButton />
<FloatButton icon={<CommentOutlined />} />
</FloatButton.Group>
<Switch onChange={onChange} checked={open} style={{ margin: 16 }} />
</>
);
};
export default App;

View File

@ -0,0 +1,11 @@
## zh-CN
可以通过 `description` 设置文字内容。
> 仅当 `shape` 属性为 `square` 时支持。由于空间较小,推荐使用比较精简的双数文字。
## en-US
Setting `description` prop to show FloatButton with description.
> supported only when `shape` is `square`. Due to narrow space for text, short sentence is recommended.

View File

@ -0,0 +1,23 @@
import React from 'react';
import { FileTextOutlined } from '@ant-design/icons';
import { FloatButton } from 'antd';
const App: React.FC = () => (
<>
<FloatButton
icon={<FileTextOutlined />}
description="HELP INFO"
shape="square"
style={{ right: 24 }}
/>
<FloatButton description="HELP INFO" shape="square" style={{ right: 94 }} />
<FloatButton
icon={<FileTextOutlined />}
description="HELP"
shape="square"
style={{ right: 164 }}
/>
</>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
设置 `trigger` 属性即可开启菜单模式。提供 `hover``click` 两种触发方式。
## en-US
Open menu mode with `trigger`, which could be `hover` or `click`.

View File

@ -0,0 +1,28 @@
import React from 'react';
import { CommentOutlined, CustomerServiceOutlined } from '@ant-design/icons';
import { FloatButton } from 'antd';
const App: React.FC = () => (
<>
<FloatButton.Group
trigger="click"
type="primary"
style={{ right: 24 }}
icon={<CustomerServiceOutlined />}
>
<FloatButton />
<FloatButton icon={<CommentOutlined />} />
</FloatButton.Group>
<FloatButton.Group
trigger="hover"
type="primary"
style={{ right: 94 }}
icon={<CustomerServiceOutlined />}
>
<FloatButton />
<FloatButton icon={<CommentOutlined />} />
</FloatButton.Group>
</>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
按钮组合使用时,推荐使用 `<FloatButton.Group />`,并通过设置 `shape` 属性改变悬浮按钮组的形状。悬浮按钮组的 `shape` 会覆盖内部 FloatButton 的 `shape` 属性。
## en-US
When multiple buttons are used together, `<FloatButton.Group />` is recommended. By setting `shape` of FloatButton.Group, you can change the shape of group. `shape` of FloatButton.Group will override `shape` of FloatButton inside.

View File

@ -0,0 +1,21 @@
import React from 'react';
import { QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { FloatButton } from 'antd';
const App: React.FC = () => (
<>
<FloatButton.Group shape="circle" style={{ right: 24 }}>
<FloatButton icon={<QuestionCircleOutlined />} />
<FloatButton />
<FloatButton.BackTop visibilityHeight={0} />
</FloatButton.Group>
<FloatButton.Group shape="square" style={{ right: 94 }}>
<FloatButton icon={<QuestionCircleOutlined />} />
<FloatButton />
<FloatButton icon={<SyncOutlined />} />
<FloatButton.BackTop visibilityHeight={0} />
</FloatButton.Group>
</>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
调试用组件,请勿直接使用。
## en-US
Debug usage. Do not use in your production.

View File

@ -0,0 +1,39 @@
import React from 'react';
import { CustomerServiceOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { FloatButton } from 'antd';
/** Test usage. Do not use in your production. */
const { _InternalPanelDoNotUseOrYouWillBeFired: InternalFloatButton } = FloatButton;
const App: React.FC = () => (
<div style={{ display: 'flex', columnGap: 16, alignItems: 'center' }}>
<InternalFloatButton backTop />
<InternalFloatButton icon={<CustomerServiceOutlined />} />
<InternalFloatButton
icon={<QuestionCircleOutlined />}
description="HELP"
shape="square"
type="primary"
/>
<InternalFloatButton
shape="square"
items={[
{ icon: <QuestionCircleOutlined /> },
{ icon: <CustomerServiceOutlined /> },
{ icon: <SyncOutlined /> },
]}
/>
<InternalFloatButton
open
icon={<CustomerServiceOutlined />}
trigger="click"
items={[
{ icon: <QuestionCircleOutlined /> },
{ icon: <CustomerServiceOutlined /> },
{ icon: <SyncOutlined /> },
]}
/>
</div>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
通过 `shape` 设置不同的形状。
## en-US
Change the shape of the FloatButton with `shape`.

View File

@ -0,0 +1,22 @@
import React from 'react';
import { CustomerServiceOutlined } from '@ant-design/icons';
import { FloatButton } from 'antd';
const App: React.FC = () => (
<>
<FloatButton
shape="circle"
type="primary"
style={{ right: 94 }}
icon={<CustomerServiceOutlined />}
/>
<FloatButton
shape="square"
type="primary"
style={{ right: 24 }}
icon={<CustomerServiceOutlined />}
/>
</>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
设置 tooltip 属性,即可开启气泡卡片。
## en-US
Setting `tooltip` prop to show FloatButton with tooltip.

View File

@ -0,0 +1,6 @@
import React from 'react';
import { FloatButton } from 'antd';
const App: React.FC = () => <FloatButton tooltip={<div>Documents</div>} />;
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
通过 `type` 改变悬浮按钮的类型。
## en-US
Change the type of the FloatButton with `type`.

View File

@ -0,0 +1,12 @@
import React from 'react';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { FloatButton } from 'antd';
const App: React.FC = () => (
<>
<FloatButton icon={<QuestionCircleOutlined />} type="primary" style={{ right: 24 }} />
<FloatButton icon={<QuestionCircleOutlined />} type="default" style={{ right: 94 }} />
</>
);
export default App;

View File

@ -0,0 +1,10 @@
import BackTop from './BackTop';
import FloatButton from './FloatButton';
import FloatButtonGroup from './FloatButtonGroup';
import PurePanel from './PurePanel';
FloatButton.BackTop = BackTop;
FloatButton.Group = FloatButtonGroup;
FloatButton._InternalPanelDoNotUseOrYouWillBeFired = PurePanel;
export default FloatButton;

View File

@ -0,0 +1,71 @@
---
category: Components
group: 通用
title: FloatButton
subtitle: 悬浮按钮
toc: content
description: 悬浮于页面上方的按钮。
demo:
cols: 2
tag: 5.0.0
---
## 何时使用
- 用于网站上的全局功能;
- 无论浏览到何处都可以看见的按钮。
## 代码演示
<!-- prettier-ignore -->
<code src="./demo/basic.tsx" iframe="360">基本</code>
<code src="./demo/type.tsx" iframe="360">类型</code>
<code src="./demo/shape.tsx" iframe="360">形状</code>
<code src="./demo/description.tsx" iframe="360">描述</code>
<code src="./demo/tooltip.tsx" iframe="360">含有气泡卡片的悬浮按钮</code>
<code src="./demo/group.tsx" iframe="360">浮动按钮组</code>
<code src="./demo/group-menu.tsx" iframe="360">菜单模式</code>
<code src="./demo/controlled.tsx" iframe="360">受控模式</code>
<code src="./demo/back-top.tsx" iframe="360">回到顶部</code>
<code src="./demo/badge.tsx" iframe="360">徽标数</code>
<code src="./demo/badge-debug.tsx" iframe="360" debug>调试小圆点使用</code>
<code src="./demo/render-panel.tsx" debug>\_InternalPanelDoNotUseOrYouWillBeFired</code>
## API
通用属性参考:[通用属性](/docs/react/common-props)
> 自 `antd@5.0.0` 版本开始提供该组件。
### 共同的 API
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| icon | 自定义图标 | ReactNode | - | |
| description | 文字及其它内容 | ReactNode | - | |
| tooltip | 气泡卡片的内容 | ReactNode \| () => ReactNode | - | |
| type | 设置按钮类型 | `default` \| `primary` | `default` | |
| shape | 设置按钮形状 | `circle` \| `square` | `circle` | |
| onClick | 点击按钮时的回调 | (event) => void | - | |
| href | 点击跳转的地址,指定此属性 button 的行为和 a 链接一致 | string | - | |
| target | 相当于 a 标签的 target 属性href 存在时生效 | string | - | |
| badge | 带徽标数字的悬浮按钮(不支持 `status` 以及相关属性) | [BadgeProps](/components/badge-cn#api) | - | 5.4.0 |
### FloatButton.Group
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| shape | 设置包含的 FloatButton 按钮形状 | `circle` \| `square` | `circle` | |
| trigger | 触发方式(有触发方式为菜单模式) | `click` \| `hover` | - | |
| open | 受控展开,需配合 trigger 一起使用 | boolean | - | |
| closeIcon | 自定义关闭按钮 | React.ReactNode | `<CloseOutlined />` | |
| onOpenChange | 展开收起时的回调,需配合 trigger 一起使用 | (open: boolean) => void | - | |
### FloatButton.BackTop
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| ---------------- | ---------------------------------- | ----------------- | ------------ | ---- |
| duration | 回到顶部所需时间ms | number | 450 | |
| target | 设置需要监听其滚动事件的元素 | () => HTMLElement | () => window | |
| visibilityHeight | 滚动高度达到此参数值才出现 BackTop | number | 400 | |
| onClick | 点击按钮的回调函数 | () => void | - | |

View File

@ -0,0 +1,66 @@
import type React from 'react';
import type { BadgeProps } from '../badge';
import type { TooltipProps } from '../tooltip';
export type FloatButtonElement = HTMLAnchorElement & HTMLButtonElement;
export interface FloatButtonRef {
nativeElement: FloatButtonElement | null;
}
export type FloatButtonType = 'default' | 'primary';
export type FloatButtonShape = 'circle' | 'square';
export type FloatButtonGroupTrigger = 'click' | 'hover';
export type FloatButtonBadgeProps = Omit<BadgeProps, 'status' | 'text' | 'title' | 'children'>;
export interface FloatButtonProps extends React.DOMAttributes<FloatButtonElement> {
prefixCls?: string;
className?: string;
rootClassName?: string;
style?: React.CSSProperties;
icon?: React.ReactNode;
description?: React.ReactNode;
type?: FloatButtonType;
shape?: FloatButtonShape;
tooltip?: TooltipProps['title'];
href?: string;
target?: React.HTMLAttributeAnchorTarget;
badge?: FloatButtonBadgeProps;
['aria-label']?: React.HtmlHTMLAttributes<HTMLElement>['aria-label'];
}
export interface FloatButtonContentProps extends React.DOMAttributes<HTMLDivElement> {
className?: string;
icon?: FloatButtonProps['icon'];
description?: FloatButtonProps['description'];
prefixCls: FloatButtonProps['prefixCls'];
}
export interface FloatButtonGroupProps extends FloatButtonProps {
// 包含的 Float Button
children: React.ReactNode;
// 触发方式 (有触发方式为菜单模式)
trigger?: FloatButtonGroupTrigger;
// 受控展开
open?: boolean;
// 关闭按钮自定义图标
closeIcon?: React.ReactNode;
// 展开收起的回调
onOpenChange?: (open: boolean) => void;
}
export interface BackTopProps extends Omit<FloatButtonProps, 'target'> {
visibilityHeight?: number;
onClick?: React.MouseEventHandler<FloatButtonElement>;
target?: () => HTMLElement | Window | Document;
prefixCls?: string;
children?: React.ReactNode;
className?: string;
rootClassName?: string;
style?: React.CSSProperties;
duration?: number;
}

View File

@ -0,0 +1,413 @@
import type { CSSObject } from '@ant-design/cssinjs';
import { Keyframes, unit } from '@ant-design/cssinjs';
import { resetComponent } from '../../style';
import { initFadeMotion } from '../../style/motion/fade';
import { initMotion } from '../../style/motion/motion';
import type { FullToken, GenerateStyle, GetDefaultToken } from '../../theme/internal';
import { genStyleHooks, mergeToken } from '../../theme/internal';
import getOffset from '../util';
/** Component only token. Which will handle additional calculation of alias token */
export interface ComponentToken {
/**
* Offset of the badge dot in a circular button
* @internal
*/
dotOffsetInCircle: number;
/**
* Offset of the badge dot in a square button
* @internal
*/
dotOffsetInSquare: number;
}
type FloatButtonToken = FullToken<'FloatButton'> & {
floatButtonColor: string;
floatButtonBackgroundColor: string;
floatButtonHoverBackgroundColor: string;
floatButtonFontSize: number;
floatButtonSize: number;
floatButtonIconSize: number | string;
floatButtonBodySize: number | string;
floatButtonBodyPadding: number;
badgeOffset: number | string;
// Position
floatButtonInsetBlockEnd: number;
floatButtonInsetInlineEnd: number;
};
const initFloatButtonGroupMotion = (token: FloatButtonToken) => {
const { componentCls, floatButtonSize, motionDurationSlow, motionEaseInOutCirc } = token;
const groupPrefixCls = `${componentCls}-group`;
const moveDownIn = new Keyframes('antFloatButtonMoveDownIn', {
'0%': {
transform: `translate3d(0, ${unit(floatButtonSize)}, 0)`,
transformOrigin: '0 0',
opacity: 0,
},
'100%': {
transform: 'translate3d(0, 0, 0)',
transformOrigin: '0 0',
opacity: 1,
},
});
const moveDownOut = new Keyframes('antFloatButtonMoveDownOut', {
'0%': {
transform: 'translate3d(0, 0, 0)',
transformOrigin: '0 0',
opacity: 1,
},
'100%': {
transform: `translate3d(0, ${unit(floatButtonSize)}, 0)`,
transformOrigin: '0 0',
opacity: 0,
},
});
return [
{
[`${groupPrefixCls}-wrap`]: {
...initMotion(`${groupPrefixCls}-wrap`, moveDownIn, moveDownOut, motionDurationSlow, true),
},
},
{
[`${groupPrefixCls}-wrap`]: {
[`
&${groupPrefixCls}-wrap-enter,
&${groupPrefixCls}-wrap-appear
`]: {
opacity: 0,
animationTimingFunction: motionEaseInOutCirc,
},
[`&${groupPrefixCls}-wrap-leave`]: {
animationTimingFunction: motionEaseInOutCirc,
},
},
},
];
};
// ============================== Group ==============================
const floatButtonGroupStyle: GenerateStyle<FloatButtonToken, CSSObject> = (token) => {
const {
antCls,
componentCls,
floatButtonSize,
margin,
borderRadiusLG,
borderRadiusSM,
badgeOffset,
floatButtonBodyPadding,
calc,
} = token;
const groupPrefixCls = `${componentCls}-group`;
return {
[groupPrefixCls]: {
...resetComponent(token),
zIndex: 99,
display: 'block',
border: 'none',
position: 'fixed',
width: floatButtonSize,
height: 'auto',
boxShadow: 'none',
minHeight: floatButtonSize,
insetInlineEnd: token.floatButtonInsetInlineEnd,
insetBlockEnd: token.floatButtonInsetBlockEnd,
borderRadius: borderRadiusLG,
[`${groupPrefixCls}-wrap`]: {
zIndex: -1,
display: 'block',
position: 'relative',
marginBottom: margin,
},
[`&${groupPrefixCls}-rtl`]: {
direction: 'rtl',
},
[componentCls]: {
position: 'static',
},
},
[`${groupPrefixCls}-circle`]: {
[`${componentCls}-circle:not(:last-child)`]: {
marginBottom: token.margin,
[`${componentCls}-body`]: {
width: floatButtonSize,
height: floatButtonSize,
borderRadius: '50%',
},
},
},
[`${groupPrefixCls}-square`]: {
[`${componentCls}-square`]: {
borderRadius: 0,
padding: 0,
'&:first-child': {
borderStartStartRadius: borderRadiusLG,
borderStartEndRadius: borderRadiusLG,
},
'&:last-child': {
borderEndStartRadius: borderRadiusLG,
borderEndEndRadius: borderRadiusLG,
},
'&:not(:last-child)': {
borderBottom: `${unit(token.lineWidth)} ${token.lineType} ${token.colorSplit}`,
},
[`${antCls}-badge`]: {
[`${antCls}-badge-count`]: {
top: calc(calc(floatButtonBodyPadding).add(badgeOffset)).mul(-1).equal(),
insetInlineEnd: calc(calc(floatButtonBodyPadding).add(badgeOffset)).mul(-1).equal(),
},
},
},
[`${groupPrefixCls}-wrap`]: {
display: 'block',
borderRadius: borderRadiusLG,
boxShadow: token.boxShadowSecondary,
[`${componentCls}-square`]: {
boxShadow: 'none',
marginTop: 0,
borderRadius: 0,
padding: floatButtonBodyPadding,
'&:first-child': {
borderStartStartRadius: borderRadiusLG,
borderStartEndRadius: borderRadiusLG,
},
'&:last-child': {
borderEndStartRadius: borderRadiusLG,
borderEndEndRadius: borderRadiusLG,
},
'&:not(:last-child)': {
borderBottom: `${unit(token.lineWidth)} ${token.lineType} ${token.colorSplit}`,
},
[`${componentCls}-body`]: {
width: token.floatButtonBodySize,
height: token.floatButtonBodySize,
},
},
},
},
[`${groupPrefixCls}-circle-shadow`]: {
boxShadow: 'none',
},
[`${groupPrefixCls}-square-shadow`]: {
boxShadow: token.boxShadowSecondary,
[`${componentCls}-square`]: {
boxShadow: 'none',
padding: floatButtonBodyPadding,
[`${componentCls}-body`]: {
width: token.floatButtonBodySize,
height: token.floatButtonBodySize,
borderRadius: borderRadiusSM,
},
},
},
};
};
// ============================== Shared ==============================
const sharedFloatButtonStyle: GenerateStyle<FloatButtonToken, CSSObject> = (token) => {
const {
antCls,
componentCls,
floatButtonBodyPadding,
floatButtonIconSize,
floatButtonSize,
borderRadiusLG,
badgeOffset,
dotOffsetInSquare,
dotOffsetInCircle,
calc,
} = token;
return {
[componentCls]: {
...resetComponent(token),
border: 'none',
position: 'fixed',
cursor: 'pointer',
zIndex: 99,
// Do not remove the 'display: block' here.
// Deleting it will cause marginBottom to become ineffective.
// Ref: https://github.com/ant-design/ant-design/issues/44700
display: 'block',
width: floatButtonSize,
height: floatButtonSize,
insetInlineEnd: token.floatButtonInsetInlineEnd,
insetBlockEnd: token.floatButtonInsetBlockEnd,
boxShadow: token.boxShadowSecondary,
// Pure Panel
'&-pure': {
position: 'relative',
inset: 'auto',
},
'&:empty': {
display: 'none',
},
[`${antCls}-badge`]: {
width: '100%',
height: '100%',
[`${antCls}-badge-count`]: {
transform: 'translate(0, 0)',
transformOrigin: 'center',
top: calc(badgeOffset).mul(-1).equal(),
insetInlineEnd: calc(badgeOffset).mul(-1).equal(),
},
},
[`${componentCls}-body`]: {
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: `all ${token.motionDurationMid}`,
[`${componentCls}-content`]: {
overflow: 'hidden',
textAlign: 'center',
minHeight: floatButtonSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: `${unit(calc(floatButtonBodyPadding).div(2).equal())} ${unit(
floatButtonBodyPadding,
)}`,
[`${componentCls}-icon`]: {
textAlign: 'center',
margin: 'auto',
width: floatButtonIconSize,
fontSize: floatButtonIconSize,
lineHeight: 1,
},
},
},
},
[`${componentCls}-rtl`]: {
direction: 'rtl',
},
[`${componentCls}-circle`]: {
height: floatButtonSize,
borderRadius: '50%',
[`${antCls}-badge`]: {
[`${antCls}-badge-dot`]: {
top: dotOffsetInCircle,
insetInlineEnd: dotOffsetInCircle,
},
},
[`${componentCls}-body`]: {
borderRadius: '50%',
},
},
[`${componentCls}-square`]: {
height: 'auto',
minHeight: floatButtonSize,
borderRadius: borderRadiusLG,
[`${antCls}-badge`]: {
[`${antCls}-badge-dot`]: {
top: dotOffsetInSquare,
insetInlineEnd: dotOffsetInSquare,
},
},
[`${componentCls}-body`]: {
height: 'auto',
borderRadius: borderRadiusLG,
},
},
[`${componentCls}-default`]: {
backgroundColor: token.floatButtonBackgroundColor,
transition: `background-color ${token.motionDurationMid}`,
[`${componentCls}-body`]: {
backgroundColor: token.floatButtonBackgroundColor,
transition: `background-color ${token.motionDurationMid}`,
'&:hover': {
backgroundColor: token.colorFillContent,
},
[`${componentCls}-content`]: {
[`${componentCls}-icon`]: {
color: token.colorText,
},
[`${componentCls}-description`]: {
display: 'flex',
alignItems: 'center',
lineHeight: unit(token.fontSizeLG),
color: token.colorText,
fontSize: token.fontSizeSM,
},
},
},
},
[`${componentCls}-primary`]: {
backgroundColor: token.colorPrimary,
[`${componentCls}-body`]: {
backgroundColor: token.colorPrimary,
transition: `background-color ${token.motionDurationMid}`,
'&:hover': {
backgroundColor: token.colorPrimaryHover,
},
[`${componentCls}-content`]: {
[`${componentCls}-icon`]: {
color: token.colorTextLightSolid,
},
[`${componentCls}-description`]: {
display: 'flex',
alignItems: 'center',
lineHeight: unit(token.fontSizeLG),
color: token.colorTextLightSolid,
fontSize: token.fontSizeSM,
},
},
},
},
};
};
// ============================== Export ==============================
export const prepareComponentToken: GetDefaultToken<'FloatButton'> = (token) => ({
dotOffsetInCircle: getOffset(token.controlHeightLG / 2),
dotOffsetInSquare: getOffset(token.borderRadiusLG),
});
export default genStyleHooks(
'FloatButton',
(token) => {
const {
colorTextLightSolid,
colorBgElevated,
controlHeightLG,
marginXXL,
marginLG,
fontSize,
fontSizeIcon,
controlItemBgHover,
paddingXXS,
calc,
} = token;
const floatButtonToken = mergeToken<FloatButtonToken>(token, {
floatButtonBackgroundColor: colorBgElevated,
floatButtonColor: colorTextLightSolid,
floatButtonHoverBackgroundColor: controlItemBgHover,
floatButtonFontSize: fontSize,
floatButtonIconSize: calc(fontSizeIcon).mul(1.5).equal(),
floatButtonSize: controlHeightLG,
floatButtonInsetBlockEnd: marginXXL,
floatButtonInsetInlineEnd: marginLG,
floatButtonBodySize: calc(controlHeightLG).sub(calc(paddingXXS).mul(2)).equal(),
// 这里的 paddingXXS 是简写,完整逻辑是 (controlHeightLG - (controlHeightLG - paddingXXS * 2)) / 2,
floatButtonBodyPadding: paddingXXS,
badgeOffset: calc(paddingXXS).mul(1.5).equal(),
});
return [
floatButtonGroupStyle(floatButtonToken),
sharedFloatButtonStyle(floatButtonToken),
initFadeMotion(token),
initFloatButtonGroupMotion(floatButtonToken),
];
},
prepareComponentToken,
);

View File

@ -0,0 +1,10 @@
const getOffset = (radius: number): number => {
if (radius === 0) {
return 0;
}
// 如果要考虑通用性,这里应该用三角函数 Math.sin(45)
// 但是这个场景比较特殊,始终是等腰直角三角形,所以直接用 Math.sqrt() 开方即可
return radius - Math.sqrt(radius ** 2 / 2);
};
export default getOffset;

View File

@ -16,10 +16,16 @@ export { default as VideoPlayer } from './VideoPlayer'
export type { VideoViewProps, VideoViewRef } from './VideoPlayer'
export { default as Tabs } from './tabs'
export type { TabPaneProps, TabsProps } from './tabs';
export { default as Button } from './button'
export { default as message } from './message'
export { default as Button } from './button'
export type { ArgsProps } from './message'
export type { ButtonProps, ButtonGroupProps } from './button';
export { default as FloatButton } from './float-button';
export type {
FloatButtonGroupProps,
FloatButtonProps,
FloatButtonRef,
} from './float-button/interface';
export { default as Space } from './space'
export { default as Slider } from './slider'
export type { SliderBaseProps, SliderMarks, SliderRangeProps, SliderSingleProps, SliderTooltipProps } from './slider';