|
90 | 90 | // so CSS transitions on color have existing elements to animate between. |
91 | 91 | function applyClasses() { |
92 | 92 | logoPre.querySelectorAll<HTMLElement>(".hero-logo-letter").forEach((el) => { |
93 | | - el.classList.toggle("hovered", el.dataset.key === hoveredKey); |
| 93 | + const key = el.dataset.key!; |
| 94 | + el.classList.toggle("hovered", key === hoveredKey); |
| 95 | + el.classList.toggle("pressed", pressedKeys.has(key)); |
94 | 96 | }); |
95 | 97 | } |
96 | 98 |
|
97 | | - // Maps a mouse event to a key by dividing the pre into 19 character columns. |
| 99 | + // Maps a clientX coordinate to a key by dividing the pre into 19 character columns. |
98 | 100 | // h: cols 0–5, d: cols 6–11, i: cols 12–17, col 18 is the trailing '|' (ignored). |
99 | | - function keyFromMouse(e: MouseEvent): string | null { |
| 101 | + function keyFromPoint(clientX: number): string | null { |
100 | 102 | const rect = logoPre.getBoundingClientRect(); |
101 | | - const col = Math.floor((e.clientX - rect.left) / (rect.width / 19)); |
| 103 | + const col = Math.floor((clientX - rect.left) / (rect.width / 19)); |
102 | 104 | return col < 6 ? "h" : col < 12 ? "d" : col < 18 ? "i" : null; |
103 | 105 | } |
104 | 106 |
|
| 107 | + function releaseAll() { |
| 108 | + if (pressedKeys.size === 0) return; |
| 109 | + pressedKeys.clear(); |
| 110 | + render(); |
| 111 | + } |
| 112 | + |
| 113 | + function pressTouches(e: TouchEvent) { |
| 114 | + e.preventDefault(); |
| 115 | + pressedKeys.clear(); |
| 116 | + for (const touch of e.touches) { |
| 117 | + const key = keyFromPoint(touch.clientX); |
| 118 | + if (key) pressedKeys.add(key); |
| 119 | + } |
| 120 | + render(); |
| 121 | + } |
| 122 | + |
105 | 123 | // Mouse |
106 | 124 | logoPre.addEventListener("mousemove", (e) => { |
107 | | - const key = keyFromMouse(e); |
108 | | - if (key === hoveredKey) return; |
109 | | - hoveredKey = key; |
110 | | - applyClasses(); // class-only update — preserves spans so transition fires |
| 125 | + const key = keyFromPoint(e.clientX); |
| 126 | + if (key !== hoveredKey) { |
| 127 | + hoveredKey = key; |
| 128 | + applyClasses(); |
| 129 | + } |
| 130 | + if (e.buttons === 1) { |
| 131 | + pressedKeys.clear(); |
| 132 | + if (key) pressedKeys.add(key); |
| 133 | + render(); |
| 134 | + } |
111 | 135 | }); |
112 | 136 | logoPre.addEventListener("mouseleave", () => { |
113 | 137 | if (hoveredKey === null) return; |
114 | 138 | hoveredKey = null; |
115 | 139 | applyClasses(); |
116 | 140 | }); |
117 | 141 | logoPre.addEventListener("mousedown", (e) => { |
118 | | - const key = keyFromMouse(e); |
| 142 | + const key = keyFromPoint(e.clientX); |
119 | 143 | if (key) { |
120 | 144 | pressedKeys.add(key); |
121 | 145 | render(); |
122 | 146 | } |
123 | 147 | }); |
124 | | - document.addEventListener("mouseup", () => { |
125 | | - if (pressedKeys.size === 0) return; |
126 | | - pressedKeys.clear(); |
127 | | - render(); |
128 | | - }); |
| 148 | + document.addEventListener("mouseup", releaseAll); |
| 149 | + |
| 150 | + // Touch |
| 151 | + logoPre.addEventListener("touchstart", pressTouches, { passive: false }); |
| 152 | + logoPre.addEventListener("touchmove", pressTouches, { passive: false }); |
| 153 | + document.addEventListener("touchend", releaseAll); |
| 154 | + document.addEventListener("touchcancel", releaseAll); |
129 | 155 |
|
130 | 156 | // Keyboard — skip when focus is in a text input to avoid interfering with typing |
131 | 157 | const KEYS = new Set(["h", "d", "i"]); |
|
160 | 186 | .hero-logo-letter { |
161 | 187 | color: var(--green); |
162 | 188 | transition: all 150ms linear; |
163 | | - &.hovered { |
| 189 | + &.hovered, |
| 190 | + &.pressed { |
164 | 191 | color: var(--mauve); |
165 | 192 | } |
166 | 193 | } |
|
0 commit comments