mirror of
https://github.com/thomasnordquist/MQTT-Explorer.git
synced 2026-09-11 09:03:33 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633e092ecf | ||
|
|
d6282e6499 | ||
|
|
75d599d2fa | ||
|
|
a3e0f89a32 | ||
|
|
3b5bd45d8c | ||
|
|
daead9273c | ||
|
|
429cc190b4 | ||
|
|
ffd4010561 | ||
|
|
9ebce15dd2 | ||
|
|
180d7b3226 |
@@ -6,40 +6,219 @@ interface State {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
target: { x: number; y: number }
|
target: { x: number; y: number }
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
stepSizeX: number
|
startPosition: { x: number; y: number }
|
||||||
stepSizeY: 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> {
|
class Demo extends React.Component<{ classes: any }, State> {
|
||||||
private timer: any
|
private timer: any
|
||||||
|
private clickTimer: any
|
||||||
private frameInterval = 20
|
private frameInterval = 20
|
||||||
|
private readonly correctionDelayMs = 50 // Delay before overshoot correction starts
|
||||||
|
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props)
|
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))
|
* Generates a seeded random value using a simple LCG algorithm
|
||||||
const steSizeY = Math.min(this.state.stepSizeY, Math.abs(this.state.position.y - this.state.target.y))
|
* Better quality than sin-based PRNG for consistent jitter patterns
|
||||||
const dirX = this.state.position.x > this.state.target.x ? -1 : 1
|
*/
|
||||||
const dirY = this.state.position.y > this.state.target.y ? -1 : 1
|
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)
|
* Adds human-like micro-movements to the cursor position
|
||||||
return
|
* 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({
|
this.setState({
|
||||||
position: {
|
position: {
|
||||||
x: this.state.position.x + dirX * steSizeX,
|
x: newX,
|
||||||
y: this.state.position.y + dirY * steSizeY,
|
y: newY,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.timer = setTimeout(() => {
|
// Continue animation if not complete
|
||||||
this.moveCloser(steps + 1)
|
if (progress < 1) {
|
||||||
}, this.frameInterval)
|
// 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() {
|
public componentDidMount() {
|
||||||
@@ -47,11 +226,40 @@ class Demo extends React.Component<{ classes: any }, State> {
|
|||||||
this.setState({ enabled: true })
|
this.setState({ enabled: true })
|
||||||
}
|
}
|
||||||
;(window as any).demo.moveMouse = (x: number, y: number, animationTime: number) => {
|
;(window as any).demo.moveMouse = (x: number, y: number, animationTime: number) => {
|
||||||
const stepSizeX = Math.abs(this.state.position.x - x) / (animationTime / this.frameInterval)
|
const newJitterSeed = Math.random()
|
||||||
const stepSizeY = Math.abs(this.state.position.y - y) / (animationTime / this.frameInterval)
|
const currentPos = this.state.position
|
||||||
this.setState({ stepSizeX, stepSizeY, enabled: true, target: { x, y } })
|
|
||||||
|
// 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()
|
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() {
|
public render() {
|
||||||
@@ -64,11 +272,26 @@ class Demo extends React.Component<{ classes: any }, State> {
|
|||||||
top: this.state.position.y + 2,
|
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) => ({
|
const style = (theme: Theme) => ({
|
||||||
|
'@keyframes clickPulse': {
|
||||||
|
from: {
|
||||||
|
transform: 'scale(1)',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
transform: 'scale(2.5)',
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
width: '32px',
|
width: '32px',
|
||||||
height: '32px',
|
height: '32px',
|
||||||
@@ -77,6 +300,16 @@ const style = (theme: Theme) => ({
|
|||||||
filter: theme.palette.type === 'light' ? undefined : 'invert(100%)',
|
filter: theme.palette.type === 'light' ? undefined : 'invert(100%)',
|
||||||
pointerEvents: 'none' as 'none',
|
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)
|
export default withStyles(style)(Demo)
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export async function clickOn(
|
|||||||
|
|
||||||
await moveToCenterOfElement(element)
|
await moveToCenterOfElement(element)
|
||||||
await element.hover()
|
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 element.click({ delay, button, force, clickCount: clicks })
|
||||||
await sleep(50)
|
await sleep(50)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user