|
1 | 1 | // [AI DISCLAIMER: AI was used to help debug socket emit for timed tests; lines 394-408] |
2 | 2 |
|
3 | | -import { useState, useEffect, useRef, useLayoutEffect } from 'react'; |
| 3 | +import { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'; |
4 | 4 | import { useRace } from '../context/RaceContext'; |
5 | 5 | import { useSocket } from '../context/SocketContext'; |
6 | 6 | import playKeySound from './Sound.jsx'; |
@@ -65,6 +65,57 @@ function Typing({ |
65 | 65 | return (getComputedStyle(document.documentElement).getPropertyValue('--glide-cursor-enabled') || '0').trim() === '1'; |
66 | 66 | }); |
67 | 67 | const initialCursorSetRef = useRef(false); |
| 68 | + const initialCursorStyle = typeof document !== 'undefined' |
| 69 | + ? (document.documentElement.getAttribute('data-cursor') || 'block') |
| 70 | + : 'block'; |
| 71 | + const cursorStyleRef = useRef(initialCursorStyle); |
| 72 | + const [cursorStyle, setCursorStyle] = useState(initialCursorStyle); |
| 73 | + const typingActiveRef = useRef(false); |
| 74 | + const [typingActive, setTypingActive] = useState(false); |
| 75 | + |
| 76 | + const setTypingActiveState = useCallback((active) => { |
| 77 | + if (typingActiveRef.current === active) return; |
| 78 | + typingActiveRef.current = active; |
| 79 | + setTypingActive(active); |
| 80 | + if (typeof document !== 'undefined') { |
| 81 | + const overlay = cursorRef.current; |
| 82 | + if (overlay) { |
| 83 | + if (cursorStyleRef.current === 'caret') { |
| 84 | + overlay.classList.toggle('typing-active', active); |
| 85 | + } else { |
| 86 | + overlay.classList.remove('typing-active'); |
| 87 | + } |
| 88 | + } |
| 89 | + const mode = cursorStyleRef.current === 'caret' && active ? 'solid' : 'blink'; |
| 90 | + document.documentElement.setAttribute('data-caret-blink', mode); |
| 91 | + } |
| 92 | + }, []); |
| 93 | + |
| 94 | + useEffect(() => { |
| 95 | + if (typeof document === 'undefined') return undefined; |
| 96 | + return () => { |
| 97 | + document.documentElement.removeAttribute('data-caret-blink'); |
| 98 | + }; |
| 99 | + }, []); |
| 100 | + |
| 101 | + useEffect(() => { |
| 102 | + if (typeof document === 'undefined') return undefined; |
| 103 | + const root = document.documentElement; |
| 104 | + const syncCursorStyle = () => { |
| 105 | + const next = root.getAttribute('data-cursor') || 'block'; |
| 106 | + cursorStyleRef.current = next; |
| 107 | + setCursorStyle(next); |
| 108 | + if (next !== 'caret') { |
| 109 | + setTypingActiveState(false); |
| 110 | + } else if (!typingActiveRef.current) { |
| 111 | + document.documentElement.setAttribute('data-caret-blink', 'blink'); |
| 112 | + } |
| 113 | + }; |
| 114 | + syncCursorStyle(); |
| 115 | + const observer = new MutationObserver(syncCursorStyle); |
| 116 | + observer.observe(root, { attributes: true, attributeFilter: ['data-cursor'] }); |
| 117 | + return () => observer.disconnect(); |
| 118 | + }, [setTypingActiveState]); |
68 | 119 |
|
69 | 120 | // Use testMode and testDuration for timed tests if provided |
70 | 121 | useEffect(() => { |
@@ -200,6 +251,15 @@ function Typing({ |
200 | 251 | }, [typingState.position]); |
201 | 252 |
|
202 | 253 | // Track snippet changes to reset input |
| 254 | + useEffect(() => { |
| 255 | + const active = ((raceState.inProgress || raceState.type === 'practice') && input.length > 0 && !typingState.completed); |
| 256 | + setTypingActiveState(active); |
| 257 | + }, [input.length, raceState.inProgress, raceState.type, typingState.completed, setTypingActiveState]); |
| 258 | + |
| 259 | + useEffect(() => { |
| 260 | + return () => setTypingActiveState(false); |
| 261 | + }, [setTypingActiveState]); |
| 262 | + |
203 | 263 | useEffect(() => { |
204 | 264 | if (raceState.snippet && raceState.snippet.id !== snippetId) { |
205 | 265 | setSnippetId(raceState.snippet.id); |
@@ -751,17 +811,26 @@ function Typing({ |
751 | 811 | const y = Math.round((rect.top - containerRect.top) + scrollY); |
752 | 812 |
|
753 | 813 | // Determine caret vs block based on Settings-managed CSS var |
754 | | - const useCaret = (document.documentElement.getAttribute('data-cursor') === 'caret'); |
| 814 | + const useCaret = (cursorStyleRef.current === 'caret'); |
755 | 815 |
|
756 | 816 | // Size overlay to target element |
757 | 817 | const height = rect.height; |
758 | 818 | const width = useCaret ? 0 : rect.width; // caret drawn via border-left for crispness |
759 | 819 | overlay.style.height = `${height}px`; |
760 | 820 | overlay.style.width = `${width}px`; |
761 | | - overlay.className = `cursor-overlay ${useCaret ? 'caret' : 'block'}`; |
| 821 | + overlay.className = 'cursor-overlay'; |
| 822 | + if (useCaret) { |
| 823 | + overlay.classList.add('caret'); |
| 824 | + overlay.classList.remove('block'); |
| 825 | + overlay.classList.toggle('typing-active', typingActiveRef.current); |
| 826 | + } else { |
| 827 | + overlay.classList.add('block'); |
| 828 | + overlay.classList.remove('caret'); |
| 829 | + overlay.classList.remove('typing-active'); |
| 830 | + } |
762 | 831 |
|
763 | 832 | // Cursor-specific duration (caret snappier) |
764 | | - overlay.style.setProperty('--cursor-glide-duration', useCaret ? '95ms' : '95ms'); |
| 833 | + overlay.style.setProperty('--cursor-glide-duration', useCaret ? '85ms' : '110ms'); |
765 | 834 |
|
766 | 835 | // First placement should not animate from origin |
767 | 836 | if (!initialCursorSetRef.current) { |
@@ -1019,14 +1088,16 @@ function Typing({ |
1019 | 1088 | }; |
1020 | 1089 | }, []); |
1021 | 1090 |
|
| 1091 | + const caretSolid = typingActive && cursorStyle === 'caret'; |
| 1092 | + |
1022 | 1093 | return ( |
1023 | 1094 | <> |
1024 | 1095 | <div className="stats-container">{getStatsContent()}</div> |
1025 | 1096 |
|
1026 | 1097 | {/* Only show typing area (snippet + input) if race is NOT completed */} |
1027 | 1098 | {!raceState.completed && ( |
1028 | 1099 | <div className="typing-area"> |
1029 | | - <div className={`snippet-display ${isShaking ? 'shake-animation' : ''} ${glideEnabled ? 'glide-on' : ''}`}> |
| 1100 | + <div className={`snippet-display ${isShaking ? 'shake-animation' : ''} ${glideEnabled ? 'glide-on' : ''} ${caretSolid ? 'caret-solid' : ''}`}> |
1030 | 1101 | {/* Smooth-glide overlay cursor (rendered behind text) */} |
1031 | 1102 | <div ref={cursorRef} className="cursor-overlay block" aria-hidden="true" /> |
1032 | 1103 | {/* Error message moved OUTSIDE snippet-display */} |
|
0 commit comments