├── .dumirc.ts
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .prettierignore
├── .prettierrc.js
├── LICENSE
├── README.md
├── babel.config.js
├── commitlint.config.js
├── docs
└── index.md
├── package.json
├── rollup.config.mjs
├── src
├── ControlBar
│ └── index.tsx
├── Jigsaw
│ ├── LoadingBox.tsx
│ └── index.tsx
├── SliderButton
│ └── index.tsx
├── SliderIcon
│ ├── IconArrowRight.tsx
│ ├── IconCheck.tsx
│ ├── IconImageFill.tsx
│ ├── IconLoading.tsx
│ ├── IconRefresh.tsx
│ ├── IconX.tsx
│ ├── SliderIconBase.tsx
│ └── index.tsx
├── demos
│ ├── actionRef.tsx
│ ├── assets
│ │ ├── 1bg@2x.jpg
│ │ ├── 1puzzle@2x.png
│ │ ├── 2bg.png
│ │ ├── 2puzzle.png
│ │ ├── 3bg.png
│ │ ├── 3puzzle.png
│ │ └── sunflower.jpg
│ ├── basic.tsx
│ ├── create-puzzle.tsx
│ ├── custom-content.module.less
│ ├── custom-content.tsx
│ ├── custom-dark.tsx
│ ├── custom-height.tsx
│ ├── custom-intl.tsx
│ ├── custom-style.tsx
│ ├── custom-styles.tsx
│ ├── dev-button.tsx
│ ├── dev-control-bar.tsx
│ ├── dev-icon.tsx
│ ├── dev-jigsaw.tsx
│ ├── error.tsx
│ ├── errors.tsx
│ ├── float.tsx
│ ├── modal.tsx
│ ├── request-failed.tsx
│ ├── service1.ts
│ ├── service2.ts
│ ├── service3.ts
│ ├── service4.ts
│ ├── size.tsx
│ ├── size2.tsx
│ ├── slider-full-width.tsx
│ └── slider.tsx
├── index.tsx
├── interface.ts
├── style
│ ├── ControlBar.less
│ ├── Jigsaw.less
│ ├── SliderButton.less
│ ├── SliderIcon.less
│ ├── config.less
│ ├── index.less
│ └── index.ts
└── utils.ts
├── stylelint.config.mjs
├── tests
├── __snapshots__
│ └── index.test.tsx.snap
├── fixtures
│ └── service1.ts
└── index.test.tsx
├── tsconfig.build.json
├── tsconfig.json
├── tsconfig.types.json
└── typings.d.ts
/.dumirc.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'dumi';
2 |
3 | const isDev = process.env.NODE_ENV === 'development';
4 | const publicPath = isDev ? '/' : '/rc-slider-captcha/';
5 |
6 | export default defineConfig({
7 | themeConfig: {
8 | name: 'rc-slider-captcha',
9 | logo: 'https://www.caijinfeng.com/logo.png',
10 | nav: [],
11 | prefersColor: {
12 | default: 'light',
13 | switch: false
14 | },
15 | footer: `
16 |
caijf | Copyright © 2022-present
17 |
18 |
`
19 | },
20 | base: publicPath,
21 | publicPath,
22 | favicons: ['https://www.caijinfeng.com/favicon.ico'],
23 | outputPath: 'docs-dist',
24 | analytics: {
25 | ga_v2: 'G-9R6Q9PDGBK'
26 | },
27 | // headScripts: [
28 | // {
29 | // src: 'https://cdn.bootcdn.net/ajax/libs/vConsole/3.13.0/vconsole.min.js'
30 | // },
31 | // {
32 | // content: 'var vConsole = new window.VConsole();'
33 | // }
34 | // ],
35 | styles: [
36 | `body .dumi-default-doc-layout {
37 | background: white;
38 | }
39 | body .dumi-default-doc-layout > main{
40 | padding-top: 24px;
41 | }
42 | body .dumi-default-header{
43 | display: none;
44 | }
45 | body .dumi-default-header-left {
46 | width: auto;
47 | }
48 | body .dumi-default-header-menu-btn{
49 | display: none;
50 | }
51 | body .dumi-default-doc-layout > main > .dumi-default-doc-layout-toc-wrapper {
52 | top: 52px;
53 | }
54 | @media screen and (max-width: 400px){
55 | body .dumi-default-previewer-demo{
56 | padding: 40px 12px;
57 | }
58 | body .dumi-default-doc-layout > main {
59 | padding: 0 12px;
60 | }
61 | }`
62 | ]
63 | });
64 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
15 | [Makefile]
16 | indent_style = tab
17 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /npm-debug.log*
4 | /yarn-error.log
5 | /yarn.lock
6 | /package-lock.json
7 |
8 | # production
9 | /es
10 | /dist
11 | /docs-dist
12 |
13 | # misc
14 | .DS_Store
15 | /coverage
16 |
17 | # umi
18 | .umi
19 | .umi-production
20 | .umi-test
21 | .env.local
22 |
23 | # ide
24 | /.vscode
25 | /.idea
26 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | jest: true
7 | },
8 | settings: {
9 | react: {
10 | version: 'detect'
11 | }
12 | },
13 | extends: [
14 | 'eslint:recommended',
15 | 'plugin:react/recommended',
16 | 'plugin:@typescript-eslint/recommended'
17 | ],
18 | parser: '@typescript-eslint/parser',
19 | parserOptions: {
20 | ecmaFeatures: {
21 | jsx: true
22 | },
23 | ecmaVersion: 12,
24 | sourceType: 'module'
25 | },
26 | plugins: ['react', 'react-hooks', '@typescript-eslint'],
27 | rules: {
28 | 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks
29 | 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies
30 | '@typescript-eslint/no-explicit-any': 0,
31 | '@typescript-eslint/ban-ts-comment': 0
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Build and Deploy Docs
2 | on:
3 | push:
4 | branches:
5 | - main
6 | paths:
7 | - docs/**
8 | - src/**
9 | - .dumirc.ts
10 | - .github/**
11 | - package.json
12 | - README.md
13 |
14 | jobs:
15 | build-and-deploy:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v3
20 | - uses: pnpm/action-setup@v2
21 | with:
22 | version: 8
23 | - uses: actions/setup-node@v3
24 | with:
25 | node-version: 16
26 | - name: Install
27 | run: pnpm install
28 | - name: Build
29 | run: pnpm build
30 | - name: Test
31 | run: pnpm test
32 | - name: Build docs
33 | run: pnpm run docs:build
34 | - name: Deploy docs
35 | uses: peaceiris/actions-gh-pages@v3
36 | with:
37 | github_token: ${{ secrets.ACCESS_TOKEN_WORKFLOW }}
38 | publish_dir: docs-dist
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /npm-debug.log*
6 | /yarn-error.log
7 | /yarn.lock
8 | /package-lock.json
9 | pnpm-lock.yaml
10 |
11 | # production
12 | /es
13 | /dist
14 | /dist-bak
15 | /docs-dist
16 | /types
17 |
18 | # misc
19 | .DS_Store
20 | /coverage
21 |
22 | # umi
23 | .dumi/tmp
24 | .dumi/tmp-test
25 | .dumi/tmp-production
26 | .env.local
27 |
28 | # ide
29 | /.vscode
30 | /.idea
31 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | npx --no -- commitlint --edit "$1"
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npx --no-install lint-staged
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | CNAME
4 | LICENSE
5 | package.json
6 | package-lock.json
7 |
8 | *.lock
9 | yarn-error.log
10 | *debug.log
11 |
12 | .gitignore
13 | .prettierignore
14 | .eslintignore
15 | .eslintcache
16 | .history
17 |
18 | *.svg
19 | *.png
20 | *.jpg
21 | *.gif
22 | *.bmp
23 |
24 | dist
25 | docs-dist
26 | .umi
27 | .umi-production
28 | .umi-test
29 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | tabWidth: 2,
4 | trailingComma: 'none',
5 | printWidth: 100,
6 | useTabs: false,
7 | semi: true,
8 | bracketSpacing: true,
9 | arrowParens: 'always',
10 | proseWrap: 'never',
11 | plugins: ['prettier-plugin-two-style-order']
12 | };
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 caijf
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # rc-slider-captcha
2 |
3 | [![npm][npm]][npm-url] 
4 |
5 | React 滑块验证码组件。
6 |
7 | [查看文档和示例][site]
8 |
9 | [][site]
10 |
11 | [掘金文章介绍地址](https://juejin.cn/post/7160519128950767652)
12 |
13 | ## 特性
14 |
15 | - 简单易用
16 | - 适配 PC 和移动端
17 | - 兼容 IE11 和现代浏览器
18 | - 使用 TypeScript 开发,提供完整的类型定义文件
19 |
20 | ## 安装
21 |
22 | ```shell
23 | npm install rc-slider-captcha
24 | ```
25 |
26 | ```shell
27 | yarn add rc-slider-captcha
28 | ```
29 |
30 | ```shell
31 | pnpm add rc-slider-captcha
32 | ```
33 |
34 | ## 使用
35 |
36 | ```typescript
37 | import SliderCaptcha from 'rc-slider-captcha';
38 |
39 | const Demo = () => {
40 | return (
41 | {
43 | return {
44 | bgUrl: 'background image url',
45 | puzzleUrl: 'puzzle image url'
46 | };
47 | }}
48 | onVerify={async (data) => {
49 | console.log(data);
50 | // verify data
51 | return Promise.resolve();
52 | }}
53 | />
54 | );
55 | };
56 | ```
57 |
58 | ## API
59 |
60 | ```typescript
61 | import SliderCaptcha, {
62 | SliderCaptchaProps,
63 | VerifyParam,
64 | Status,
65 | ActionType
66 | } from 'rc-slider-captcha';
67 | ```
68 |
69 | ### SliderCaptcha
70 |
71 | | 参数 | 说明 | 类型 | 默认值 |
72 | | --- | --- | --- | --- |
73 | | request | 请求背景图和拼图 | `() => Promise<{ bgUrl:string; puzzleUrl:string;}>` | - |
74 | | onVerify | 用户操作滑块完成后触发,主要用于验证,返回 `resolve` 表示验证成功,`reject` 表示验证失败。 | `(data: VerifyParam) => Promise` | - |
75 | | mode | 显示模式。`embed` - 嵌入式, `float` - 触发式, `slider` - 只有滑块无拼图。 | `'embed' \| 'float' \| 'slider'` | `'embed'` |
76 | | bgSize | 背景图尺寸 | `{ width: number; height: number; }` | `{ width: 320, height: 160 }` |
77 | | puzzleSize | 拼图尺寸和偏移调整,默认宽度 `60`,高度为背景图高度。 | `{ width: number; height: number; left: number; top: number; }` | `{ width: 60 }` |
78 | | tipText | 提示文本配置 | `{ default: ReactNode; loading: ReactNode; moving: ReactNode; verifying: ReactNode; success: ReactNode; error: ReactNode; errors: ReactNode; loadFailed: ReactNode; }` | - |
79 | | tipIcon | 提示图标配置 | `{ default: ReactNode; loading: ReactNode; error: ReactNode; success: ReactNode; refresh: ReactNode; loadFailed: ReactNode; }` | - |
80 | | actionRef | 常用操作,比如`刷新`。 | `React.MutableRefObject;` | - |
81 | | showRefreshIcon | 显示右上角刷新图标 | `boolean` | `true` |
82 | | limitErrorCount | 限制连续错误次数。当连续错误次数达到限制时,不允许操作滑块和刷新图标,必须手动点击操作条刷新。`0` 表示不限制错误次数。 | `number` | `0` |
83 | | jigsawContent | 拼图区域自定义内容,需要自己定义绝对定位和 zIndex 。 | `ReactNode` | - |
84 | | loadingBoxProps | 拼图区域加载配置,支持 div 属性。 | `{ icon: ReactNode; text: ReactNode }` | - |
85 | | autoRequest | 自动发起请求 | `boolean` | `true` |
86 | | autoRefreshOnError | 验证失败后自动刷新 | `boolean` | `true` |
87 | | errorHoldDuration | 错误停留多少毫秒后自动刷新,仅在 `autoRefreshOnError=true` 时生效。 | `number` | `500` |
88 | | loadingDelay | 设置 `loading` 状态延迟的时间,避免闪烁,单位为毫秒。 | `number` | `0` |
89 | | placement | 浮层位置。仅在 `mode=float` 时生效。 | `'top' \| 'bottom'` | `'top'` |
90 | | precision | 数字精度。为避免内部计算产生精度问题,只对 `onVerify` 方法参数 `x` `y` `sliderOffsetX` 生效。 | `number \| false` | `7` |
91 | | className | 容器类名 | `string` | - |
92 | | style | 容器样式 | `CSSProperties` | - |
93 | | styles | 配置内置模块样式 | `{ panel?: CSSProperties; jigsaw?: CSSProperties; bgImg?: CSSProperties; puzzleImg?: CSSProperties; control?: CSSProperties; indicator?: CSSProperties; }` | - |
94 |
95 | > 连续错误次数说明:当用户操作滑块验证成功后,将重置连续错误次数为 0 。当用户点击限制错误次数操作条刷新时也将错误次数重置为 0 。
96 |
97 | ### VerifyParam
98 |
99 | ```typescript
100 | type VerifyParam = {
101 | x: number; // 拼图 x 轴移动值。(拼图和滑块按钮移动距离不一样,这里指的是计算后的拼图移动距离。)
102 | y: number; // 用户操作按钮或拼图 y 轴移动值。(按下鼠标到释放鼠标 y 轴的差值。)
103 | sliderOffsetX: number; // 滑块 x 轴偏移值。(暂时没有什么场景会用到。)
104 | duration: number; // 操作持续时长,单位毫秒。
105 | trail: [number, number][]; // 移动轨迹
106 | targetType: 'puzzle' | 'button'; // 操作 dom 目标。 puzzle-拼图 button-滑块按钮。
107 | errorCount: number; // 连续错误次数
108 | };
109 | ```
110 |
111 | 如果对安全比较重视的,可以通过 `y` `duration` `trail` 等结合算法判断是否人为操作,防止一些非人为操作破解滑块验证码。
112 |
113 | 大部分情况下,只需要将 `x` 传给后端即可(如果背景图和滑块有比例缩放,可能需要自己计算 `x` 乘以缩放比例)。
114 |
115 | ### actionRef
116 |
117 | 提供给外部的操作,便于一些特殊场景自定义。
118 |
119 | ```typescript
120 | export type ActionType = {
121 | refresh: (resetErrorCount?: boolean) => void; // 刷新,参数为是否重置连续错误次数为0
122 | status: Status; // 每次获取返回当前的状态,注意它不是引用值,而是一个静态值。部分场景下配合自定义刷新操作使用。
123 | };
124 |
125 | export enum Status {
126 | Default = 1, // 默认
127 | Loading, // 加载中
128 | Verify, // 验证中
129 | Success, // 验证成功
130 | Error, // 验证失败
131 | LoadFailed // 加载失败
132 | }
133 | ```
134 |
135 | ### CSS 变量
136 |
137 | | 变量名 | 说明 | 默认值 |
138 | | --- | --- | --- |
139 | | --rcsc-primary | 主色 | `#1991fa` |
140 | | --rcsc-primary-light | 主色-浅 | `#d1e9fe` |
141 | | --rcsc-error | 错误色 | `#f57a7a` |
142 | | --rcsc-error-light | 错误色-浅 | `#fce1e1` |
143 | | --rcsc-success | 成功色 | `#52ccba` |
144 | | --rcsc-success-light | 成功色-浅 | `#d2f4ef` |
145 | | --rcsc-border-color | 边框色 | `#e4e7eb` |
146 | | --rcsc-bg-color | 背景色 | `#f7f9fa` |
147 | | --rcsc-text-color | 文本色 | `#45494c` |
148 | | --rcsc-button-color | 按钮颜色 | `#676d73` |
149 | | --rcsc-button-hover-color | 鼠标移入时,按钮颜色 | `#ffffff` |
150 | | --rcsc-button-bg-color | 按钮背景颜色 | `#ffffff` |
151 | | --rcsc-panel-border-radius | 图片容器边框圆角 | `2px` |
152 | | --rcsc-control-height | 滑轨高度 | `42px` |
153 |
154 | > \*注意 IE11 不支持 css 变量,如果你的项目需要兼容 IE11,尽量不使用 css 变量改变样式。
155 |
156 | [site]: https://caijf.github.io/rc-slider-captcha/
157 | [npm]: https://img.shields.io/npm/v/rc-slider-captcha.svg
158 | [npm-url]: https://npmjs.com/package/rc-slider-captcha
159 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ['@babel/preset-env', { targets: { node: 'current' } }],
4 | ['@babel/preset-react', { runtime: 'automatic' }],
5 | '@babel/preset-typescript'
6 | ]
7 | };
8 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = { extends: ['@commitlint/config-conventional'] };
2 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | toc: content
3 | ---
4 |
5 | # rc-slider-captcha
6 |
7 | [![npm][npm]][npm-url]  [![GitHub Star][github-star]][github-url]
8 |
9 | React 滑块验证码组件。
10 |
11 | ## 特性
12 |
13 | - 简单易用
14 | - 适配 PC 和移动端
15 | - 兼容 IE11 和现代浏览器
16 | - 使用 TypeScript 开发,提供完整的类型定义文件
17 |
18 | ## 代码演示
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ### 基础用法
37 |
38 | 只需传入 `request` 和 `onVerify` 。
39 |
40 | `request` 异步返回背景图和滑块图。
41 |
42 | `onVerify` 用户滑动停止时触发,用于验证。参数 [VerifyParam](#verifyparam) 一般情况下只需要用到拼图 `x` 轴偏移值。
43 |
44 |
45 |
46 | ### 触发式
47 |
48 | 设置 `mode="float"`。
49 |
50 |
51 |
52 | > **触发式交互说明:**
53 | >
54 | > - PC 端:鼠标移入时显示拼图,移出隐藏拼图。
55 | > - 移动端:触摸滑块显示拼图,停止触摸后,如果有向右滑动过则验证后隐藏拼图,否则隐藏拼图。
56 | >
57 | > **注意,验证成功后,PC 端和移动端都会隐藏拼图,且不再显示。假如后面提交时验证码失效,可以通过手动触发刷新。**
58 |
59 | ### 纯滑块,无拼图
60 |
61 | 设置 `mode="slider"` 无需 `request` 。你也可以自定义宽度、结合移动轨迹,做人机校验识别。
62 |
63 |
64 |
65 | 宽度自适应
66 |
67 |
68 |
69 | ### 手动刷新
70 |
71 | 假如滑动验证成功之后,等一段时间再去提交表单,服务返回验证码失效。这时候可以通过主动刷新滑块验证码,并提示用户重新验证。
72 |
73 |
74 |
75 | ### 自定义尺寸
76 |
77 | **什么情况下需要自定义尺寸?**
78 |
79 | 1. 背景图`宽度`不等于 `320` 或`高度`不等于 `160`
80 | 2. 拼图`宽度`不等于 `60` 或高度不等于背景图高度,需要调整 `left` 、 `top`
81 |
82 |
83 |
84 |
85 |
86 | ### 自定义样式
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | 暗色主题
99 |
100 |
101 |
102 | ### 自定义文本
103 |
104 |
105 |
106 | ### 请求失败
107 |
108 | 当图片接口请求失败时,友好显示。
109 |
110 |
111 |
112 | ### 验证失败处理
113 |
114 | 一、验证失败不自动刷新,需要手动点击刷新图标 或 手动调用刷新方法
115 |
116 | 设置 `autoRefreshOnError={false}` 。如果验证失败需要外部手动刷新 或 用户点击刷新图标。
117 |
118 |
119 |
120 | 二、连续验证失败超过限制次数,需要手动点击刷新
121 |
122 | 当连续失败3次后,需要点击滑块控制条才能刷新。
123 |
124 |
125 |
126 | ### 验证成功提示
127 |
128 | 自定义拼图内容,验证成功后显示“多少秒完成,打败了多少用户”。
129 |
130 |
131 |
132 | ### 结合弹窗
133 |
134 | 点击登录按钮显示滑块验证码弹窗,你可以在 `onVerify` 成功之后进行页面跳转或其他操作。
135 |
136 |
137 |
138 | ### 客户端生成拼图
139 |
140 | > 使用 [create-puzzle](https://caijf.github.io/create-puzzle/) 生成背景图和拼图。如果你使用的是 Node.js 做服务端,推荐使用 [node-puzzle](https://github.com/caijf/node-puzzle) 。
141 |
142 |
143 |
144 | ## API
145 |
146 |
147 |
148 | [npm]: https://img.shields.io/npm/v/rc-slider-captcha.svg
149 | [npm-url]: https://npmjs.com/package/rc-slider-captcha
150 | [github-star]: https://img.shields.io/github/stars/caijf/rc-slider-captcha?style=social
151 | [github-url]: https://github.com/caijf/rc-slider-captcha
152 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rc-slider-captcha",
3 | "version": "1.7.2",
4 | "description": "React slider captcha component.",
5 | "keywords": [
6 | "react",
7 | "component",
8 | "slider-captcha"
9 | ],
10 | "main": "dist/index.cjs.js",
11 | "module": "dist/index.esm.js",
12 | "types": "types/index.d.ts",
13 | "files": [
14 | "dist",
15 | "types"
16 | ],
17 | "scripts": {
18 | "test": "jest",
19 | "start": "dumi dev",
20 | "docs:build": "dumi build",
21 | "build": "rm -rf dist && rollup -c && npm run build:types",
22 | "build:types": "rm -rf types && tsc -p tsconfig.types.json",
23 | "lint": "npm run lint:js && npm run lint:style",
24 | "lint:js": "eslint --ext .js,.jsx,.ts,.tsx src",
25 | "lint-fix:js": "npm run lint:js -- --fix",
26 | "lint:style": "stylelint src/**/*.less",
27 | "lint-fix:style": "npm run lint:stylelint -- --fix",
28 | "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"",
29 | "prepublishOnly": "npm run build && npm test",
30 | "commit": "cz",
31 | "tsc": "tsc --noEmit",
32 | "prepare": "husky && dumi setup"
33 | },
34 | "config": {
35 | "commitizen": {
36 | "path": "@commitlint/cz-commitlint"
37 | }
38 | },
39 | "lint-staged": {
40 | "**/*.{css,less}": "stylelint --fix",
41 | "**/*.{js,jsx,ts,tsx}": "eslint",
42 | "*.{js,jsx,ts,tsx,less,md,json}": "prettier -w"
43 | },
44 | "repository": {
45 | "type": "git",
46 | "url": "git+https://github.com/caijf/rc-slider-captcha.git"
47 | },
48 | "author": "caijf",
49 | "license": "MIT",
50 | "bugs": {
51 | "url": "https://github.com/caijf/rc-slider-captcha/issues"
52 | },
53 | "homepage": "https://github.com/caijf/rc-slider-captcha#readme",
54 | "dependencies": {
55 | "classnames": "^2.5.1",
56 | "rc-hooks": "^3.0.15",
57 | "tslib": "^2.8.1"
58 | },
59 | "devDependencies": {
60 | "@babel/core": "^7.26.9",
61 | "@babel/preset-env": "^7.26.9",
62 | "@babel/preset-react": "^7.26.3",
63 | "@babel/preset-typescript": "^7.26.0",
64 | "@commitlint/cli": "^19.8.0",
65 | "@commitlint/config-conventional": "^19.8.0",
66 | "@commitlint/cz-commitlint": "^19.8.0",
67 | "@rollup/plugin-commonjs": "^28.0.3",
68 | "@rollup/plugin-node-resolve": "^16.0.0",
69 | "@rollup/plugin-typescript": "^12.1.2",
70 | "@testing-library/jest-dom": "^6.6.3",
71 | "@testing-library/react": "^16.2.0",
72 | "@types/jest": "^29.5.14",
73 | "@types/react": "^18.3.18",
74 | "@types/react-dom": "^18.3.5",
75 | "@typescript-eslint/eslint-plugin": "^7.18.0",
76 | "@typescript-eslint/parser": "^7.18.0",
77 | "antd": "^5.24.3",
78 | "autoprefixer": "^10.4.21",
79 | "babel-jest": "^29.7.0",
80 | "commitizen": "^4.3.1",
81 | "create-puzzle": "^3.0.2",
82 | "doly-icons": "^1.6.0",
83 | "dumi": "^2.4.18",
84 | "eslint": "^8.57.1",
85 | "eslint-plugin-react": "^7.37.4",
86 | "eslint-plugin-react-hooks": "^4.6.2",
87 | "husky": "^9.1.7",
88 | "jest": "^29.7.0",
89 | "jest-environment-jsdom": "^29.7.0",
90 | "less": "^4.2.2",
91 | "lint-staged": "^15.4.3",
92 | "postcss": "^8.5.3",
93 | "postcss-css-variables": "^0.19.0",
94 | "prettier": "^3.5.3",
95 | "prettier-plugin-two-style-order": "^1.0.1",
96 | "react": "^18.3.1",
97 | "react-dom": "^18.3.1",
98 | "rollup": "^4.35.0",
99 | "rollup-plugin-postcss": "^4.0.2",
100 | "stylelint": "^16.15.0",
101 | "stylelint-config-css-modules": "^4.4.0",
102 | "stylelint-config-standard": "^37.0.0",
103 | "stylelint-declaration-block-no-ignored-properties": "^2.8.0",
104 | "stylelint-no-unsupported-browser-features": "^8.0.4",
105 | "typescript": "^5.8.2",
106 | "ut2": "^1.16.0"
107 | },
108 | "peerDependencies": {
109 | "react": ">=16.9.0"
110 | },
111 | "browserslist": {
112 | "development": [
113 | "last 1 version"
114 | ],
115 | "production": [
116 | "last 1 version",
117 | "> 1%",
118 | "ie 11"
119 | ]
120 | },
121 | "publishConfig": {
122 | "registry": "https://registry.npmjs.org/"
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs';
2 | import { nodeResolve } from '@rollup/plugin-node-resolve';
3 | import typescript from '@rollup/plugin-typescript';
4 | import autoprefixer from 'autoprefixer';
5 | import cssvariables from 'postcss-css-variables';
6 | import postcss from 'rollup-plugin-postcss';
7 |
8 | export default {
9 | input: './src/index.tsx',
10 | output: [
11 | {
12 | file: 'dist/index.cjs.js',
13 | exports: 'named',
14 | format: 'cjs'
15 | },
16 | {
17 | file: 'dist/index.esm.js',
18 | format: 'es'
19 | }
20 | ],
21 | external: ['react', 'rc-hooks', 'classnames', 'tslib'],
22 | plugins: [
23 | nodeResolve(),
24 | commonjs(),
25 | typescript({
26 | tsconfig: './tsconfig.build.json'
27 | }),
28 | postcss({
29 | inject: true,
30 | extensions: ['.less'],
31 | plugins: [autoprefixer, cssvariables({ preserve: true })]
32 | })
33 | ]
34 | };
35 |
--------------------------------------------------------------------------------
/src/ControlBar/index.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import React, {
3 | FC,
4 | Ref,
5 | HTMLAttributes,
6 | ReactNode,
7 | useImperativeHandle,
8 | useMemo,
9 | useRef
10 | } from 'react';
11 | import SliderButton, { SliderButtonProps } from '../SliderButton';
12 | import { prefixCls, setStyle } from '../utils';
13 | import '../style';
14 | import SliderIcon from '../SliderIcon';
15 | import { Status } from '../interface';
16 |
17 | const controlPrefixCls = `${prefixCls}-control`;
18 | const SliderButtonDefaultWidth = 40;
19 | const IndicatorBorderWidth = 2;
20 |
21 | export type TipTextType = {
22 | /**
23 | * @description 默认状态显示内容。
24 | * @default '向右拖动滑块填充拼图'
25 | */
26 | default: ReactNode;
27 |
28 | /**
29 | * @description 加载中显示内容。
30 | * @default '加载中...'
31 | */
32 | loading: ReactNode;
33 |
34 | /**
35 | * @description 用户操作滑块移动中显示内容。
36 | * @default null
37 | */
38 | moving: ReactNode;
39 |
40 | /**
41 | * @description 用户停止操作验证中显示内容。
42 | * @default null
43 | */
44 | verifying: ReactNode;
45 |
46 | /**
47 | * @description 验证成功显示内容。
48 | * @default null
49 | */
50 | success: ReactNode;
51 |
52 | /**
53 | * @description 验证失败显示内容。
54 | * @default null
55 | */
56 | error: ReactNode;
57 |
58 | /**
59 | * @description 验证失败且连续错误次数超出限制显示内容。
60 | * @default 失败过多,点击重试
61 | */
62 | errors: ReactNode;
63 |
64 | /**
65 | * @description `request` 请求返回失败显示内容。
66 | * @default '加载失败,点击重试'
67 | */
68 | loadFailed: ReactNode;
69 | };
70 |
71 | export type TipIconType = {
72 | /**
73 | * @description 默认状态滑轨按钮图标。
74 | * @default
75 | */
76 | default: ReactNode;
77 |
78 | /**
79 | * @description 加载中滑轨按钮图标。
80 | * @default
81 | */
82 | loading: ReactNode;
83 |
84 | /**
85 | * @description 验证失败滑轨提示图标。
86 | * @default
87 | */
88 | error: ReactNode;
89 |
90 | /**
91 | * @description 验证成功滑轨提示图标。
92 | * @default
93 | */
94 | success: ReactNode;
95 | };
96 |
97 | export type ControlBarRefType = {
98 | getSliderButtonWidth(force?: boolean): number;
99 | getIndicatorBorderWidth(force?: boolean): number;
100 | getRect(force?: boolean): DOMRect;
101 | updateLeft(left: number): void;
102 | };
103 |
104 | interface ControlBarProps extends HTMLAttributes {
105 | status?: Status;
106 | isLimitErrors?: boolean;
107 | tipText?: Partial; // 提示文本
108 | tipIcon?: Partial; // 提示图标
109 | sliderButtonProps?: SliderButtonProps;
110 | indicatorProps?: HTMLAttributes;
111 | controlRef?: Ref;
112 | }
113 |
114 | const ControlBar: FC = ({
115 | status = Status.Default,
116 | isLimitErrors,
117 | tipText: customTipText,
118 | tipIcon: customTipIcon,
119 | sliderButtonProps,
120 | indicatorProps,
121 | controlRef,
122 | ...restProps
123 | }) => {
124 | const wrapperRef = useRef(null);
125 | const sliderButtonRef = useRef(null);
126 | const indicatorRef = useRef(null);
127 | const rectRef = useRef<{
128 | sliderButtonWidth?: number;
129 | indicatorBorderWidth?: number;
130 | rect?: DOMRect;
131 | }>({});
132 |
133 | const tipText = useMemo(
134 | () => ({
135 | default: '向右拖动滑块填充拼图',
136 | loading: '加载中...',
137 | moving: null,
138 | verifying: null,
139 | success: null,
140 | error: null,
141 | errors: (
142 | <>
143 | 失败过多,点击重试
144 | >
145 | ),
146 | loadFailed: '加载失败,点击重试',
147 | ...customTipText
148 | }),
149 | [customTipText]
150 | );
151 | const tipIcon = useMemo(
152 | () => ({
153 | default: ,
154 | loading: ,
155 | error: ,
156 | success: ,
157 | ...customTipIcon
158 | }),
159 | [customTipIcon]
160 | );
161 | const statusViewMap = useMemo(
162 | () => ({
163 | [Status.Default]: [tipText.default, tipIcon.default],
164 | [Status.Loading]: [tipText.loading, tipIcon.default],
165 | [Status.Moving]: [tipText.moving, tipIcon.default],
166 | [Status.Verify]: [tipText.verifying, tipIcon.loading],
167 | [Status.Error]: [tipText.error, tipIcon.error],
168 | [Status.Success]: [tipText.success, tipIcon.success],
169 | [Status.LoadFailed]: [tipText.loadFailed, tipIcon.default]
170 | }),
171 | [tipText, tipIcon]
172 | );
173 |
174 | const getSliderButtonWidth = (force?: boolean) => {
175 | if (force || typeof rectRef.current.sliderButtonWidth !== 'number') {
176 | rectRef.current.sliderButtonWidth =
177 | sliderButtonRef.current?.clientWidth || SliderButtonDefaultWidth;
178 | }
179 | return rectRef.current.sliderButtonWidth!;
180 | };
181 |
182 | const getIndicatorBorderWidth = (force?: boolean) => {
183 | if (force || typeof rectRef.current.indicatorBorderWidth !== 'number') {
184 | if (indicatorRef.current) {
185 | const indicatorStyles = window.getComputedStyle(indicatorRef.current);
186 | rectRef.current.indicatorBorderWidth =
187 | parseInt(indicatorStyles.borderLeftWidth) + parseInt(indicatorStyles.borderRightWidth);
188 | } else {
189 | rectRef.current.indicatorBorderWidth = IndicatorBorderWidth;
190 | }
191 | }
192 | return rectRef.current.indicatorBorderWidth!;
193 | };
194 |
195 | const getRect = (force?: boolean) => {
196 | if (force || !rectRef.current.rect) {
197 | if (wrapperRef.current) {
198 | rectRef.current.rect = wrapperRef.current?.getBoundingClientRect();
199 | }
200 | }
201 | return rectRef.current.rect!;
202 | };
203 |
204 | useImperativeHandle(
205 | controlRef,
206 | () => ({
207 | getSliderButtonWidth,
208 | getIndicatorBorderWidth,
209 | getRect,
210 | updateLeft(left) {
211 | const sliderButtonWidth = getSliderButtonWidth();
212 | const indicatorBorderWidth = getIndicatorBorderWidth();
213 | setStyle(sliderButtonRef.current, { left: left + 'px' });
214 | setStyle(indicatorRef.current, {
215 | width: left + sliderButtonWidth + indicatorBorderWidth + 'px'
216 | });
217 | }
218 | }),
219 | []
220 | );
221 |
222 | const isLoading = status === Status.Loading;
223 | const isMoving = status === Status.Moving;
224 | const isVerify = status === Status.Verify;
225 | const isSuccess = status === Status.Success;
226 | const isError = status === Status.Error;
227 | const isLoadFailed = status === Status.LoadFailed;
228 |
229 | const currentTipText = isLimitErrors ? tipText.errors : statusViewMap[status][0];
230 |
231 | return (
232 |
249 |
254 |
264 | {statusViewMap[status][1]}
265 |
266 |
270 | {currentTipText}
271 |
272 |
273 | );
274 | };
275 |
276 | export default ControlBar;
277 |
--------------------------------------------------------------------------------
/src/Jigsaw/LoadingBox.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import React from 'react';
3 | import SliderIcon from '../SliderIcon';
4 | import { prefixCls } from '../utils';
5 | import '../style';
6 |
7 | const currentPrefixCls = `${prefixCls}-loading`;
8 |
9 | export interface LoadingBoxProps extends React.HTMLAttributes {
10 | icon?: React.ReactNode;
11 | text?: React.ReactNode;
12 | }
13 |
14 | const LoadingBox: React.FC = ({
15 | icon = ,
16 | text = '加载中...',
17 | className,
18 | ...restProps
19 | }) => {
20 | return (
21 |
22 |
{icon}
23 |
{text}
24 |
25 | );
26 | };
27 |
28 | export default LoadingBox;
29 |
--------------------------------------------------------------------------------
/src/Jigsaw/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | HTMLAttributes,
3 | ImgHTMLAttributes,
4 | ReactNode,
5 | Ref,
6 | useImperativeHandle,
7 | useRef
8 | } from 'react';
9 | import classnames from 'classnames';
10 | import { Status } from '../interface';
11 | import LoadingBox, { LoadingBoxProps } from './LoadingBox';
12 | import { prefixCls, setStyle } from '../utils';
13 | import '../style';
14 | import React from 'react';
15 | import SliderIcon from '../SliderIcon';
16 |
17 | const jigsawPrefixCls = `${prefixCls}-jigsaw`;
18 |
19 | type SizeType = {
20 | width: number;
21 | height: number;
22 | top: number;
23 | left: number;
24 | };
25 |
26 | export type JigsawRefType = {
27 | /**
28 | * 更新拼图 `left` 值。
29 | * @param left 样式 `left` 值。
30 | */
31 | updateLeft(left: number): void;
32 | };
33 |
34 | // 默认配置
35 | export const defaultConfig = {
36 | bgSize: {
37 | width: 320,
38 | height: 160
39 | },
40 | puzzleSize: {
41 | width: 60,
42 | left: 0
43 | },
44 | loadFailedIcon: ,
45 | refreshIcon:
46 | };
47 |
48 | export interface JigsawProps extends HTMLAttributes {
49 | /**
50 | * @description 状态。
51 | */
52 | status?: Status;
53 |
54 | /**
55 | * @description 背景图片尺寸。
56 | * @default { width: 320, height: 160 }
57 | */
58 | bgSize?: Partial>;
59 |
60 | /**
61 | * @description 拼图尺寸和偏移调整。
62 | * @default { width: 60, left: 0 }
63 | */
64 | puzzleSize?: Partial;
65 |
66 | /**
67 | * @description 背景图 url 。
68 | */
69 | bgUrl?: string;
70 |
71 | /**
72 | * @description 拼图 url 。
73 | */
74 | puzzleUrl?: string;
75 |
76 | /**
77 | * @description 背景图元素属性。
78 | */
79 | bgImgProps?: ImgHTMLAttributes;
80 |
81 | /**
82 | * @description 拼图元素属性。
83 | */
84 | puzzleImgProps?: ImgHTMLAttributes;
85 |
86 | /**
87 | * @description 拼图操作。
88 | */
89 | jigsawRef?: Ref;
90 |
91 | /**
92 | * @description 拼图区域加载配置,支持 div 属性。
93 | */
94 | loadingBoxProps?: LoadingBoxProps;
95 |
96 | /**
97 | * @description 拼图区域加载失败图标。
98 | * @default
99 | */
100 | loadFailedIcon?: ReactNode;
101 | showRefreshIcon?: boolean; // 显示右上角刷新图标
102 |
103 | /**
104 | * @description 拼图区域刷新图标。
105 | * @default
106 | */
107 | refreshIcon?: ReactNode;
108 |
109 | /**
110 | * @description 禁止刷新。
111 | */
112 | disabledRefresh?: boolean;
113 |
114 | /**
115 | * @description 点击刷新时触发。
116 | * @returns
117 | */
118 | onRefresh?: () => void;
119 | }
120 |
121 | const Jigsaw: React.FC = ({
122 | status,
123 | bgSize = defaultConfig.bgSize,
124 | puzzleSize = defaultConfig.puzzleSize,
125 | bgUrl,
126 | puzzleUrl,
127 | bgImgProps,
128 | puzzleImgProps,
129 | jigsawRef,
130 |
131 | loadingBoxProps,
132 | loadFailedIcon = defaultConfig.loadFailedIcon,
133 | showRefreshIcon = true,
134 | refreshIcon = defaultConfig.refreshIcon,
135 | disabledRefresh,
136 | onRefresh,
137 |
138 | style,
139 | className,
140 | children,
141 | ...restProps
142 | }) => {
143 | const puzzleRef = useRef(null);
144 |
145 | useImperativeHandle(jigsawRef, () => ({
146 | updateLeft(left) {
147 | setStyle(puzzleRef.current, { left: left + 'px' });
148 | }
149 | }));
150 |
151 | if (status === Status.Loading) {
152 | return (
153 |
160 | );
161 | }
162 |
163 | if (status === Status.LoadFailed || !bgUrl || !puzzleUrl) {
164 | return (
165 |
166 | {loadFailedIcon}
167 |
168 | );
169 | }
170 |
171 | const isStop = status === Status.Verify || status === Status.Error || status === Status.Success; // 是否停止滑动
172 |
173 | return (
174 |
182 |

189 |

197 | {showRefreshIcon && status !== Status.Success && refreshIcon && (
198 |
{
203 | if (status !== Status.Verify && !disabledRefresh) {
204 | onRefresh?.();
205 | }
206 | }}
207 | >
208 | {refreshIcon}
209 |
210 | )}
211 | {children}
212 |
213 | );
214 | };
215 |
216 | export default Jigsaw;
217 |
--------------------------------------------------------------------------------
/src/SliderButton/index.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import React from 'react';
3 | import { isSupportTouch, prefixCls } from '../utils';
4 | import '../style';
5 |
6 | const currentPrefixCls = `${prefixCls}-button`;
7 |
8 | export interface SliderButtonProps extends React.HTMLAttributes {
9 | disabled?: boolean;
10 | active?: boolean;
11 | success?: boolean;
12 | error?: boolean;
13 | verify?: boolean;
14 | buttonRef?: React.RefObject;
15 | }
16 |
17 | const SliderButton: React.FC = ({
18 | className,
19 | disabled,
20 | active,
21 | success,
22 | error,
23 | verify,
24 | buttonRef,
25 | ...restProps
26 | }) => (
27 |
39 | );
40 |
41 | SliderButton.displayName = 'SliderButton';
42 |
43 | export default SliderButton;
44 |
--------------------------------------------------------------------------------
/src/SliderIcon/IconArrowRight.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const IconArrowRight: React.FC> = (props) => {
4 | return (
5 |
17 | );
18 | };
19 |
20 | export default IconArrowRight;
21 |
--------------------------------------------------------------------------------
/src/SliderIcon/IconCheck.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const IconCheck: React.FC> = (props) => {
4 | return (
5 |
15 | );
16 | };
17 |
18 | export default IconCheck;
19 |
--------------------------------------------------------------------------------
/src/SliderIcon/IconImageFill.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const IconImageFill: React.FC> = (props) => {
4 | return (
5 |
15 | );
16 | };
17 |
18 | export default IconImageFill;
19 |
--------------------------------------------------------------------------------
/src/SliderIcon/IconLoading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const IconLoading: React.FC> = (props) => {
4 | return (
5 |
33 | );
34 | };
35 |
36 | export default IconLoading;
37 |
--------------------------------------------------------------------------------
/src/SliderIcon/IconRefresh.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const IconRefresh: React.FC> = (props) => {
4 | return (
5 |
15 | );
16 | };
17 |
18 | export default IconRefresh;
19 |
--------------------------------------------------------------------------------
/src/SliderIcon/IconX.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const IconX: React.FC> = (props) => {
4 | return (
5 |
15 | );
16 | };
17 |
18 | export default IconX;
19 |
--------------------------------------------------------------------------------
/src/SliderIcon/SliderIconBase.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import React from 'react';
3 | import { prefixCls } from '../utils';
4 |
5 | const currentPrefixCls = `${prefixCls}-icon`;
6 |
7 | export interface SliderIconBaseProps extends React.HTMLAttributes {
8 | spin?: boolean;
9 | }
10 |
11 | const SliderIconBase: React.FC = ({ className, spin, ...restProps }) => {
12 | return (
13 |
17 | );
18 | };
19 |
20 | export default SliderIconBase;
21 |
--------------------------------------------------------------------------------
/src/SliderIcon/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import IconArrayRight from './IconArrowRight';
3 | import IconCheck from './IconCheck';
4 | import IconLoading from './IconLoading';
5 | import IconRefresh from './IconRefresh';
6 | import IconX from './IconX';
7 | import IconImageFill from './IconImageFill';
8 | import '../style';
9 | import BaseIcon, { SliderIconBaseProps } from './SliderIconBase';
10 |
11 | const iconMap = {
12 | arrowRight: ,
13 | check: ,
14 | loading: ,
15 | refresh: ,
16 | x: ,
17 | imageFill:
18 | };
19 |
20 | export interface SliderIconProps extends SliderIconBaseProps {
21 | type: keyof typeof iconMap;
22 | }
23 |
24 | const SliderIcon: React.FC = ({ type, ...restProps }) => {
25 | return {iconMap[type]};
26 | };
27 |
28 | export default SliderIcon;
29 |
--------------------------------------------------------------------------------
/src/demos/actionRef.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha';
2 | import React, { useRef } from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service1';
4 |
5 | function Demo() {
6 | const actionRef = useRef();
7 |
8 | return (
9 |
10 |
{
14 | console.log(data);
15 | return verifyCaptcha(data);
16 | }}
17 | actionRef={actionRef}
18 | />
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export default Demo;
27 |
--------------------------------------------------------------------------------
/src/demos/assets/1bg@2x.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/1bg@2x.jpg
--------------------------------------------------------------------------------
/src/demos/assets/1puzzle@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/1puzzle@2x.png
--------------------------------------------------------------------------------
/src/demos/assets/2bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/2bg.png
--------------------------------------------------------------------------------
/src/demos/assets/2puzzle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/2puzzle.png
--------------------------------------------------------------------------------
/src/demos/assets/3bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/3bg.png
--------------------------------------------------------------------------------
/src/demos/assets/3puzzle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/3puzzle.png
--------------------------------------------------------------------------------
/src/demos/assets/sunflower.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/sunflower.jpg
--------------------------------------------------------------------------------
/src/demos/basic.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service1';
4 |
5 | function Demo() {
6 | return (
7 | {
10 | console.log(data);
11 | return verifyCaptcha(data);
12 | }}
13 | />
14 | );
15 | }
16 |
17 | export default Demo;
18 |
--------------------------------------------------------------------------------
/src/demos/create-puzzle.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha';
2 | import React, { useRef, useState } from 'react';
3 | import { Options, createPuzzle } from 'create-puzzle';
4 | import DemoImage from './assets/sunflower.jpg';
5 | import { Radio } from 'antd';
6 | import { useUpdateEffect } from 'rc-hooks';
7 |
8 | function Demo() {
9 | const [format, setFormat] = useState('blob');
10 | const actionRef = useRef();
11 | const offsetXRef = useRef(0); // x 轴偏移值
12 |
13 | useUpdateEffect(() => {
14 | actionRef.current?.refresh();
15 | }, [format]);
16 |
17 | return (
18 |
19 |
20 | 图片格式:
21 | setFormat(e.target.value)}
26 | >
27 | Blob
28 | Base64
29 |
30 |
31 |
33 | createPuzzle(DemoImage, {
34 | format
35 | }).then((res) => {
36 | offsetXRef.current = res.x;
37 |
38 | return {
39 | bgUrl: res.bgUrl,
40 | puzzleUrl: res.puzzleUrl
41 | };
42 | })
43 | }
44 | onVerify={(data) => {
45 | console.log(data);
46 | if (data.x >= offsetXRef.current - 5 && data.x < offsetXRef.current + 5) {
47 | return Promise.resolve();
48 | }
49 | return Promise.reject();
50 | }}
51 | bgSize={{
52 | width: 360
53 | }}
54 | loadingDelay={300}
55 | actionRef={actionRef}
56 | />
57 |
58 | );
59 | }
60 |
61 | export default Demo;
62 |
--------------------------------------------------------------------------------
/src/demos/custom-content.module.less:
--------------------------------------------------------------------------------
1 | .custom {
2 | .successTip {
3 | position: absolute;
4 | bottom: 0;
5 | left: 0;
6 | z-index: 2;
7 | box-sizing: border-box;
8 | width: 100%;
9 | padding: 2px 5px;
10 | color: #fff;
11 | font-size: 14px;
12 | text-align: center;
13 | background-color: #43a047;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/demos/custom-content.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React, { useState } from 'react';
3 | import styles from './custom-content.module.less';
4 | import { getCaptcha, verifyCaptcha } from './service1';
5 |
6 | function Demo() {
7 | const [visible, setVisible] = useState(false);
8 | const [duration, setDuration] = useState(0);
9 |
10 | return (
11 | {
14 | console.log(data);
15 | return verifyCaptcha(data).then(() => {
16 | setDuration(data.duration);
17 | setVisible(true);
18 | });
19 | }}
20 | className={styles.custom}
21 | jigsawContent={
22 | visible && (
23 |
24 | {Number((duration / 1000).toFixed(2))}秒内完成,打败了xx%用户
25 |
26 | )
27 | }
28 | />
29 | );
30 | }
31 |
32 | export default Demo;
33 |
--------------------------------------------------------------------------------
/src/demos/custom-dark.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service1';
4 |
5 | function Demo() {
6 | return (
7 | {
10 | console.log(data);
11 | return verifyCaptcha(data);
12 | }}
13 | style={{
14 | '--rcsc-bg-color': '#141414',
15 | '--rcsc-text-color': 'rgba(255, 255, 255, 0.85)',
16 | '--rcsc-border-color': '#424242',
17 | '--rcsc-button-color': 'rgba(255, 255, 255, 0.65)',
18 | '--rcsc-button-bg-color': '#333'
19 | }}
20 | />
21 | );
22 | }
23 |
24 | export default Demo;
25 |
--------------------------------------------------------------------------------
/src/demos/custom-height.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * debug: true
3 | */
4 | import SliderCaptcha from 'rc-slider-captcha';
5 | import React from 'react';
6 | import { getCaptcha, verifyCaptcha } from './service1';
7 |
8 | function Demo() {
9 | return (
10 | <>
11 | {
15 | console.log(data);
16 | return verifyCaptcha(data);
17 | }}
18 | style={{
19 | '--rcsc-control-height': '30px'
20 | }}
21 | />
22 |
23 | 底部显示浮层
24 | {
30 | console.log(data);
31 | return verifyCaptcha(data);
32 | }}
33 | />
34 | >
35 | );
36 | }
37 |
38 | export default Demo;
39 |
--------------------------------------------------------------------------------
/src/demos/custom-intl.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service1';
4 |
5 | function Demo() {
6 | return (
7 | {
10 | console.log(data);
11 | return verifyCaptcha(data);
12 | }}
13 | tipText={{
14 | default: 'Drag to complete the puzzle',
15 | loading: 'Loading...',
16 | moving: 'Drag right to the puzzle',
17 | verifying: 'Verifying',
18 | error: 'Failed'
19 | }}
20 | loadingBoxProps={{
21 | text: 'loading'
22 | }}
23 | />
24 | );
25 | }
26 |
27 | export default Demo;
28 |
--------------------------------------------------------------------------------
/src/demos/custom-style.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRepeat, EmojiFrownFill, EmojiSmileFill, Gem, Heart } from 'doly-icons';
2 | import SliderCaptcha from 'rc-slider-captcha';
3 | import React from 'react';
4 | import { getCaptcha, verifyCaptcha } from './service1';
5 |
6 | function Demo() {
7 | return (
8 | {
11 | console.log(data);
12 | return verifyCaptcha(data);
13 | }}
14 | tipIcon={{
15 | default: ,
16 | loading: ,
17 | success: ,
18 | error: ,
19 | refresh:
20 | }}
21 | tipText={{
22 | default: '向右👉拖动完成拼图',
23 | loading: '👩🏻💻🧑💻努力中...',
24 | moving: '向右拖动至拼图位置',
25 | verifying: '验证中...',
26 | error: '验证失败'
27 | }}
28 | loadingBoxProps={{
29 | icon: ,
30 | text: "I'm loading"
31 | }}
32 | style={{
33 | '--rcsc-primary': '#e91e63',
34 | '--rcsc-primary-light': '#f8bbd0',
35 | '--rcsc-panel-border-radius': '10px',
36 | '--rcsc-control-border-radius': '20px'
37 | }}
38 | />
39 | );
40 | }
41 |
42 | export default Demo;
43 |
--------------------------------------------------------------------------------
/src/demos/custom-styles.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * debug: true
3 | */
4 | import { ArrowRepeat, EmojiFrownFill, EmojiSmileFill, Gem, Heart } from 'doly-icons';
5 | import SliderCaptcha from 'rc-slider-captcha';
6 | import React from 'react';
7 | import { getCaptcha, verifyCaptcha } from './service1';
8 |
9 | function Demo() {
10 | return (
11 | {
14 | console.log(data);
15 | return verifyCaptcha(data);
16 | }}
17 | tipIcon={{
18 | default: ,
19 | loading: ,
20 | success: ,
21 | error: ,
22 | refresh:
23 | }}
24 | tipText={{
25 | default: '向右👉拖动完成拼图',
26 | loading: '👩🏻💻🧑💻努力中...',
27 | moving: '向右拖动至拼图位置',
28 | verifying: '验证中...',
29 | error: '验证失败'
30 | }}
31 | loadingBoxProps={{
32 | icon: ,
33 | text: "I'm loading"
34 | }}
35 | style={{
36 | '--rcsc-primary': '#e91e63',
37 | '--rcsc-primary-light': '#f8bbd0',
38 | '--rcsc-text-color': 'gray',
39 | '--rcsc-panel-border-radius': '10px',
40 | '--rcsc-control-border-radius': '20px'
41 | }}
42 | styles={{
43 | panel: { fontSize: 14 },
44 | jigsaw: { fontSize: 16 },
45 | bgImg: { fontSize: 18 },
46 | puzzleImg: { fontSize: 20 },
47 | control: { fontSize: 22 },
48 | indicator: { fontSize: 24 }
49 | }}
50 | />
51 | );
52 | }
53 |
54 | export default Demo;
55 |
--------------------------------------------------------------------------------
/src/demos/dev-button.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * debug: true
3 | */
4 | import * as React from 'react';
5 | import SliderButton from '../SliderButton';
6 | import SliderIcon from '../SliderIcon';
7 |
8 | function Demo() {
9 | return (
10 | <>
11 | 默认:{' '}
12 |
13 |
14 |
15 |
16 | 禁用:{' '}
17 |
18 |
19 |
20 |
21 | 激活:{' '}
22 |
23 |
24 |
25 |
26 | 验证:{' '}
27 |
28 |
29 |
30 |
31 | 成功:{' '}
32 |
33 |
34 |
35 |
36 | 失败:{' '}
37 |
38 |
39 |
40 | >
41 | );
42 | }
43 |
44 | export default Demo;
45 |
--------------------------------------------------------------------------------
/src/demos/dev-control-bar.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * debug: true
3 | */
4 | import * as React from 'react';
5 | import { Status } from '../interface';
6 | import ControlBar, { ControlBarRefType } from '../ControlBar';
7 | import { Space } from 'antd';
8 |
9 | const Demo = () => {
10 | const controlBarRef1 = React.useRef(null);
11 | const controlBarRef2 = React.useRef(null);
12 | const controlBarRef3 = React.useRef(null);
13 | const controlBarRef4 = React.useRef(null);
14 | const controlBarRef5 = React.useRef(null);
15 | const controlBarRef6 = React.useRef(null);
16 | const controlBarRef7 = React.useRef(null);
17 | const controlBarRef8 = React.useRef(null);
18 |
19 | React.useEffect(() => {
20 | controlBarRef4.current?.updateLeft(48);
21 | controlBarRef5.current?.updateLeft(158);
22 | controlBarRef6.current?.updateLeft(158);
23 | controlBarRef7.current?.updateLeft(48);
24 | controlBarRef8.current?.updateLeft(48);
25 | }, []);
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default Demo;
42 |
--------------------------------------------------------------------------------
/src/demos/dev-icon.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * debug: true
3 | */
4 | import * as React from 'react';
5 | import SliderIcon from '../SliderIcon';
6 |
7 | function Demo() {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
16 | >
17 | );
18 | }
19 |
20 | export default Demo;
21 |
--------------------------------------------------------------------------------
/src/demos/dev-jigsaw.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * debug: true
3 | */
4 | import React, { useEffect, useRef } from 'react';
5 | import Jigsaw, { JigsawRefType } from '../Jigsaw';
6 | import { Space } from 'antd';
7 | import ImageBg from './assets/1bg@2x.jpg';
8 | import ImagePuzzle from './assets/1puzzle@2x.png';
9 | import { Status } from '../interface';
10 |
11 | function Demo() {
12 | const jigsawRef1 = useRef(null);
13 | const jigsawRef2 = useRef(null);
14 | const jigsawRef3 = useRef(null);
15 | const jigsawRef4 = useRef(null);
16 | const jigsawRef5 = useRef(null);
17 | const jigsawRef6 = useRef(null);
18 | const jigsawRef7 = useRef(null);
19 | const jigsawRef8 = useRef(null);
20 |
21 | useEffect(() => {
22 | jigsawRef4.current?.updateLeft(90);
23 | jigsawRef5.current?.updateLeft(90);
24 | jigsawRef6.current?.updateLeft(90);
25 | jigsawRef7.current?.updateLeft(60);
26 | jigsawRef8.current?.updateLeft(60);
27 | }, []);
28 |
29 | return (
30 |
31 |
40 |
49 |
58 |
67 |
76 |
85 |
94 |
104 |
105 | );
106 | }
107 |
108 | export default Demo;
109 |
--------------------------------------------------------------------------------
/src/demos/error.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha';
2 | import React, { useRef } from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service1';
4 |
5 | function Demo() {
6 | const actionRef = useRef();
7 |
8 | return (
9 |
10 |
{
13 | console.log(data);
14 | return verifyCaptcha(data);
15 | }}
16 | autoRefreshOnError={false}
17 | actionRef={actionRef}
18 | />
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export default Demo;
27 |
--------------------------------------------------------------------------------
/src/demos/errors.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha';
2 | import React, { useRef } from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service1';
4 |
5 | function Demo() {
6 | const actionRef = useRef();
7 |
8 | return (
9 |
10 |
{
13 | console.log(data);
14 | return verifyCaptcha(data);
15 | }}
16 | limitErrorCount={3}
17 | actionRef={actionRef}
18 | />
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export default Demo;
27 |
--------------------------------------------------------------------------------
/src/demos/float.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service1';
4 |
5 | function Demo() {
6 | return (
7 | <>
8 | {
12 | console.log(data);
13 | return verifyCaptcha(data);
14 | }}
15 | />
16 |
17 | 底部显示浮层
18 | {
24 | console.log(data);
25 | return verifyCaptcha(data);
26 | }}
27 | />
28 | >
29 | );
30 | }
31 |
32 | export default Demo;
33 |
--------------------------------------------------------------------------------
/src/demos/modal.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Modal } from 'antd';
2 | import SliderCaptcha from 'rc-slider-captcha';
3 | import React, { useState } from 'react';
4 | import { getCaptcha, verifyCaptcha } from './service1';
5 |
6 | function Demo() {
7 | const [open, setOpen] = useState(false);
8 | return (
9 | <>
10 |
11 |
用户名: xxx
12 |
密码: xxx
13 |
16 |
17 | setOpen(false)}
20 | title="安全验证"
21 | footer={false}
22 | centered
23 | width={368}
24 | style={{ maxWidth: '100%' }}
25 | >
26 | {
29 | console.log(data);
30 | return verifyCaptcha(data);
31 | }}
32 | />
33 |
34 | >
35 | );
36 | }
37 |
38 | export default Demo;
39 |
--------------------------------------------------------------------------------
/src/demos/request-failed.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service4';
4 |
5 | function Demo() {
6 | return (
7 | {
10 | console.log(data);
11 | return verifyCaptcha(data);
12 | }}
13 | // tipText={{
14 | // loadFailed: '🧑💻加载失败,点击重新加载'
15 | // }}
16 | />
17 | );
18 | }
19 |
20 | export default Demo;
21 |
--------------------------------------------------------------------------------
/src/demos/service1.ts:
--------------------------------------------------------------------------------
1 | import { inRange, sleep } from 'ut2';
2 | import ImageBg from './assets/1bg@2x.jpg';
3 | import ImagePuzzle from './assets/1puzzle@2x.png';
4 |
5 | export const getCaptcha = async () => {
6 | await sleep();
7 | return {
8 | bgUrl: ImageBg,
9 | puzzleUrl: ImagePuzzle
10 | };
11 | };
12 |
13 | export const verifyCaptcha = async (data: { x: number }) => {
14 | await sleep();
15 | // value is 90±5
16 | if (data && inRange(data.x, 85, 95)) {
17 | return Promise.resolve();
18 | }
19 | return Promise.reject();
20 | };
21 |
--------------------------------------------------------------------------------
/src/demos/service2.ts:
--------------------------------------------------------------------------------
1 | import { inRange, sleep } from 'ut2';
2 | import ImageBg from './assets/2bg.png';
3 | import ImagePuzzle from './assets/2puzzle.png';
4 |
5 | export const getCaptcha = async () => {
6 | await sleep();
7 | return {
8 | bgUrl: ImageBg,
9 | puzzleUrl: ImagePuzzle,
10 | y: 31
11 | };
12 | };
13 |
14 | export const verifyCaptcha = async (data: { x: number }) => {
15 | await sleep();
16 | // value is 190±5
17 | if (data && inRange(data.x, 185, 195)) {
18 | return Promise.resolve();
19 | }
20 | return Promise.reject();
21 | };
22 |
--------------------------------------------------------------------------------
/src/demos/service3.ts:
--------------------------------------------------------------------------------
1 | import { inRange, sleep } from 'ut2';
2 | import ImageBg from './assets/3bg.png';
3 | import ImagePuzzle from './assets/3puzzle.png';
4 |
5 | export const getCaptcha = async () => {
6 | await sleep();
7 | return {
8 | bgUrl: ImageBg,
9 | puzzleUrl: ImagePuzzle
10 | };
11 | };
12 |
13 | export const verifyCaptcha = async (data: { x: number }) => {
14 | await sleep();
15 | // value is 254±5
16 | if (data && inRange(data.x, 249, 259)) {
17 | return Promise.resolve();
18 | }
19 | return Promise.reject();
20 | };
21 |
--------------------------------------------------------------------------------
/src/demos/service4.ts:
--------------------------------------------------------------------------------
1 | import { inRange, sleep } from 'ut2';
2 | import ImageBg from './assets/1bg@2x.jpg';
3 | import ImagePuzzle from './assets/1puzzle@2x.png';
4 |
5 | let count = 0;
6 |
7 | export const getCaptcha = async () => {
8 | await sleep();
9 |
10 | if (++count % 2 !== 0) {
11 | return Promise.reject('request failed');
12 | }
13 |
14 | return {
15 | bgUrl: ImageBg,
16 | puzzleUrl: ImagePuzzle
17 | };
18 | };
19 |
20 | export const verifyCaptcha = async (data: { x: number }) => {
21 | await sleep();
22 | // value is 90±5
23 | if (data && inRange(data.x, 85, 95)) {
24 | return Promise.resolve();
25 | }
26 | return Promise.reject();
27 | };
28 |
--------------------------------------------------------------------------------
/src/demos/size.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React, { useState } from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service2';
4 |
5 | function Demo() {
6 | const [top, setTop] = useState(0);
7 |
8 | return (
9 | {
11 | return getCaptcha().then((res) => {
12 | setTop(res.y);
13 | return res;
14 | });
15 | }}
16 | onVerify={(data) => {
17 | console.log(data);
18 | return verifyCaptcha(data);
19 | }}
20 | bgSize={{
21 | width: 310,
22 | height: 110
23 | }}
24 | puzzleSize={{
25 | width: 55,
26 | height: 45,
27 | top
28 | }}
29 | />
30 | );
31 | }
32 |
33 | export default Demo;
34 |
--------------------------------------------------------------------------------
/src/demos/size2.tsx:
--------------------------------------------------------------------------------
1 | import SliderCaptcha from 'rc-slider-captcha';
2 | import React from 'react';
3 | import { getCaptcha, verifyCaptcha } from './service3';
4 |
5 | function Demo() {
6 | return (
7 | {
11 | console.log(data);
12 | return verifyCaptcha(data);
13 | }}
14 | bgSize={{
15 | width: 348,
16 | height: 110
17 | }}
18 | puzzleSize={{
19 | width: 62
20 | }}
21 | />
22 | );
23 | }
24 |
25 | export default Demo;
26 |
--------------------------------------------------------------------------------
/src/demos/slider-full-width.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import SliderCaptcha, { ActionType, Status } from 'rc-slider-captcha';
3 | import { useSize } from 'rc-hooks';
4 |
5 | function Demo() {
6 | const wrapperRef = useRef(null);
7 | const { width } = useSize(wrapperRef);
8 | const actionRef = useRef();
9 |
10 | const finalWidth = width || 320;
11 | const controlButtonWidth = 40;
12 | const indicatorBorderWidth = 2;
13 |
14 | useEffect(() => {
15 | if (actionRef.current && actionRef.current.status === Status.Success) {
16 | actionRef.current.refresh();
17 | // reset your slider captcha flag
18 | // ...
19 | }
20 | }, [width]);
21 |
22 | return (
23 | <>
24 |
25 | {
43 | console.log(data);
44 | if (data.x === finalWidth - controlButtonWidth - indicatorBorderWidth) {
45 | return Promise.resolve();
46 | }
47 | return Promise.reject();
48 | }}
49 | actionRef={actionRef}
50 | />
51 |
52 |
53 |
54 |
55 | >
56 | );
57 | }
58 |
59 | export default Demo;
60 |
--------------------------------------------------------------------------------
/src/demos/slider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha';
3 |
4 | function Demo() {
5 | const actionRef = useRef();
6 |
7 | const controlBarWidth = 320;
8 | const controlButtonWidth = 40;
9 | const indicatorBorderWidth = 2;
10 |
11 | return (
12 |
13 |
{
28 | console.log(data);
29 | if (data.x === controlBarWidth - controlButtonWidth - indicatorBorderWidth) {
30 | return Promise.resolve();
31 | }
32 | return Promise.reject();
33 | }}
34 | actionRef={actionRef}
35 | />
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | export default Demo;
44 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import React, { ReactNode, useImperativeHandle, useMemo, useRef } from 'react';
3 | import { useSafeState, useLatest, useMount } from 'rc-hooks';
4 | import './style';
5 | import { SliderButtonProps } from './SliderButton';
6 | import {
7 | getClient,
8 | isSupportPointer,
9 | isSupportTouch,
10 | normalizeNumber,
11 | prefixCls,
12 | reflow,
13 | setStyle
14 | } from './utils';
15 | import ControlBar, { ControlBarRefType, TipIconType, TipTextType } from './ControlBar';
16 | import { Status } from './interface';
17 | import Jigsaw, { defaultConfig as jigsawDefaultConfig, JigsawProps, JigsawRefType } from './Jigsaw';
18 |
19 | const events = isSupportPointer
20 | ? {
21 | start: 'pointerdown',
22 | move: 'pointermove',
23 | end: 'pointerup',
24 | cancel: 'pointercancel'
25 | }
26 | : isSupportTouch
27 | ? {
28 | start: 'touchstart',
29 | move: 'touchmove',
30 | end: 'touchend',
31 | cancel: 'touchcancel'
32 | }
33 | : {
34 | start: 'mousedown',
35 | move: 'mousemove',
36 | end: 'mouseup',
37 | cancel: 'touchcancel'
38 | };
39 | const startEventName = isSupportPointer
40 | ? 'onPointerDown'
41 | : isSupportTouch
42 | ? 'onTouchStart'
43 | : 'onMouseDown';
44 |
45 | type StyleWithVariable = React.CSSProperties & Partial>;
46 | type StyleProp = StyleWithVariable<
47 | | '--rcsc-primary'
48 | | '--rcsc-primary-light'
49 | | '--rcsc-error'
50 | | '--rcsc-error-light'
51 | | '--rcsc-success'
52 | | '--rcsc-success-light'
53 | | '--rcsc-border-color'
54 | | '--rcsc-bg-color'
55 | | '--rcsc-text-color'
56 | | '--rcsc-button-color'
57 | | '--rcsc-button-hover-color'
58 | | '--rcsc-button-bg-color'
59 | | '--rcsc-panel-border-radius'
60 | | '--rcsc-control-border-radius'
61 | | '--rcsc-control-height'
62 | >;
63 |
64 | type JigsawImages = {
65 | /**
66 | * 背景图
67 | */
68 | bgUrl: string;
69 |
70 | /**
71 | * 拼图
72 | */
73 | puzzleUrl: string;
74 | };
75 |
76 | export enum CurrentTargetType {
77 | Puzzle = 'puzzle',
78 | Button = 'button'
79 | }
80 |
81 | export type VerifyParam = {
82 | /**
83 | * 拼图 x 轴移动值。(这里指的是计算后的拼图移动距离。)
84 | *
85 | * 如果背景图和滑块有比例缩放,可能需要自己计算 x 乘以缩放比例
86 | */
87 | x: number;
88 |
89 | /**
90 | * 用户操作按钮或拼图 y 轴移动值。(按下鼠标到释放鼠标 y 轴的差值。)
91 | */
92 | y: number;
93 |
94 | /**
95 | * 滑块 x 轴偏移值。(暂时没有什么场景会用到)
96 | */
97 | sliderOffsetX: number;
98 |
99 | /**
100 | * 操作持续时长,单位毫秒。
101 | */
102 | duration: number;
103 |
104 | /**
105 | * 移动轨迹。
106 | */
107 | trail: [number, number][];
108 |
109 | /**
110 | * 操作 dom 目标。 `puzzle`-拼图 `button`-滑块按钮。
111 | */
112 | targetType: CurrentTargetType;
113 |
114 | /**
115 | * 连续错误次数。
116 | */
117 | errorCount: number;
118 | };
119 |
120 | export { Status };
121 |
122 | // 常用操作
123 | export type ActionType = {
124 | /**
125 | * @description 主动刷新。
126 | * @param resetErrorCount 是否重置连续错误次数为 `0`。默认为 `false`。
127 | * @returns
128 | */
129 | refresh: (resetErrorCount?: boolean) => void;
130 |
131 | /**
132 | * @description 每次获取返回当前的状态,注意它不是引用值,而是一个静态值。部分场景下配合自定义刷新操作使用。
133 | */
134 | status: Status;
135 | };
136 |
137 | export type SliderCaptchaProps = Pick<
138 | JigsawProps,
139 | 'bgSize' | 'puzzleSize' | 'showRefreshIcon' | 'loadingBoxProps'
140 | > & {
141 | /**
142 | * @description 限制连续错误次数。当连续错误次数达到限制时,不允许操作滑块和刷新图标,必须手动点击操作条刷新。`0` 表示不限制错误次数。
143 | * @default 0
144 | */
145 | limitErrorCount?: number;
146 |
147 | /**
148 | * @description 用户操作滑块完成后触发,主要用于验证,返回 `resolve` 表示验证成功,`reject` 表示验证失败。
149 | * @param data 验证参数。
150 | * @returns
151 | */
152 | onVerify: (data: VerifyParam) => Promise;
153 |
154 | /**
155 | * @description 提示文本配置。
156 | * @default { default: '向右拖动滑块填充拼图', loading: '加载中...', moving: null, verifying: null, success: null, error: null, errors: (<> 失败过多,点击重试>), loadFailed: '加载失败,点击重试' }
157 | */
158 | tipText?: Partial;
159 |
160 | /**
161 | * @description 提示图标配置。
162 | */
163 | tipIcon?: Partial<
164 | TipIconType & {
165 | /**
166 | * @description 拼图区域刷新图标。
167 | * @default
168 | */
169 | refresh: JigsawProps['refreshIcon'];
170 |
171 | /**
172 | * @description 拼图区域加载失败图标。
173 | * @default
174 | */
175 | loadFailed: ReactNode;
176 | }
177 | >;
178 |
179 | /**
180 | * @description 拼图区域刷新图标。
181 | * @deprecated 即将废弃,请使用 `tipIcon.refresh`。
182 | */
183 | refreshIcon?: ReactNode;
184 |
185 | /**
186 | * @description 自动发起请求。
187 | * @default true
188 | */
189 | autoRequest?: boolean;
190 |
191 | /**
192 | * @description 验证失败后自动刷新。
193 | * @default true
194 | */
195 | autoRefreshOnError?: boolean;
196 |
197 | /**
198 | * @description 常用操作,比如`刷新`。
199 | */
200 | actionRef?: React.MutableRefObject;
201 |
202 | /**
203 | * @description 拼图区域自定义内容,需要自己定义绝对定位和 `zIndex`。如“xx秒完成超过多少用户” 或隐藏刷新图标,自定义右上角内容。
204 | */
205 | jigsawContent?: React.ReactNode;
206 |
207 | /**
208 | * @description 错误停留多少毫秒后自动刷新。仅在 `autoRefreshOnError=true` 时生效。
209 | * @default 500
210 | */
211 | errorHoldDuration?: number;
212 |
213 | /**
214 | * @description 设置 `loading` 状态延迟的时间,避免闪烁,单位为毫秒。
215 | * @default 0
216 | */
217 | loadingDelay?: number;
218 |
219 | /**
220 | * @description 浮层位置。仅在 `mode=float` 时生效。
221 | * @default 'top'
222 | */
223 | placement?: 'top' | 'bottom';
224 |
225 | /**
226 | * @description 滑轨按钮属性。
227 | */
228 | sliderButtonProps?: SliderButtonProps;
229 |
230 | /**
231 | * @description 数字精度。为避免内部计算产生精度问题,只对 `onVerify` 方法参数 `x` `y` `sliderOffsetX` 生效。
232 | * @default 7
233 | */
234 | precision?: number | false;
235 |
236 | /**
237 | * @description 类名。
238 | */
239 | className?: string;
240 |
241 | /**
242 | * @description 样式。
243 | */
244 | style?: StyleProp;
245 |
246 | /**
247 | * @description 配置内置模块样式。
248 | */
249 | styles?: {
250 | panel?: StyleProp;
251 | jigsaw?: StyleProp;
252 | bgImg?: StyleProp;
253 | puzzleImg?: StyleProp;
254 | control?: StyleProp;
255 | indicator?: StyleProp;
256 | };
257 | } & (
258 | | {
259 | /**
260 | * @description 模式。`embed`-嵌入式 `float`-触发式 `slider`-只有滑块无拼图,默认为 `embed`。
261 | */
262 | mode?: 'embed' | 'float';
263 |
264 | /**
265 | * @description 请求背景图和拼图。
266 | * @returns
267 | */
268 | request: () => Promise;
269 | }
270 | | {
271 | /**
272 | * @description 纯滑块不需要传入 `request`。
273 | */
274 | mode: 'slider';
275 |
276 | /**
277 | * @description 不要传。
278 | * @returns
279 | */
280 | request?: () => Promise;
281 | }
282 | );
283 | const SliderCaptcha: React.FC = ({
284 | mode: outMode = 'embed',
285 | limitErrorCount = 0,
286 | tipText,
287 | tipIcon,
288 | refreshIcon: customRefreshIcon,
289 | bgSize: outBgSize,
290 | puzzleSize: outPuzzleSize,
291 | request,
292 | autoRequest = true,
293 | onVerify,
294 | autoRefreshOnError = true,
295 | actionRef,
296 | showRefreshIcon = true,
297 | jigsawContent,
298 | errorHoldDuration = 500,
299 | loadingDelay = 0,
300 | placement = 'top',
301 | loadingBoxProps,
302 | sliderButtonProps,
303 | precision = 7,
304 | className,
305 | style,
306 | styles
307 | }) => {
308 | const [jigsawImgs, setJigsawImgs] = useSafeState();
309 | const [status, setStatus] = useSafeState(Status.Default);
310 | const latestStatus = useLatest(status); // 同步status值,提供给事件方法使用
311 | const controlRef = useRef(null);
312 | const jigsawRef = useRef(null);
313 |
314 | // dom ref
315 | const panelRef = useRef(null);
316 |
317 | // config
318 | const mode = useMemo(
319 | () => (outMode === 'float' || outMode === 'slider' ? outMode : 'embed'),
320 | [outMode]
321 | );
322 | const refreshIcon = useMemo(() => {
323 | if (customRefreshIcon !== undefined) {
324 | return customRefreshIcon;
325 | }
326 | if (tipIcon?.refresh !== undefined) {
327 | return tipIcon.refresh;
328 | }
329 | }, [customRefreshIcon, tipIcon]);
330 | const bgSize = useMemo(() => ({ ...jigsawDefaultConfig.bgSize, ...outBgSize }), [outBgSize]);
331 | const puzzleSize = useMemo(
332 | () => ({ ...jigsawDefaultConfig.puzzleSize, ...outPuzzleSize }),
333 | [outPuzzleSize]
334 | );
335 | const placementPos = useMemo(() => (placement === 'bottom' ? 'top' : 'bottom'), [placement]);
336 |
337 | const internalRef = useRef({
338 | isPressed: false, // 标识是否按下
339 | trail: [] as VerifyParam['trail'], // 移动轨迹
340 | errorCount: 0, // 连续错误次数
341 | startInfo: { x: 0, y: 0, timestamp: 0 }, // 鼠标按下或触摸开始信息
342 | currentTargetType: CurrentTargetType.Button, // 当前触发事件的节点,拼图或按钮
343 |
344 | floatTransitionTimer: null as any, // 触发式渐变过渡效果定时器
345 | floatDelayShowTimer: null as any, // 触发式鼠标移入定时器
346 | floatDelayHideTimer: null as any, // 触发式鼠标移出定时器
347 | refreshTimer: null as any, // 自动刷新的定时器
348 | loadingTimer: null as any, // 延迟加载状态定时器
349 |
350 | sliderButtonWidth: 40, // 滑块按钮宽度
351 | indicatorBorderWidth: 2, // 滑轨边框宽度
352 | ratio: 1, // 当滑块或拼图为触发事件的焦点时,两者的变换比例
353 | buttonMaxDistance: 0, // 按钮最大可移动距离
354 | puzzleMaxDistance: 0 // 拼图最大可移动距离
355 | });
356 |
357 | const modeIsSlider = mode === 'slider'; // 单滑轨,无图片
358 | const hasLoadingDelay = typeof loadingDelay === 'number' && loadingDelay > 0; // 延迟加载状态
359 |
360 | const isLimitErrors =
361 | status === Status.Error &&
362 | limitErrorCount > 0 &&
363 | internalRef.current.errorCount >= limitErrorCount; // 是否超过限制错误次数
364 |
365 | // 更新最大可移动距离
366 | const updateMaxDistance = () => {
367 | internalRef.current.buttonMaxDistance =
368 | bgSize.width -
369 | internalRef.current.sliderButtonWidth -
370 | internalRef.current.indicatorBorderWidth;
371 | internalRef.current.puzzleMaxDistance = bgSize.width - puzzleSize.width - puzzleSize.left;
372 | };
373 |
374 | const getControlHeight = () => {
375 | return controlRef.current?.getRect(true).height || 42;
376 | };
377 |
378 | // 获取背景图和拼图
379 | const getJigsawImages = async () => {
380 | if (modeIsSlider) return;
381 | if (request) {
382 | if (hasLoadingDelay) {
383 | internalRef.current.loadingTimer = setTimeout(() => {
384 | setStatus(Status.Loading);
385 | }, loadingDelay);
386 | } else {
387 | setStatus(Status.Loading);
388 | }
389 |
390 | try {
391 | const result = await request();
392 |
393 | if (hasLoadingDelay) {
394 | clearTimeout(internalRef.current.loadingTimer);
395 | }
396 |
397 | setJigsawImgs(result);
398 | setStatus(Status.Default);
399 | } catch (err) {
400 | // console.error(err);
401 | if (hasLoadingDelay) {
402 | clearTimeout(internalRef.current.loadingTimer);
403 | }
404 | setStatus(Status.LoadFailed);
405 | }
406 | }
407 | };
408 |
409 | // 触发式下,显示面板
410 | const showPanel = (delay = 300) => {
411 | if (mode !== 'float' || latestStatus.current === Status.Success) {
412 | return;
413 | }
414 |
415 | clearTimeout(internalRef.current.floatTransitionTimer);
416 | clearTimeout(internalRef.current.floatDelayHideTimer);
417 | clearTimeout(internalRef.current.floatDelayShowTimer);
418 |
419 | internalRef.current.floatDelayShowTimer = setTimeout(() => {
420 | setStyle(panelRef.current, { display: 'block' });
421 | reflow(panelRef.current);
422 | const controlBarHeight = getControlHeight() + 'px';
423 | setStyle(panelRef.current, { [placementPos]: controlBarHeight, opacity: '1' });
424 | }, delay);
425 | };
426 |
427 | // 触发式下,隐藏面板
428 | const hidePanel = (delay = 300) => {
429 | if (mode !== 'float') {
430 | return;
431 | }
432 |
433 | clearTimeout(internalRef.current.floatTransitionTimer);
434 | clearTimeout(internalRef.current.floatDelayHideTimer);
435 | clearTimeout(internalRef.current.floatDelayShowTimer);
436 |
437 | internalRef.current.floatDelayHideTimer = setTimeout(() => {
438 | const controlBarHalfHeight = getControlHeight() / 2 + 'px';
439 | setStyle(panelRef.current, { [placementPos]: controlBarHalfHeight, opacity: '0' });
440 | internalRef.current.floatTransitionTimer = setTimeout(() => {
441 | setStyle(panelRef.current, { display: 'none' });
442 | }, 300);
443 | }, delay);
444 | };
445 |
446 | // 更新拼图位置
447 | const updatePuzzleLeft = (left: number) => {
448 | if (!modeIsSlider) {
449 | jigsawRef.current?.updateLeft(left);
450 | }
451 | };
452 |
453 | // 重置状态和元素位置
454 | const reset = () => {
455 | internalRef.current.isPressed = false;
456 | setStatus(Status.Default);
457 |
458 | controlRef.current?.updateLeft(0);
459 | updatePuzzleLeft(puzzleSize.left);
460 | };
461 |
462 | // 刷新
463 | const refresh = (resetErrorCount = false) => {
464 | // 重置连续错误次数记录
465 | if (resetErrorCount) {
466 | internalRef.current.errorCount = 0;
467 | }
468 |
469 | // 清除延迟调用刷新方法的定时器
470 | clearTimeout(internalRef.current.refreshTimer);
471 |
472 | // 防止连续调用刷新方法,会触发多次请求的问题
473 | if (latestStatus.current === Status.Loading) {
474 | return;
475 | }
476 |
477 | reset();
478 | getJigsawImages();
479 | };
480 |
481 | // 点击滑块操作条,如果连续超过错误次数或请求失败则刷新
482 | const handleClickControl = () => {
483 | if (isLimitErrors || status === Status.LoadFailed) {
484 | refresh(isLimitErrors);
485 | }
486 | };
487 |
488 | // 鼠标移入显示面板,如果支持touch事件不处理
489 | const handleMouseEnter = () => {
490 | if (isSupportTouch) {
491 | return;
492 | }
493 | showPanel();
494 | };
495 |
496 | // 鼠标移出隐藏面板,如果支持touch事件不处理
497 | const handleMouseLeave = () => {
498 | if (isSupportTouch) {
499 | return;
500 | }
501 | hidePanel();
502 | };
503 |
504 | const touchstartPuzzle = (e: any) => {
505 | internalRef.current.currentTargetType = CurrentTargetType.Puzzle;
506 | touchstart(e);
507 | };
508 | const touchstartSliderButton = (e: any) => {
509 | internalRef.current.currentTargetType = CurrentTargetType.Button;
510 | touchstart(e);
511 | };
512 |
513 | // 鼠标按下或触摸开始
514 | const touchstart = (e: any) => {
515 | if (latestStatus.current !== Status.Default) {
516 | return;
517 | }
518 |
519 | e.preventDefault(); // 防止移动端按下后会选择文本或图片
520 |
521 | const { clientX, clientY } = getClient(e);
522 |
523 | internalRef.current.startInfo = {
524 | x: clientX,
525 | y: clientY,
526 | timestamp: new Date().getTime()
527 | };
528 | internalRef.current.trail = [[clientX, clientY]];
529 |
530 | if (controlRef.current) {
531 | internalRef.current.sliderButtonWidth = controlRef.current.getSliderButtonWidth(true);
532 | internalRef.current.indicatorBorderWidth = controlRef.current.getIndicatorBorderWidth(true);
533 | }
534 | updateMaxDistance();
535 |
536 | // TODO 改动比例,等大版本更新在调整。
537 | // if (modeIsSlider) {
538 | // internalRef.current.ratio = 1;
539 | // } else {
540 | // 最大可移动区间值比例
541 | internalRef.current.ratio =
542 | internalRef.current.puzzleMaxDistance / internalRef.current.buttonMaxDistance;
543 | if (internalRef.current.currentTargetType === CurrentTargetType.Puzzle) {
544 | internalRef.current.ratio = 1 / internalRef.current.ratio;
545 | }
546 | // }
547 |
548 | // 处理移动端-触发式兼容
549 | // 可触屏电脑不支持触摸事件,但是 pointerType 可能为 'touch' 或 'pen'
550 | if (isSupportTouch || e.pointerType === 'pen' || e.pointerType === 'touch') {
551 | showPanel(0);
552 | }
553 |
554 | internalRef.current.isPressed = true;
555 |
556 | document.addEventListener(events.move, touchmove);
557 | document.addEventListener(events.end, touchend);
558 | document.addEventListener(events.cancel, touchend);
559 | };
560 |
561 | // 鼠标移动 或 触摸移动
562 | const touchmove = (e: any) => {
563 | if (!internalRef.current.isPressed) {
564 | return;
565 | }
566 |
567 | e.preventDefault();
568 | const { clientX, clientY } = getClient(e);
569 |
570 | let diffX = clientX - internalRef.current.startInfo.x; // 移动距离
571 | internalRef.current.trail.push([clientX, clientY]); // 记录移动轨迹
572 |
573 | if (latestStatus.current !== Status.Moving && diffX > 0) {
574 | setStatus(Status.Moving);
575 | }
576 |
577 | let puzzleLeft = diffX; // 拼图左偏移值
578 | let sliderButtonLeft = diffX; // 滑块按钮左偏移值
579 |
580 | if (internalRef.current.currentTargetType === CurrentTargetType.Puzzle) {
581 | diffX = Math.max(0, Math.min(diffX, internalRef.current.puzzleMaxDistance));
582 | puzzleLeft = diffX + puzzleSize.left;
583 | sliderButtonLeft = diffX * internalRef.current.ratio;
584 | } else {
585 | diffX = Math.max(0, Math.min(diffX, internalRef.current.buttonMaxDistance));
586 | sliderButtonLeft = diffX;
587 | puzzleLeft = diffX * internalRef.current.ratio + puzzleSize.left;
588 | }
589 |
590 | controlRef.current?.updateLeft(sliderButtonLeft);
591 | updatePuzzleLeft(puzzleLeft);
592 | };
593 |
594 | // 鼠标弹起 或 停止触摸
595 | const touchend = (e: any) => {
596 | document.removeEventListener(events.move, touchmove);
597 | document.removeEventListener(events.end, touchend);
598 | document.removeEventListener(events.cancel, touchend);
599 |
600 | if (!internalRef.current.isPressed) {
601 | return;
602 | }
603 |
604 | if (latestStatus.current !== Status.Moving) {
605 | internalRef.current.isPressed = false;
606 |
607 | // 如果是移动端事件,并且是触发式,隐藏浮层
608 | if (isSupportTouch) {
609 | hidePanel();
610 | }
611 | return;
612 | }
613 |
614 | if (onVerify) {
615 | internalRef.current.isPressed = false;
616 | setStatus(Status.Verify);
617 |
618 | const endTimestamp = new Date().getTime();
619 | const { clientX, clientY } = getClient(e);
620 |
621 | const diffY = clientY - internalRef.current.startInfo.y;
622 | let diffX = clientX - internalRef.current.startInfo.x; // 拼图移动距离
623 | let sliderOffsetX = diffX; // 滑块偏移值
624 |
625 | if (internalRef.current.currentTargetType === CurrentTargetType.Puzzle) {
626 | diffX = Math.max(0, Math.min(diffX, internalRef.current.puzzleMaxDistance));
627 | sliderOffsetX = diffX * internalRef.current.ratio;
628 | } else {
629 | diffX = Math.max(0, Math.min(diffX, internalRef.current.buttonMaxDistance));
630 | sliderOffsetX = diffX;
631 | diffX *= internalRef.current.ratio;
632 | }
633 |
634 | onVerify({
635 | x: normalizeNumber(diffX, precision),
636 | y: normalizeNumber(diffY, precision),
637 | sliderOffsetX: normalizeNumber(sliderOffsetX, precision),
638 | duration: endTimestamp - internalRef.current.startInfo.timestamp,
639 | trail: internalRef.current.trail,
640 | targetType: internalRef.current.currentTargetType,
641 | errorCount: internalRef.current.errorCount
642 | })
643 | .then(() => {
644 | internalRef.current.errorCount = 0;
645 | setStatus(Status.Success);
646 | hidePanel();
647 | })
648 | .catch(() => {
649 | internalRef.current.errorCount += 1;
650 | setStatus(Status.Error);
651 |
652 | if (isSupportTouch || e.pointerType === 'pen' || e.pointerType === 'touch') {
653 | hidePanel();
654 | }
655 |
656 | if (
657 | (limitErrorCount <= 0 || internalRef.current.errorCount < limitErrorCount) &&
658 | autoRefreshOnError
659 | ) {
660 | internalRef.current.refreshTimer = setTimeout(() => {
661 | refresh();
662 | }, errorHoldDuration);
663 | }
664 | });
665 | } else {
666 | reset();
667 | }
668 | };
669 |
670 | useMount(() => {
671 | if (autoRequest) {
672 | getJigsawImages();
673 | }
674 | });
675 |
676 | // 提供给外部
677 | useImperativeHandle(actionRef, () => ({
678 | refresh,
679 | get status() {
680 | return latestStatus.current;
681 | }
682 | }));
683 |
684 | return (
685 |
693 | {!modeIsSlider && (
694 |
695 |
699 |
719 | {jigsawContent}
720 |
721 |
722 |
723 | )}
724 |
738 |
739 | );
740 | };
741 |
742 | export default SliderCaptcha;
743 |
--------------------------------------------------------------------------------
/src/interface.ts:
--------------------------------------------------------------------------------
1 | // 内部状态
2 | export enum Status {
3 | Default = 1,
4 | Loading,
5 | Moving,
6 | Verify,
7 | Success,
8 | Error,
9 | LoadFailed
10 | }
11 |
--------------------------------------------------------------------------------
/src/style/ControlBar.less:
--------------------------------------------------------------------------------
1 | @import './config.less';
2 |
3 | .@{prefixCls}-control {
4 | position: relative;
5 | box-sizing: border-box;
6 | width: 100%;
7 | height: @control-height;
8 | background-color: @bg-color;
9 | /* stylelint-disable-next-line declaration-property-value-no-unknown */
10 | border: 1px solid @border-color;
11 | border-radius: @control-border-radius;
12 |
13 | &-button {
14 | position: absolute;
15 | top: 0;
16 | left: 0;
17 | z-index: 2;
18 | border-radius: @control-border-radius;
19 | }
20 |
21 | &-indicator {
22 | position: absolute;
23 | top: -1px;
24 | bottom: -1px;
25 | left: -1px;
26 | display: none;
27 | box-sizing: border-box;
28 | width: 0;
29 | background-color: @primary-light;
30 | /* stylelint-disable-next-line declaration-property-value-no-unknown */
31 | border: 1px solid @primary;
32 | border-radius: @control-border-radius;
33 | }
34 |
35 | &-tips {
36 | position: relative;
37 | z-index: 1;
38 | display: flex;
39 | align-items: center;
40 | justify-content: center;
41 | width: 100%;
42 | height: 100%;
43 | color: @text-color;
44 | font-size: 14px;
45 | line-height: 20px;
46 | text-align: center;
47 | user-select: none;
48 | }
49 |
50 | // 其他状态
51 | &-moving &-indicator,
52 | &-verify &-indicator,
53 | &-error &-indicator,
54 | &-success &-indicator {
55 | display: block;
56 | }
57 |
58 | &-error &-indicator {
59 | background-color: @error-light;
60 | border-color: @error;
61 | }
62 |
63 | &-success &-indicator {
64 | background-color: @success-light;
65 | border-color: @success;
66 | }
67 |
68 | // 失败多次
69 | &-errors,
70 | &-load-failed {
71 | padding-left: 0;
72 | background-color: @error-light;
73 | border-color: @error;
74 | }
75 | &-errors &-button,
76 | &-errors &-indicator,
77 | &-load-failed &-button,
78 | &-load-failed &-indicator {
79 | display: none;
80 | }
81 | &-errors &-tips,
82 | &-load-failed &-tips {
83 | color: @error;
84 | cursor: pointer;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/style/Jigsaw.less:
--------------------------------------------------------------------------------
1 | @import './config.less';
2 |
3 | .@{prefixCls} {
4 | &-jigsaw {
5 | position: relative;
6 | background-color: #f7f9fa;
7 | user-select: none;
8 |
9 | img {
10 | position: absolute;
11 | }
12 | &-bg {
13 | width: 100%;
14 | height: 100%;
15 | pointer-events: none;
16 | }
17 | &-puzzle {
18 | width: 60px;
19 | height: 100%;
20 | touch-action: none;
21 |
22 | &:hover {
23 | cursor: grab;
24 | }
25 | &:active {
26 | cursor: grabbing;
27 | }
28 | }
29 |
30 | &-refresh {
31 | position: absolute;
32 | top: 0;
33 | right: 0;
34 | z-index: 2;
35 | padding: 5px;
36 | color: #fff;
37 | font-size: 22px;
38 | line-height: 0;
39 | cursor: pointer;
40 | opacity: 0.75;
41 | transition: opacity 0.2s linear;
42 |
43 | &:hover {
44 | opacity: 1;
45 | }
46 |
47 | &-disabled {
48 | cursor: not-allowed;
49 |
50 | &:hover {
51 | opacity: 0.75;
52 | }
53 | }
54 | }
55 |
56 | &-stop &-puzzle {
57 | pointer-events: none;
58 |
59 | &:hover,
60 | &:active {
61 | cursor: default;
62 | }
63 | }
64 | }
65 |
66 | &-loading {
67 | display: flex;
68 | flex-direction: column;
69 | align-items: center;
70 | justify-content: center;
71 | box-sizing: border-box;
72 | padding: 15px;
73 | color: @text-color;
74 | font-size: 14px;
75 | text-align: center;
76 | background-color: @bg-color;
77 |
78 | &-icon {
79 | font-size: 30px;
80 | }
81 |
82 | &-text {
83 | margin-top: 5px;
84 | }
85 | }
86 |
87 | &-load-failed {
88 | display: flex;
89 | flex-direction: column;
90 | align-items: center;
91 | justify-content: center;
92 | box-sizing: border-box;
93 | width: 100%;
94 | height: 100%;
95 | color: #ccc;
96 | font-size: 85px;
97 | background-color: @bg-color;
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/style/SliderButton.less:
--------------------------------------------------------------------------------
1 | @import './config.less';
2 |
3 | .@{prefixCls}-button {
4 | display: inline-block;
5 | display: inline-flex;
6 | align-items: center;
7 | justify-content: center;
8 | box-sizing: border-box;
9 | width: 40px;
10 | height: 100%;
11 | padding: 5px 0;
12 | color: @button-color;
13 | font-size: 22px;
14 | line-height: 1;
15 | background-color: @button-bg-color;
16 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
17 | cursor: grab;
18 | transition-timing-function: linear;
19 | transition-duration: 0.2s;
20 | transition-property: background-color, color;
21 | touch-action: none;
22 |
23 | // -pc 用于区分PC端和移动端,移动端不需要移入样式
24 | &-active,
25 | &-verify,
26 | &-pc:hover,
27 | &:active {
28 | color: @button-hover-color;
29 | background-color: @primary;
30 | }
31 | &-active,
32 | &:active {
33 | cursor: grabbing;
34 | }
35 |
36 | &-verify,
37 | &-verify:active {
38 | cursor: wait;
39 | }
40 |
41 | &-error,
42 | &-error:hover,
43 | &-error:active {
44 | color: @button-hover-color;
45 | background-color: @error;
46 | cursor: default;
47 | }
48 |
49 | &-success,
50 | &-success:hover,
51 | &-success:active {
52 | color: @button-hover-color;
53 | background-color: @success;
54 | cursor: default;
55 | }
56 |
57 | &-disabled,
58 | &-disabled:hover,
59 | &-disabled:active {
60 | color: @button-color;
61 | background-color: @button-bg-color;
62 | cursor: no-drop;
63 | opacity: 0.7;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/style/SliderIcon.less:
--------------------------------------------------------------------------------
1 | @import './config.less';
2 |
3 | @keyframes slider-icon-animate_spin {
4 | 100% {
5 | transform: rotate(360deg);
6 | }
7 | }
8 |
9 | .@{prefixCls}-icon {
10 | display: inline-block;
11 | display: inline-flex;
12 | align-items: center;
13 | justify-content: center;
14 | font-style: normal;
15 | line-height: 0;
16 | text-align: center;
17 | text-transform: none;
18 | vertical-align: -0.125em;
19 | text-rendering: optimizeLegibility;
20 | -webkit-font-smoothing: antialiased;
21 | -moz-osx-font-smoothing: grayscale;
22 |
23 | & > svg {
24 | line-height: 1;
25 | }
26 |
27 | &-spin {
28 | animation: slider-icon-animate_spin 1s infinite linear;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/style/config.less:
--------------------------------------------------------------------------------
1 | @prefixCls: rc-slider-captcha;
2 |
3 | // theme
4 | // TODO 下个大版本不再兼容ie11,使用颜色计算的css变量值
5 | @primary: var(--rcsc-primary, #1991fa);
6 | @primary-light: var(--rcsc-primary-light, #d1e9fe);
7 | @error: var(--rcsc-error, #f57a7a);
8 | @error-light: var(--rcsc-error-light, #fce1e1);
9 | @success: var(--rcsc-success, #52ccba);
10 | @success-light: var(--rcsc-success-light, #d2f4ef);
11 | @border-color: var(--rcsc-border-color, #e4e7eb);
12 | @bg-color: var(--rcsc-bg-color, #f7f9fa);
13 | @text-color: var(--rcsc-text-color, #45494c);
14 | @button-color: var(--rcsc-button-color, #676d73);
15 | @button-bg-color: var(--rcsc-button-bg-color, #fff);
16 | @button-hover-color: var(--rcsc-button-hover-color, #fff);
17 | @panel-border-radius: var(--rcsc-panel-border-radius, 2px);
18 | @control-border-radius: var(--rcsc-control-border-radius, 2px);
19 | @control-height: var(--rcsc-control-height, 42px);
20 |
--------------------------------------------------------------------------------
/src/style/index.less:
--------------------------------------------------------------------------------
1 | @import './config.less';
2 | @import './SliderIcon.less';
3 | @import './SliderButton.less';
4 | @import './ControlBar.less';
5 | @import './Jigsaw.less';
6 |
7 | .@{prefixCls} {
8 | position: relative;
9 |
10 | & > * {
11 | // iOS中通过Javascript定义的可点击元素,长按就会出现一个灰色背景
12 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
13 | }
14 |
15 | &-panel {
16 | padding-bottom: 15px;
17 |
18 | &-inner {
19 | overflow: hidden;
20 | border-radius: @panel-border-radius;
21 | }
22 | }
23 |
24 | &-float &-panel {
25 | position: absolute;
26 | left: 0;
27 | display: none;
28 | opacity: 0;
29 | transition-timing-function: ease-out;
30 | transition-duration: 0.3s;
31 | transition-property: top, bottom, opacity;
32 | }
33 |
34 | &-float-top &-panel {
35 | bottom: 22px;
36 | }
37 |
38 | &-float-bottom &-panel {
39 | top: 22px;
40 | padding: 15px 0 0;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/style/index.ts:
--------------------------------------------------------------------------------
1 | import './index.less';
2 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | // className 前缀
2 | export const prefixCls = 'rc-slider-captcha';
3 |
4 | // 获取事件触发客户端坐标
5 | export function getClient(e: any) {
6 | let x = 0,
7 | y = 0;
8 | if (typeof e.clientX === 'number' && typeof e.clientY === 'number') {
9 | x = e.clientX;
10 | y = e.clientY;
11 | } else if (e.touches && e.touches[0]) {
12 | x = e.touches[0].clientX;
13 | y = e.touches[0].clientY;
14 | } else if (e.changedTouches && e.changedTouches[0]) {
15 | x = e.changedTouches[0].clientX;
16 | y = e.changedTouches[0].clientY;
17 | }
18 | return {
19 | clientX: x,
20 | clientY: y
21 | };
22 | }
23 |
24 | // 设置样式
25 | export function setStyle(el: HTMLElement | null, styleObj: Record = {}) {
26 | if (el) {
27 | for (const prop in styleObj) {
28 | el.style[prop as any] = styleObj[prop];
29 | }
30 | }
31 | }
32 |
33 | // 当前运行环境是否可以使用 dom
34 | export const isBrowser =
35 | typeof window === 'object' &&
36 | window &&
37 | typeof document === 'object' &&
38 | document &&
39 | window.document === document &&
40 | !!document.addEventListener;
41 |
42 | // 是否支持指针事件
43 | export const isSupportPointer = isBrowser && 'onpointerdown' in window;
44 |
45 | // 是否支持Touch事件
46 | // 区分移动端和PC端的事件绑定,移动端也会触发 mouseup mousedown 事件
47 | export const isSupportTouch = isBrowser && 'ontouchstart' in window;
48 |
49 | // 触发重绘
50 | export const reflow = (node: HTMLElement | null) => node?.scrollTop;
51 |
52 | // 规整化数字精度
53 | export function normalizeNumber(num: number, precision?: number | false) {
54 | if (
55 | typeof num === 'number' &&
56 | !Number.isNaN(num) &&
57 | typeof precision === 'number' &&
58 | precision > 0
59 | ) {
60 | return Number(num.toFixed(precision));
61 | }
62 | return num;
63 | }
64 |
--------------------------------------------------------------------------------
/stylelint.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | extends: [
3 | 'stylelint-config-standard',
4 | 'stylelint-config-css-modules',
5 | 'stylelint-no-unsupported-browser-features'
6 | ],
7 | plugins: ['stylelint-declaration-block-no-ignored-properties'],
8 | customSyntax: 'postcss-less',
9 | rules: {
10 | 'plugin/declaration-block-no-ignored-properties': true,
11 | 'no-descending-specificity': null,
12 | 'no-invalid-position-at-import-rule': null,
13 | 'declaration-empty-line-before': null,
14 | 'keyframes-name-pattern': null,
15 | 'custom-property-pattern': null,
16 | 'number-max-precision': 8,
17 | 'alpha-value-notation': 'number',
18 | 'color-function-notation': 'legacy',
19 | 'selector-class-pattern': null,
20 | 'selector-id-pattern': null,
21 | 'font-family-no-missing-generic-family-keyword': null,
22 | 'rule-empty-line-before': null,
23 | 'import-notation': ['string'],
24 | 'value-keyword-case': ['lower', { ignoreKeywords: ['optimizeLegibility'] }],
25 | 'selector-no-vendor-prefix': [
26 | true,
27 | { ignoreSelectors: ['::-webkit-input-placeholder', '/-moz-.*/'] }
28 | ]
29 | },
30 | ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts']
31 | };
32 |
--------------------------------------------------------------------------------
/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`snapshot custom config 1`] = `
4 |
5 |
9 |
12 |
16 |
20 |
23 | define icon
24 |
25 |
28 | define text
29 |
30 |
31 |
32 |
33 |
37 |
41 |
44 | define icon default
45 |
46 |
49 | define loading
50 |
51 |
52 |
53 |
54 | `;
55 |
56 | exports[`snapshot custom config 2`] = `
57 |
58 |
62 |
65 |
69 |
73 |

79 |

85 |
88 | define icon refresh
89 |
90 |
91 |
92 |
93 |
97 |
101 |
104 | define icon default
105 |
106 |
109 | define default
110 |
111 |
112 |
113 |
114 | `;
115 |
116 | exports[`snapshot default mode 1`] = `
117 |
118 |
122 |
125 |
129 |
133 |
136 |
139 |
218 |
219 |
220 |
223 | 加载中...
224 |
225 |
226 |
227 |
228 |
231 |
234 |
237 |
240 |
255 |
256 |
257 |
260 | 加载中...
261 |
262 |
263 |
264 |
265 | `;
266 |
267 | exports[`snapshot default mode 2`] = `
268 |
269 |
273 |
276 |
280 |
284 |

290 |

296 |
299 |
302 |
313 |
314 |
315 |
316 |
317 |
318 |
321 |
324 |
327 |
330 |
345 |
346 |
347 |
350 | 向右拖动滑块填充拼图
351 |
352 |
353 |
354 |
355 | `;
356 |
357 | exports[`snapshot default mode 3`] = `
358 |
359 |
363 |
366 |
370 |
374 |

380 |

386 |
387 |
388 |
389 |
392 |
395 |
398 |
401 |
416 |
417 |
418 |
421 | 向右拖动滑块填充拼图
422 |
423 |
424 |
425 |
426 | `;
427 |
428 | exports[`snapshot float mode 1`] = `
429 |
430 |
434 |
437 |
441 |
445 |
448 |
451 |
530 |
531 |
532 |
535 | 加载中...
536 |
537 |
538 |
539 |
540 |
543 |
546 |
549 |
552 |
567 |
568 |
569 |
572 | 加载中...
573 |
574 |
575 |
576 |
577 | `;
578 |
579 | exports[`snapshot float mode 2`] = `
580 |
581 |
585 |
588 |
592 |
596 |

602 |

608 |
611 |
614 |
625 |
626 |
627 |
628 |
629 |
630 |
633 |
636 |
639 |
642 |
657 |
658 |
659 |
662 | 向右拖动滑块填充拼图
663 |
664 |
665 |
666 |
667 | `;
668 |
669 | exports[`snapshot float mode 3`] = `
670 |
671 |
675 |
678 |
682 |
686 |

692 |

698 |
701 |
704 |
715 |
716 |
717 |
718 |
719 |
720 |
723 |
726 |
729 |
732 |
747 |
748 |
749 |
752 | 向右拖动滑块填充拼图
753 |
754 |
755 |
756 |
757 | `;
758 |
759 | exports[`snapshot slider mode 1`] = `
760 |
761 |
765 |
768 |
771 |
774 |
777 |
792 |
793 |
794 |
797 | 向右拖动滑块填充拼图
798 |
799 |
800 |
801 |
802 | `;
803 |
--------------------------------------------------------------------------------
/tests/fixtures/service1.ts:
--------------------------------------------------------------------------------
1 | import { sleep } from 'ut2';
2 |
3 | export const getCaptcha = async () => {
4 | await sleep();
5 | return {
6 | bgUrl: 'image1',
7 | puzzleUrl: 'image2'
8 | };
9 | };
10 |
11 | export const verifyCaptcha = async (data: { x: number }) => {
12 | await sleep();
13 | if (data.x && data.x > 87 && data.x < 93) {
14 | return Promise.resolve();
15 | }
16 | return Promise.reject();
17 | };
18 |
--------------------------------------------------------------------------------
/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import '@testing-library/jest-dom';
5 | import { render } from '@testing-library/react';
6 | import React, { act } from 'react';
7 | import { getCaptcha, verifyCaptcha } from './fixtures/service1';
8 | import SliderCaptcha from '..';
9 |
10 | describe('snapshot', () => {
11 | beforeAll(() => {
12 | jest.useFakeTimers();
13 | });
14 | afterAll(() => {
15 | jest.useRealTimers();
16 | });
17 |
18 | test('default mode', async () => {
19 | const component = render();
20 |
21 | expect(component.asFragment()).toMatchSnapshot();
22 |
23 | await act(async () => {
24 | jest.runAllTimers();
25 | });
26 |
27 | expect(component.asFragment()).toMatchSnapshot();
28 |
29 | await act(async () => {
30 | component.rerender(
31 |
32 | );
33 | });
34 |
35 | expect(component.asFragment()).toMatchSnapshot();
36 | });
37 |
38 | test('float mode', async () => {
39 | const component = render(
40 |
41 | );
42 | expect(component.asFragment()).toMatchSnapshot();
43 |
44 | await act(async () => {
45 | jest.runAllTimers();
46 | });
47 |
48 | expect(component.asFragment()).toMatchSnapshot();
49 |
50 | await act(async () => {
51 | component.rerender(
52 |
58 | );
59 | });
60 |
61 | expect(component.asFragment()).toMatchSnapshot();
62 | });
63 |
64 | test('slider mode', () => {
65 | const component = render();
66 | expect(component.asFragment()).toMatchSnapshot();
67 | });
68 |
69 | test('custom config', async () => {
70 | const component = render(
71 |
111 | );
112 |
113 | expect(component.asFragment()).toMatchSnapshot();
114 |
115 | await act(async () => {
116 | jest.runAllTimers();
117 | });
118 |
119 | expect(component.asFragment()).toMatchSnapshot();
120 | });
121 | });
122 |
123 | describe('render', () => {
124 | beforeAll(() => {
125 | jest.useFakeTimers();
126 | });
127 | afterAll(() => {
128 | jest.useRealTimers();
129 | });
130 |
131 | it('render default', async () => {
132 | const { container } = render();
133 |
134 | // console.log(container.innerHTML);
135 | // console.log(container.querySelector('.rc-slider-captcha-loading')?.getAttribute('style'));
136 |
137 | const wrapperEl = container.querySelector('.rc-slider-captcha') as HTMLElement;
138 | const panelEl = container.querySelector('.rc-slider-captcha-panel') as HTMLElement;
139 | const controlEl = container.querySelector('.rc-slider-captcha-control') as HTMLElement;
140 |
141 | expect(container).toContainElement(wrapperEl);
142 | expect(wrapperEl).toContainElement(panelEl);
143 | expect(wrapperEl).toContainElement(controlEl);
144 |
145 | expect(container.querySelector('.rc-slider-captcha-loading')).toBeInTheDocument();
146 | expect(container.querySelector('.rc-slider-captcha-jigsaw')).not.toBeInTheDocument();
147 | expect(controlEl.querySelector('.rc-slider-captcha-control-tips')).toHaveTextContent(
148 | '加载中...'
149 | );
150 |
151 | await act(async () => {
152 | jest.runAllTimers();
153 | });
154 |
155 | expect(container.querySelector('.rc-slider-captcha-loading')).not.toBeInTheDocument();
156 | expect(container.querySelector('.rc-slider-captcha-jigsaw')).toBeInTheDocument();
157 | expect(controlEl.querySelector('.rc-slider-captcha-control-tips')).toHaveTextContent(
158 | '向右拖动滑块填充拼图'
159 | );
160 | });
161 | });
162 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["src"],
4 | "exclude": ["src/demos/**/*", "src/.umi/**/*", "src/.umi*/**/*"]
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES5",
4 | "module": "ES2015",
5 | "moduleResolution": "node",
6 | "jsx": "react",
7 | "esModuleInterop": true,
8 | "types": ["jest", "@testing-library/jest-dom"],
9 | "lib": ["DOM", "ESNext"],
10 | "strict": true,
11 | "skipLibCheck": true,
12 | "baseUrl": "./",
13 | "paths": {
14 | "@@/*": [".dumi/tmp/*"],
15 | "rc-slider-captcha": ["src"]
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.types.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.build.json",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "emitDeclarationOnly": true,
6 | "declarationDir": "types"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.css';
2 | declare module '*.less';
3 | declare module '*.png';
4 | declare module '*.jpg';
5 | declare module '*.jpeg';
6 |
--------------------------------------------------------------------------------