comment-hover.ts 2.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. export type HoverCommentLine = {
  2. lineNumber: number
  3. side?: "additions" | "deletions"
  4. }
  5. export function createHoverCommentUtility(props: {
  6. label: string
  7. getHoveredLine: () => HoverCommentLine | undefined
  8. onSelect: (line: HoverCommentLine) => void
  9. }) {
  10. if (typeof document === "undefined") return
  11. const button = document.createElement("button")
  12. button.type = "button"
  13. button.ariaLabel = props.label
  14. button.textContent = "+"
  15. button.style.width = "20px"
  16. button.style.height = "20px"
  17. button.style.display = "flex"
  18. button.style.alignItems = "center"
  19. button.style.justifyContent = "center"
  20. button.style.border = "none"
  21. button.style.borderRadius = "var(--radius-md)"
  22. button.style.background = "var(--icon-interactive-base)"
  23. button.style.color = "var(--white)"
  24. button.style.boxShadow = "var(--shadow-xs)"
  25. button.style.fontSize = "14px"
  26. button.style.lineHeight = "1"
  27. button.style.cursor = "pointer"
  28. button.style.position = "relative"
  29. button.style.left = "30px"
  30. button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
  31. let line: HoverCommentLine | undefined
  32. const sync = () => {
  33. const next = props.getHoveredLine()
  34. if (!next) return
  35. line = next
  36. }
  37. // The hovered line changes when the pointer moves or when content scrolls under
  38. // a stationary pointer, so track both with passive listeners instead of polling
  39. // every animation frame. There is no teardown hook for this utility; like the
  40. // rAF loop this replaced, the listeners self-detach on the first event after
  41. // the button leaves the DOM.
  42. const onHoverInvalidated = () => {
  43. if (!button.isConnected) {
  44. document.removeEventListener("pointermove", onHoverInvalidated)
  45. document.removeEventListener("scroll", onHoverInvalidated, true)
  46. return
  47. }
  48. sync()
  49. }
  50. const open = () => {
  51. const next = props.getHoveredLine() ?? line
  52. if (!next) return
  53. props.onSelect(next)
  54. }
  55. document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
  56. document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
  57. button.addEventListener("mouseenter", sync)
  58. button.addEventListener("mousemove", sync)
  59. button.addEventListener("pointerdown", (event) => {
  60. event.preventDefault()
  61. event.stopPropagation()
  62. sync()
  63. })
  64. button.addEventListener("mousedown", (event) => {
  65. event.preventDefault()
  66. event.stopPropagation()
  67. sync()
  68. })
  69. button.addEventListener("click", (event) => {
  70. event.preventDefault()
  71. event.stopPropagation()
  72. open()
  73. })
  74. return button
  75. }