Skip to content

Commit

Permalink
feat(captcha): add hollow-shape prop (#458)
Browse files Browse the repository at this point in the history
* feat: add path process prop

* feat: update
  • Loading branch information
qmhc committed Jan 15, 2024
1 parent 84d0b85 commit 21e675e
Show file tree
Hide file tree
Showing 8 changed files with 417 additions and 78 deletions.
129 changes: 101 additions & 28 deletions components/captcha/captcha.tsx
Expand Up @@ -39,6 +39,7 @@ import {
randomHardColor
} from '@vexip-ui/utils'
import { captchaProps } from './props'
import { heartPath, puzzlePath, shieldPath, squarePath } from './hollow-paths'

import type { CaptchaSliderExposed } from '@/components/captcha-slider'
import type { SuccessEvent } from './symbol'
Expand Down Expand Up @@ -98,6 +99,10 @@ export default defineComponent({
hideDelay: {
default: 3000,
validator: value => value >= 0
},
hollowShape: {
default: squarePath,
isFunc: true
}
})

Expand Down Expand Up @@ -138,6 +143,7 @@ export default defineComponent({

let imageLoaded = false
let image: HTMLImageElement | undefined
let memoryCanvas: HTMLCanvasElement | undefined

const isLoading = computed(() => props.loading || imageLoading.value || testLoading.value)
const failLocked = computed(() => props.failLimit > 0 && failedCount.value >= props.failLimit)
Expand Down Expand Up @@ -178,9 +184,15 @@ export default defineComponent({
await (imagePromise.value = loadImage())
drawImageNextFrame()
})
watch([currentTarget, () => props.canvasSize[0], () => props.canvasSize[1]], () => {
drawImageNextFrame()
})
watch(
[
currentTarget,
() => props.canvasSize[0],
() => props.canvasSize[1],
() => props.hollowShape
],
drawImageNextFrame
)
watch(
[() => props.type, () => props.remotePoint],
() => {
Expand Down Expand Up @@ -338,6 +350,21 @@ export default defineComponent({
}
}

function getHollowProcess() {
if (typeof props.hollowShape === 'function') return props.hollowShape

switch (props.hollowShape) {
case 'puzzle':
return puzzlePath
case 'shield':
return shieldPath
case 'heart':
return heartPath
default:
return squarePath
}
}

function drawImage() {
const canvasEl = canvas.value
const ctx = canvasEl?.getContext?.('2d')
Expand All @@ -351,43 +378,89 @@ export default defineComponent({
return
}

if (!subCanvasEl || !subCtx) return
if (!subCanvasEl || !subCtx || !track.value) return

if (!memoryCanvas) {
if (!isClient) return

memoryCanvas = document.createElement('canvas')
}

memoryCanvas.width = canvasEl.width
memoryCanvas.height = canvasEl.height

const pathCtx = memoryCanvas.getContext('2d')

if (!pathCtx) return

ctx.clearRect(0, 0, canvasEl.width, canvasEl.height)
subCtx.clearRect(0, 0, subCanvasEl.width, subCanvasEl.height)
pathCtx.clearRect(0, 0, memoryCanvas.width, memoryCanvas.height)

const canvasRect = canvasEl.getBoundingClientRect()
const subImageRect = track.value!.getBoundingClientRect()
const widthFix = ((canvasRect.width - subImageRect.width) / canvasRect.width) * canvasEl.width
const trackRect = track.value.getBoundingClientRect()
// 滑动时以轨道为准,所以需要补正 canvas 宽度和 track 宽度的差值
const widthFix = ((canvasRect.width - trackRect.width) / canvasRect.width) * canvasEl.width

const targetX = widthFix / 2 + currentTarget.value[0] * (canvasEl.width - widthFix) * 0.01
const targetY = currentTarget.value[1] * canvasEl.height * 0.01
const sideLength = Math.min(canvasEl.width, canvasEl.height) * 0.25
const halfSideLength = sideLength * 0.5

subCanvasEl.width = sideLength + 4
const hollowProcess = getHollowProcess()

ctx.drawImage(image, 0, 0, canvasEl.width, canvasEl.height)
pathCtx.beginPath()
pathCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)'
pathCtx.lineWidth = 4

const [clipX, clipY, clipWidth, clipHeight] = hollowProcess({
ctx: pathCtx,
x: targetX,
y: targetY,
width: props.canvasSize[0],
height: props.canvasSize[1]
})

pathCtx.stroke()
pathCtx.clip()
pathCtx.drawImage(image, 0, 0, canvasEl.width, canvasEl.height)

// 中心点偏移修正
const xLeftWidth = targetX - clipX
const translateFix = ((clipWidth * 0.5 - xLeftWidth) / clipWidth) * 100

subCanvasEl.style.transform = `translate3d(${translateFix - 50}%, 0, 0)`
subCanvasEl.width = clipWidth

subCtx.drawImage(
canvasEl,
targetX - halfSideLength,
targetY - halfSideLength,
sideLength,
sideLength,
2,
targetY - halfSideLength,
sideLength,
sideLength
memoryCanvas,
clipX,
clipY,
clipWidth,
clipHeight,
0,
clipY,
clipWidth,
clipHeight
)
subCtx.lineWidth = 2
subCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)'
subCtx.strokeRect(1, targetY - halfSideLength, sideLength + 2, sideLength)

ctx.save()
ctx.beginPath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'
ctx.fillRect(
targetX - halfSideLength - 1,
targetY - halfSideLength - 1,
sideLength + 2,
sideLength + 2
)
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'
ctx.lineWidth = 10

hollowProcess({
ctx,
x: targetX,
y: targetY,
width: props.canvasSize[0],
height: props.canvasSize[1]
})

ctx.stroke()
ctx.fill()
ctx.restore()
ctx.globalCompositeOperation = 'destination-over'
ctx.drawImage(image, 0, 0, canvasEl.width, canvasEl.height)
}

function drawImageNextFrame() {
Expand Down
143 changes: 143 additions & 0 deletions components/captcha/hollow-paths.ts
@@ -0,0 +1,143 @@
export interface CaptchaHollowOptions {
ctx: CanvasRenderingContext2D,
/**
* The x coordinate of slide target center
*/
x: number,
/**
* The y coordinate of slide target center
*/
y: number,
/**
* Current canvas width
*/
width: number,
/**
* Current canvas height
*/
height: number
}

/**
* Specify the react of the hollow's shape
*/
export type CaptchaHollowResult = [x: number, y: number, width: number, height: number]
export type CaptchaHollowProcess = (options: CaptchaHollowOptions) => CaptchaHollowResult

export type CaptchaHollowType = 'square' | 'puzzle' | 'shield' | 'heart'

export const squarePath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => {
const side = Math.min(width, height) * 0.25
const halfSide = side * 0.5

ctx.moveTo(x - halfSide, y - halfSide)
ctx.lineTo(x + halfSide, y - halfSide)
ctx.lineTo(x + halfSide, y + halfSide)
ctx.lineTo(x - halfSide, y + halfSide)
ctx.closePath()

return [x - halfSide - 2, y - halfSide - 2, side + 4, side + 4]
}

export const puzzlePath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => {
const side = Math.min(width, height) * 0.2
const halfSide = side * 0.5
const left = x - halfSide
const top = y - halfSide
const radius = side * 0.2

ctx.moveTo(left, top)
ctx.arc(left + halfSide, top - radius + 2, radius, 0.72 * Math.PI, 2.26 * Math.PI)
ctx.lineTo(left + side, top)
ctx.arc(left + side + radius - 2, top + halfSide, radius, 1.21 * Math.PI, 2.78 * Math.PI)
ctx.lineTo(left + side, top + side)
ctx.lineTo(left, top + side)
ctx.arc(left + radius - 2, top + halfSide, radius + 0.4, 2.76 * Math.PI, 1.24 * Math.PI, true)
ctx.lineTo(left, top)

return [x - halfSide - 2, y - side * 0.9 - 2, side * 1.4 + 4, side * 1.4 + 4]
}

export const shieldPath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => {
const side = Math.min(width, height) * 0.25
const halfSide = side * 0.5

ctx.moveTo(x, y - halfSide)
ctx.bezierCurveTo(
x,
y - halfSide + side * 0.05,
x - halfSide + side * 0.3,
y - halfSide * 0.5 + side * 0.1,
x - halfSide,
y - halfSide * 0.7
)
ctx.bezierCurveTo(x - halfSide, y + side * 0.3, x - side * 0.1, y + halfSide, x, y + halfSide)
ctx.bezierCurveTo(
x + side * 0.1,
y + halfSide,
x + halfSide,
y + side * 0.3,
x + halfSide,
y - halfSide * 0.7
)
ctx.bezierCurveTo(
x + halfSide - side * 0.3,
y - halfSide * 0.5 + side * 0.1,
x,
y - halfSide + side * 0.05,
x,
y - halfSide
)

return [x - halfSide - 2, y - halfSide - 2, side + 4, side + 4]
}

export const heartPath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => {
const side = Math.min(width, height) * 0.25
const halfSide = side * 0.5

ctx.moveTo(x, y - side * 0.25)
ctx.bezierCurveTo(
x,
y - side * 0.4,
x - side * 0.1,
y - halfSide,
x - halfSide * 0.5,
y - halfSide
)
ctx.bezierCurveTo(
x - halfSide * 0.5 - side * 0.1,
y - halfSide,
x - halfSide,
y - side * 0.4,
x - halfSide,
y - side * 0.2
)
ctx.bezierCurveTo(
x - halfSide,
y + side * 0.2,
x - side * 0.05,
y + halfSide * 0.8,
x,
y + halfSide * 0.8
)
ctx.bezierCurveTo(
x + side * 0.05,
y + halfSide * 0.8,
x + halfSide,
y + side * 0.2,
x + halfSide,
y - side * 0.2
)
ctx.bezierCurveTo(
x + halfSide,
y - side * 0.4,
x + halfSide * 0.5 + side * 0.1,
y - halfSide,
x + halfSide * 0.5,
y - halfSide
)
ctx.bezierCurveTo(x + side * 0.1, y - halfSide, x, y - side * 0.4, x, y - side * 0.25)

return [x - halfSide - 2, y - halfSide - 2, side + 4, side * 0.9 + 4]
}
1 change: 1 addition & 0 deletions components/captcha/index.ts
@@ -1,4 +1,5 @@
export { default as Captcha } from './captcha'

export type { CaptchaProps, CaptchaCProps } from './props'
export type * from './hollow-paths'
export type { CaptchaType, CaptchaBeforeTest, CaptchaExposed } from './symbol'
2 changes: 2 additions & 0 deletions components/captcha/props.ts
Expand Up @@ -10,6 +10,7 @@ import {
import type { ExtractPropTypes, PropType } from 'vue'
import type { IconEffect } from '@/components/icon'
import type { ConfigurableProps, EventListener } from '@vexip-ui/config'
import type { CaptchaHollowProcess, CaptchaHollowType } from './hollow-paths'
import type { CaptchaBeforeTest, CaptchaType, SuccessEvent } from './symbol'

export const captchaProps = buildProps({
Expand All @@ -36,6 +37,7 @@ export const captchaProps = buildProps({
triggerText: String,
transfer: booleanStringProp,
hideDelay: Number,
hollowShape: [String, Function] as PropType<CaptchaHollowType | CaptchaHollowProcess>,
onSuccess: eventProp<EventListener<SuccessEvent>>(),
onFail: eventProp(),
onDragStart: eventProp<(percent: number) => void>(),
Expand Down
36 changes: 36 additions & 0 deletions docs/demos/captcha/hollow-shape/demo.en-US.vue
@@ -0,0 +1,36 @@
<template>
<Space vertical>
<RadioGroup v-model:value="currentShape" button :options="shapes"></RadioGroup>
<Captcha
ref="captcha"
image="/picture-3.jpg"
:hollow-shape="currentShape === 'circle' ? circlePath : currentShape"
></Captcha>
<Button type="primary" @click="captcha?.reset()">
Reset Captcha
</Button>
</Space>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { CaptchaExposed, CaptchaHollowProcess } from 'vexip-ui'
const shapes = ['square', 'puzzle', 'shield', 'heart', 'circle'] as const
const currentShape = ref(shapes[0])
const captcha = ref<CaptchaExposed>()
watch(currentShape, () => captcha.value?.reset())
const circlePath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => {
const side = Math.min(width, height) * 0.25
const halfSide = side * 0.5
ctx.arc(x, y, halfSide, 0, 2 * Math.PI)
// Must return a rect array specifying the range of hollow
return [x - halfSide, y - halfSide, side, side]
}
</script>

0 comments on commit 21e675e

Please sign in to comment.