Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 253 additions & 20 deletions app/src/components/Demo/Mouse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,52 +6,260 @@ interface State {
enabled: boolean
target: { x: number; y: number }
position: { x: number; y: number }
stepSizeX: number
stepSizeY: number
startPosition: { x: number; y: number }
startTime: number
duration: number
clicking: boolean
jitterSeed: number
overshootTarget: { x: number; y: number } | null
isOvershootCorrection: boolean
}

class Demo extends React.Component<{ classes: any }, State> {
private timer: any
private clickTimer: any
private frameInterval = 20
private readonly correctionDelayMs = 50 // Delay before overshoot correction starts

constructor(props: any) {
super(props)
this.state = { enabled: false, target: { x: 0, y: 0 }, position: { x: 0, y: 0 }, stepSizeX: 1, stepSizeY: 1 }
this.state = {
enabled: false,
target: { x: 0, y: 0 },
position: { x: 0, y: 0 },
startPosition: { x: 0, y: 0 },
startTime: 0,
duration: 0,
clicking: false,
jitterSeed: Math.random(),
overshootTarget: null,
isOvershootCorrection: false,
}
}

private moveCloser(steps: number = 0) {
const steSizeX = Math.min(this.state.stepSizeX, Math.abs(this.state.position.x - this.state.target.x))
const steSizeY = Math.min(this.state.stepSizeY, Math.abs(this.state.position.y - this.state.target.y))
const dirX = this.state.position.x > this.state.target.x ? -1 : 1
const dirY = this.state.position.y > this.state.target.y ? -1 : 1
/**
* Generates a seeded random value using a simple LCG algorithm
* Better quality than sin-based PRNG for consistent jitter patterns
*/
private seededRandom(seed: number): number {
// Linear Congruential Generator with common constants
const a = 1664525
const c = 1013904223
const m = Math.pow(2, 32)
const x = (a * seed + c) % m
return x / m
}

if (steSizeX <= 0.1 && steSizeY <= 0.1) {
this.timer && clearTimeout(this.timer)
return
/**
* Adds human-like micro-movements to the cursor position
* Small random offsets that don't affect the overall path significantly
*/
private addJitter(x: number, y: number, progress: number): { x: number; y: number } {
// Jitter is strongest in the middle of the movement (more natural)
const jitterStrength = Math.sin(progress * Math.PI) * 2 // 0 at start/end, max at middle

// Use progress to vary the seed for different jitter at each frame
const seed1 = this.state.jitterSeed * 1000 + progress * 100
const seed2 = this.state.jitterSeed * 2000 + progress * 150

const jitterX = (this.seededRandom(seed1) - 0.5) * jitterStrength
const jitterY = (this.seededRandom(seed2) - 0.5) * jitterStrength

return {
x: x + jitterX,
y: y + jitterY,
}
}

/**
* Cubic bezier easing function for natural-looking mouse movement
* Uses control points (0.25, 0.75) for a smooth ease-in-out curve
*/
private cubicBezier(t: number, p0: number, p1: number, p2: number, p3: number): number {
const u = 1 - t
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
}

/**
* Easing function that creates a smooth acceleration and deceleration
* This makes the mouse movement appear more human-like
*/
private easeInOutCubic(t: number): number {
// Cubic bezier approximation for ease-in-out
return this.cubicBezier(t, 0, 0.25, 0.75, 1)
}

/**
* Calculates an overshoot target for more realistic human-like movement
* Returns null if no overshoot, or a point slightly past the target
*/
private calculateOvershootTarget(
startX: number,
startY: number,
targetX: number,
targetY: number
): { x: number; y: number } | null {
const dx = targetX - startX
const dy = targetY - startY
const distance = Math.sqrt(dx * dx + dy * dy)

// Don't overshoot for very short distances (less than 10px)
if (distance < 10) {
return null
}

// 30% chance of overshoot for natural variation
if (this.seededRandom(this.state.jitterSeed * 3) > 0.3) {
return null
}

// Overshoot by 2-8% of the distance
const overshootPercent = 0.02 + this.seededRandom(this.state.jitterSeed * 4) * 0.06
const overshootDistance = distance * overshootPercent

// Overshoot in the direction of movement
const dirX = dx / distance
const dirY = dy / distance

return {
x: targetX + dirX * overshootDistance,
y: targetY + dirY * overshootDistance,
}
}

private moveCloser() {
const elapsed = Date.now() - this.state.startTime
const progress = Math.min(elapsed / (this.state.duration || 1), 1)

// Apply easing function for smooth, human-like movement timing
const easedProgress = this.easeInOutCubic(progress)

// Determine actual target (could be overshoot target or final target)
const actualTarget = this.state.overshootTarget || this.state.target

// Calculate bezier curve control points for a natural arc trajectory
// Instead of moving in a straight line, the cursor follows a curved path
const startX = this.state.startPosition.x
const startY = this.state.startPosition.y
const endX = actualTarget.x
const endY = actualTarget.y

// Create control points for a quadratic bezier curve
// The control point is offset perpendicular to the direct line, creating an arc
const dx = endX - startX
const dy = endY - startY
const distance = Math.sqrt(dx * dx + dy * dy)

// Arc height is proportional to distance (80% of distance, capped at 200px max)
// Quadrupled from original 20% to make the curve highly visible
// For overshoot correction, use smaller arc (more direct)
const arcPercent = this.state.isOvershootCorrection ? 0.3 : 0.8
const arcCap = this.state.isOvershootCorrection ? 50 : 200
const arcHeight = Math.min(distance * arcPercent, arcCap)

// Calculate perpendicular offset for the control point
const perpX = -dy / (distance || 1)
const perpY = dx / (distance || 1)

// Add slight randomness to control point to avoid perfectly predictable curves
const controlPointJitter = this.seededRandom(this.state.jitterSeed * 5) * 20 - 10

// Control point is at the midpoint, offset perpendicular to create an arc
const controlX = (startX + endX) / 2 + perpX * (arcHeight + controlPointJitter)
const controlY = (startY + endY) / 2 + perpY * (arcHeight + controlPointJitter)

// Calculate position on quadratic bezier curve: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
const t = easedProgress
const u = 1 - t
let newX = u * u * startX + 2 * u * t * controlX + t * t * endX
let newY = u * u * startY + 2 * u * t * controlY + t * t * endY

// Add jitter for human-like micro-movements
const jittered = this.addJitter(newX, newY, progress)
newX = jittered.x
newY = jittered.y

this.setState({
position: {
x: this.state.position.x + dirX * steSizeX,
y: this.state.position.y + dirY * steSizeY,
x: newX,
y: newY,
},
})

this.timer = setTimeout(() => {
this.moveCloser(steps + 1)
}, this.frameInterval)
// Continue animation if not complete
if (progress < 1) {
// Add random delay variation (jitter in timing)
// Frame interval varies between 15-25ms for irregular movement
// Use modulo to keep seed in reasonable range
const jitterDelay = this.seededRandom((this.state.jitterSeed * 6 + elapsed) % 100000) * 10 - 5
const nextInterval = Math.max(10, this.frameInterval + jitterDelay)

this.timer = setTimeout(() => {
this.moveCloser()
}, nextInterval)
} else {
this.timer && clearTimeout(this.timer)

// If we just reached overshoot target, start correction to actual target
if (this.state.overshootTarget && !this.state.isOvershootCorrection) {
const correctionDuration = this.state.duration * 0.15 // Correction takes 15% of original time
setTimeout(() => {
this.setState({
startPosition: { x: this.state.position.x, y: this.state.position.y },
target: this.state.target, // Keep original target
overshootTarget: null, // Clear overshoot
isOvershootCorrection: true,
startTime: Date.now(),
duration: correctionDuration,
})
this.moveCloser()
}, this.correctionDelayMs)
} else {
// Reset overshoot state for next movement
this.setState({ isOvershootCorrection: false })
}
}
}

public componentDidMount() {
;(window as any).demo.enableMouse = () => {
this.setState({ enabled: true })
}
;(window as any).demo.moveMouse = (x: number, y: number, animationTime: number) => {
const stepSizeX = Math.abs(this.state.position.x - x) / (animationTime / this.frameInterval)
const stepSizeY = Math.abs(this.state.position.y - y) / (animationTime / this.frameInterval)
this.setState({ stepSizeX, stepSizeY, enabled: true, target: { x, y } })
const newJitterSeed = Math.random()
const currentPos = this.state.position

// Calculate if this movement should have overshoot
const overshoot = this.calculateOvershootTarget(currentPos.x, currentPos.y, x, y)

// If overshoot exists, adjust duration to account for correction time
// Main movement gets 85% of time, correction gets 15%
const mainDuration = overshoot ? animationTime * 0.85 : animationTime

this.setState({
enabled: true,
target: { x, y }, // Store the actual target
overshootTarget: overshoot, // May be null or an overshoot position
isOvershootCorrection: false,
startPosition: { x: currentPos.x, y: currentPos.y },
startTime: Date.now(),
duration: mainDuration,
jitterSeed: newJitterSeed,
})
this.moveCloser()
}
;(window as any).demo.clickMouse = () => {
this.setState({ clicking: true })
this.clickTimer && clearTimeout(this.clickTimer)
this.clickTimer = setTimeout(() => {
this.setState({ clicking: false })
}, 300)
}
}

public componentWillUnmount() {
this.timer && clearTimeout(this.timer)
this.clickTimer && clearTimeout(this.clickTimer)
}

public render() {
Expand All @@ -64,11 +272,26 @@ class Demo extends React.Component<{ classes: any }, State> {
top: this.state.position.y + 2,
}

return <img src={cursor} style={cursorStyle} className={this.props.classes.cursor} />
return (
<>
<img src={cursor} style={cursorStyle} className={this.props.classes.cursor} />
{this.state.clicking && <div style={cursorStyle} className={this.props.classes.clickRipple} />}
</>
)
}
}

const style = (theme: Theme) => ({
'@keyframes clickPulse': {
from: {
transform: 'scale(1)',
opacity: 1,
},
to: {
transform: 'scale(2.5)',
opacity: 0,
},
},
cursor: {
width: '32px',
height: '32px',
Expand All @@ -77,6 +300,16 @@ const style = (theme: Theme) => ({
filter: theme.palette.type === 'light' ? undefined : 'invert(100%)',
pointerEvents: 'none' as 'none',
},
clickRipple: {
width: '48px',
height: '48px',
position: 'fixed' as 'fixed',
zIndex: 1000000 - 1, // Just below cursor
borderRadius: '50%',
border: '4px solid #4CAF50',
animation: '$clickPulse 300ms ease-out',
pointerEvents: 'none' as 'none',
},
})

export default withStyles(style)(Demo)
2 changes: 2 additions & 0 deletions src/spec/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export async function clickOn(

await moveToCenterOfElement(element)
await element.hover()
// Trigger click visualization
await runJavascript('window.demo.clickMouse && window.demo.clickMouse();', element.page())
await element.click({ delay, button, force, clickCount: clicks })
await sleep(50)
}
Expand Down
Loading