- Create migration for BrainstormSession, BrainstormIdea, BrainstormNoteRef, BrainstormParticipant, BrainstormActivity, BrainstormShare, BrainstormSnapshot - Ghost cursor: only moves toward a target element, no random wandering, 120ms interval instead of 60fps, hidden when no target - Remove animate-ping that caused visual noise
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useRef } from 'react'
|
|
|
|
interface GhostCursorProps {
|
|
isActive: boolean
|
|
containerRef: React.RefObject<HTMLDivElement | null>
|
|
targetId?: string | null
|
|
}
|
|
|
|
export function GhostCursor({ isActive, containerRef, targetId }: GhostCursorProps) {
|
|
const elRef = useRef<HTMLDivElement>(null)
|
|
const rafRef = useRef<number | null>(null)
|
|
const posRef = useRef<{ x: number; y: number; visible: boolean }>({ x: 0, y: 0, visible: false })
|
|
const targetIdRef = useRef(targetId)
|
|
targetIdRef.current = targetId
|
|
const isActiveRef = useRef(isActive)
|
|
isActiveRef.current = isActive
|
|
|
|
useEffect(() => {
|
|
if (!isActive) {
|
|
posRef.current.visible = false
|
|
if (elRef.current) elRef.current.style.opacity = '0'
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
|
return
|
|
}
|
|
|
|
let lastMove = 0
|
|
const MOVE_INTERVAL = 120
|
|
|
|
const tick = () => {
|
|
if (!isActiveRef.current) return
|
|
|
|
const now = performance.now()
|
|
if (now - lastMove < MOVE_INTERVAL) {
|
|
rafRef.current = requestAnimationFrame(tick)
|
|
return
|
|
}
|
|
lastMove = now
|
|
|
|
const container = containerRef.current
|
|
if (!container) { rafRef.current = requestAnimationFrame(tick); return }
|
|
const containerRect = container.getBoundingClientRect()
|
|
if (containerRect.width === 0 || containerRect.height === 0) { rafRef.current = requestAnimationFrame(tick); return }
|
|
|
|
const currentTargetId = targetIdRef.current
|
|
let tx: number | null = null
|
|
let ty: number | null = null
|
|
|
|
if (currentTargetId) {
|
|
const nodeElement = container.querySelector(`[data-id="${currentTargetId}"]`) as HTMLElement | null
|
|
if (nodeElement) {
|
|
const nodeRect = nodeElement.getBoundingClientRect()
|
|
tx = nodeRect.left - containerRect.left + nodeRect.width / 2
|
|
ty = nodeRect.top - containerRect.top + nodeRect.height / 2
|
|
}
|
|
}
|
|
|
|
if (tx === null || ty === null) {
|
|
if (posRef.current.visible) {
|
|
posRef.current.visible = false
|
|
if (elRef.current) elRef.current.style.opacity = '0'
|
|
}
|
|
rafRef.current = requestAnimationFrame(tick)
|
|
return
|
|
}
|
|
|
|
const pos = posRef.current
|
|
const speed = 0.15
|
|
const newX = pos.x + (tx - pos.x) * speed
|
|
const newY = pos.y + (ty - pos.y) * speed
|
|
|
|
if (!pos.visible) {
|
|
pos.x = tx
|
|
pos.y = ty
|
|
pos.visible = true
|
|
if (elRef.current) {
|
|
elRef.current.style.opacity = '1'
|
|
elRef.current.style.transform = `translate(${tx}px, ${ty}px)`
|
|
}
|
|
} else {
|
|
pos.x = isNaN(newX) ? tx : newX
|
|
pos.y = isNaN(newY) ? ty : newY
|
|
if (elRef.current) {
|
|
elRef.current.style.transform = `translate(${pos.x}px, ${pos.y}px)`
|
|
}
|
|
}
|
|
|
|
rafRef.current = requestAnimationFrame(tick)
|
|
}
|
|
|
|
rafRef.current = requestAnimationFrame(tick)
|
|
|
|
return () => {
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
|
}
|
|
}, [isActive, containerRef])
|
|
|
|
return (
|
|
<div
|
|
ref={elRef}
|
|
className="absolute pointer-events-none z-50"
|
|
style={{
|
|
left: 0,
|
|
top: 0,
|
|
opacity: 0,
|
|
transition: 'opacity 0.4s ease, transform 0.15s ease-out',
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill="#a78bfa" />
|
|
</svg>
|
|
<div className="absolute -top-1 -right-1 w-3 h-3">
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
|
|
</div>
|
|
<div className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg bg-gradient-to-r from-violet-500 to-purple-600">
|
|
AI
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|