aim.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. type Point = { x: number; y: number }
  2. export function createAim(props: {
  3. enabled: () => boolean
  4. active: () => string | undefined
  5. el: () => HTMLElement | undefined
  6. onActivate: (id: string) => void
  7. delay?: number
  8. max?: number
  9. tolerance?: number
  10. edge?: number
  11. }) {
  12. const state = {
  13. locs: [] as Point[],
  14. timer: undefined as number | undefined,
  15. pending: undefined as string | undefined,
  16. over: undefined as string | undefined,
  17. last: undefined as Point | undefined,
  18. }
  19. const delay = props.delay ?? 250
  20. const max = props.max ?? 4
  21. const tolerance = props.tolerance ?? 80
  22. const edge = props.edge ?? 18
  23. const cancel = () => {
  24. if (state.timer !== undefined) clearTimeout(state.timer)
  25. state.timer = undefined
  26. state.pending = undefined
  27. }
  28. const reset = () => {
  29. cancel()
  30. state.over = undefined
  31. state.last = undefined
  32. state.locs.length = 0
  33. }
  34. const move = (event: MouseEvent) => {
  35. if (!props.enabled()) return
  36. const el = props.el()
  37. if (!el) return
  38. const rect = el.getBoundingClientRect()
  39. const x = event.clientX
  40. const y = event.clientY
  41. if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
  42. state.locs.push({ x, y })
  43. if (state.locs.length > max) state.locs.shift()
  44. }
  45. const wait = () => {
  46. if (!props.enabled()) return 0
  47. if (!props.active()) return 0
  48. const el = props.el()
  49. if (!el) return 0
  50. if (state.locs.length < 2) return 0
  51. const rect = el.getBoundingClientRect()
  52. const loc = state.locs[state.locs.length - 1]
  53. if (!loc) return 0
  54. const prev = state.locs[0] ?? loc
  55. if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0
  56. if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0
  57. if (rect.right - loc.x <= edge) {
  58. state.last = loc
  59. return delay
  60. }
  61. const upper = { x: rect.right, y: rect.top - tolerance }
  62. const lower = { x: rect.right, y: rect.bottom + tolerance }
  63. const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x)
  64. const decreasing = slope(loc, upper)
  65. const increasing = slope(loc, lower)
  66. const prevDecreasing = slope(prev, upper)
  67. const prevIncreasing = slope(prev, lower)
  68. if (decreasing < prevDecreasing && increasing > prevIncreasing) {
  69. state.last = loc
  70. return delay
  71. }
  72. state.last = undefined
  73. return 0
  74. }
  75. const activate = (id: string) => {
  76. cancel()
  77. props.onActivate(id)
  78. }
  79. const request = (id: string) => {
  80. if (!id) return
  81. if (props.active() === id) return
  82. if (!props.active()) {
  83. activate(id)
  84. return
  85. }
  86. const ms = wait()
  87. if (ms === 0) {
  88. activate(id)
  89. return
  90. }
  91. cancel()
  92. state.pending = id
  93. state.timer = window.setTimeout(() => {
  94. state.timer = undefined
  95. if (state.pending !== id) return
  96. state.pending = undefined
  97. if (!props.enabled()) return
  98. if (!props.active()) return
  99. if (state.over !== id) return
  100. props.onActivate(id)
  101. }, ms)
  102. }
  103. const enter = (id: string, event: MouseEvent) => {
  104. if (!props.enabled()) return
  105. state.over = id
  106. move(event)
  107. request(id)
  108. }
  109. const leave = (id: string) => {
  110. if (state.over === id) state.over = undefined
  111. if (state.pending === id) cancel()
  112. }
  113. return { move, enter, leave, activate, request, cancel, reset }
  114. }