Skip to content

Commit fdcdea6

Browse files
committed
Improve terminal typing auto-scroll
1 parent d1c2914 commit fdcdea6

1 file changed

Lines changed: 157 additions & 96 deletions

File tree

src/pages/Home.jsx

Lines changed: 157 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -77,131 +77,93 @@ const TERMINAL_STAGE_START = CAPTION_STAGE_END + CAPTION_POST_PAUSE;
7777
const TERMINAL_STAGE_END = TERMINAL_STAGE_START + TERMINAL_STAGE_LENGTH;
7878
const TERMINAL_TYPE_TRAIL = 0.8;
7979
const TERMINAL_CHAR_SCROLL_MULT = 20;
80-
const TERMINAL_SCROLL_HEADROOM = 0.02; // start auto-scroll slightly before text would clip
80+
const TERMINAL_AUTO_CHAR_RATE = 45; // baseline characters per second when auto-typing kicks in
81+
const TERMINAL_SCROLL_MARGIN_RATIO = 0.12; // keep ~12% of viewport visible above latest line
82+
const TERMINAL_SCROLL_STICKY_DISTANCE = 80; // px distance from bottom treated as "pinned"
8183
const TEXT_SLOWNESS = 7;
8284
const MAX_PROGRESS = TERMINAL_STAGE_END + TERMINAL_TYPE_TRAIL;
8385
const terminalEntries = [
8486
{
8587
command: 'whoami',
86-
output: ['Community member. Here because something on your phone went boom.'],
88+
output: ['=> Community member. Here because something on your phone went boom.'],
8789
},
8890
{
8991
command: 'hostname',
90-
output: ['forums.jtechforums.org'],
92+
output: ['=> forums.jtechforums.org'],
9193
},
9294
{
9395
command: 'uname -a',
94-
output: ['Linux JTech-Server #1 running stable since the last "how do I block WhatsApp Status" post.'],
95-
},
96-
{
97-
command: 'uptime',
98-
output: ['Forum active for years. Repeat questions answered: too many to count.'],
96+
output: ['=> Linux JTech-Server #1 running stable since the last “how do I block WhatsApp Status” post.'],
9997
},
10098
{
10199
command: 'id',
102-
output: ['uid=1000(you) groups=android_help,filters,mdm,panic-posters'],
100+
output: ['=> uid=1000(you) groups=android_linux,windows,mac,panic-posters'],
103101
},
104102
{
105103
command: 'df -h',
106-
output: ['87% space used by screenshots people uploaded instead of describing the issue.'],
107-
},
108-
{
109-
command: 'free -h',
110-
output: ['RAM consumed by filter debates: 99%', ' RAM available for patience: questionable'],
111-
},
112-
{
113-
command: 'ls ~/Forums',
114-
output: ['Guides/ Android/ Filters/ KosherPhones/ "Help-I-broke-my-device"/'],
115-
},
116-
{
117-
command: 'cat Guides/mission.txt',
118-
output: [
119-
'Provide precise, up-to-date tech & filtering help to the Jewish community.',
120-
' Practical, kosher, zero balagan.',
121-
],
122-
},
123-
{
124-
command: 'faq --question="What is JTech Forums?"',
125-
output: [
126-
'A safe community for filtered tech, kosher phones, apps, MDM help,',
127-
' and level-headed troubleshooting.',
128-
],
104+
output: ['=> 87% space used by screenshots people uploaded instead of describing the situation.'],
129105
},
130106
{
131107
command: 'faq --question="Is everything kosher here?"',
132108
output: [
133-
'Yes. Strict content standards. No inappropriate links.',
134-
' This forum is built for families -- and indexed by Google.',
109+
'=> Yes. Strict content standards. No inappropriate links.',
110+
' This forum is built for families and indexed by Google.',
135111
' Keep it clean.',
136112
],
137113
},
138114
{
139115
command: 'faq --question="Can I post any random files?"',
140-
output: ['No Google Drive dumps.', ' No mystery APKs.', ' Only trusted, safe sources allowed.'],
141-
},
142-
{
143-
command: 'faq --question="Why was my post removed?"',
144116
output: [
145-
'Probably:',
146-
' - off-topic',
147-
' - not family-safe',
148-
' - linked to who-knows-where',
149-
' - or your cousin\'s "hack" wasn\'t actually a hack.',
117+
'=> No Google Drive dumps.',
118+
' No mystery APKs.',
119+
' Only trusted, safe sources allowed.',
150120
],
151121
},
152122
{
153-
command: 'grep -R "Whatsapp" ./Android',
154-
output: ['182 results... mostly "how block Status???"', ' Solutions exist. Take a deep breath.'],
155-
},
156-
{
157-
command: 'grep -R "MDM" ./Filters',
158-
output: ['Found: install guides, fix guides, and "pls help my kid found Developer Options"'],
159-
},
160-
{
161-
command: 'curl https://forums.jtechforums.org/api/stats',
162-
output: [
163-
'signups_last_30_days: 305+',
164-
' page_visits: 100000+',
165-
' posts: 15000+',
166-
' people_helped: countless',
167-
],
123+
command: 'grep -R "MDM" ./Hacking',
124+
output: ['=> Found: download and install guides, fix guides, and “pls help my kid found Developer Options”'],
168125
},
169126
{
170127
command: 'top -b -n1 | head',
171128
output: [
172-
'PID TASK CPU DESCRIPTION',
173-
'101 mods 98% deleting nonsense',
174-
'203 helpers 75% answering same question for 12th time',
175-
'404 panicd 60% "I factory reset and now nothing works plz help"',
129+
'=> PID TASK CPU DESCRIPTION',
130+
'=> 101 mods 98% deleting nonsense',
131+
'=> 203 helpers 75% answering same question for 12th time',
132+
'=> 404 panicd 60% I factory reset and now nothing works plz help',
176133
],
177134
},
178135
{
179136
command: 'sudo -l',
180-
output: ['Sorry, you don\'t have moderator permissions. Nice try though.'],
137+
output: ['=> Sorry, you dont have moderator permissions. Nice try though.'],
181138
},
182139
{
183140
command: 'dmesg | tail',
184-
output: ['ALERT: new thread detected about FIG phones being "hacked"', 'RESPONSE: highly unlikely. They\'re not magic, relax.'],
141+
output: [
142+
'=> ALERT: new thread detected about FIG phones being “hacked”',
143+
'=> RESPONSE: highly unlikely. They’re not magic, relax.',
144+
],
185145
},
186146
{
187147
command: 'stat ~/first_post.txt',
188-
output: ['size: 0 bytes', 'meaning: please include details when asking for help.'],
189-
},
190-
{
191-
command: 'join --mode=read',
192-
output: ['Browse guides. Absorb knowledge. Enjoy the calm.'],
148+
output: ['=> size: 0 bytes', '=> meaning: please include details when asking for help.'],
193149
},
194150
{
195151
command: 'join --mode=ask',
196-
output: ['Provide device, model, filter, and what you tried.', ' The more info you give, the faster you\'ll get helped.'],
152+
output: [
153+
'=> Provide device, model, clear issue, and what you tried.',
154+
' The more info you give, the faster you’ll get helped.',
155+
],
197156
},
198157
{
199-
command: 'join --mode=help-others',
200-
output: ['Share your fixes. Share your setups.', ' Someone out there needs exactly what you figured out.'],
158+
command: 'exit',
159+
output: [
160+
'=> JTech Forums — keeping your tech smooth, your devices kosher,',
161+
' and your sanity intact.',
162+
],
201163
},
202164
{
203-
command: 'exit',
204-
output: ['JTech Forums -- keeping your tech smooth, your phones kosher,', ' and your sanity intact.'],
165+
command: '',
166+
output: [' '],
205167
},
206168
];
207169

@@ -228,6 +190,9 @@ export default function Home() {
228190
const typingAccumulatorRef = useRef(0);
229191
const terminalTypedCharsRef = useRef(0);
230192
const terminalScrollingRef = useRef(null);
193+
const terminalPinnedRef = useRef(true);
194+
const typedLineCountRef = useRef(0);
195+
const progressRef = useRef(0);
231196
const [leaderboardState, setLeaderboardState] = useState({
232197
entries: [],
233198
status: 'idle',
@@ -245,15 +210,31 @@ export default function Home() {
245210
[terminalEntries]
246211
);
247212

248-
useEffect(() => {
249-
if (typeof document === 'undefined') return;
250-
const previousOverflow = document.body.style.overflow;
251-
document.body.style.overflow = 'hidden';
252-
return () => {
213+
useEffect(() => {
214+
if (typeof document === 'undefined') return;
215+
const previousOverflow = document.body.style.overflow;
216+
document.body.style.overflow = 'hidden';
217+
return () => {
253218
document.body.style.overflow = previousOverflow;
254219
};
255220
}, []);
256221

222+
useEffect(() => {
223+
progressRef.current = progress;
224+
}, [progress]);
225+
226+
useEffect(() => {
227+
const scroller = terminalScrollingRef.current;
228+
if (!scroller) return undefined;
229+
const handleScroll = () => {
230+
const distanceFromBottom = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
231+
terminalPinnedRef.current = distanceFromBottom <= TERMINAL_SCROLL_STICKY_DISTANCE;
232+
};
233+
handleScroll();
234+
scroller.addEventListener('scroll', handleScroll, { passive: true });
235+
return () => scroller.removeEventListener('scroll', handleScroll);
236+
}, []);
237+
257238
useEffect(() => {
258239
let rafId;
259240
const tick = () => {
@@ -320,6 +301,64 @@ export default function Home() {
320301
}
321302
}, [progress]);
322303

304+
useEffect(() => {
305+
if (typeof window === 'undefined' || totalTerminalChars <= 0) return undefined;
306+
let rafId = null;
307+
let lastTime = window.performance.now();
308+
309+
const tick = (time) => {
310+
const deltaSeconds = Math.max(0, (time - lastTime) / 1000);
311+
lastTime = time;
312+
const autoActive =
313+
progressRef.current >= TERMINAL_STAGE_START && terminalTypedCharsRef.current < totalTerminalChars;
314+
if (autoActive && deltaSeconds > 0) {
315+
const charDelta = deltaSeconds * TERMINAL_AUTO_CHAR_RATE;
316+
if (charDelta > 0) {
317+
setTerminalTypedChars((prev) => {
318+
if (prev >= totalTerminalChars) return prev;
319+
return Math.min(totalTerminalChars, prev + charDelta);
320+
});
321+
}
322+
}
323+
rafId = window.requestAnimationFrame(tick);
324+
};
325+
326+
rafId = window.requestAnimationFrame((time) => {
327+
lastTime = time;
328+
tick(time);
329+
});
330+
331+
return () => window.cancelAnimationFrame(rafId);
332+
}, [totalTerminalChars]);
333+
334+
useEffect(() => {
335+
const scroller = terminalScrollingRef.current;
336+
if (!scroller || !terminalPinnedRef.current) return;
337+
const available = scroller.scrollHeight - scroller.clientHeight;
338+
if (available <= 0) {
339+
scroller.scrollTop = 0;
340+
return;
341+
}
342+
const margin = scroller.clientHeight * TERMINAL_SCROLL_MARGIN_RATIO;
343+
scroller.scrollTo({
344+
top: Math.max(0, available - margin),
345+
behavior: 'smooth',
346+
});
347+
}, [terminalTypedChars]);
348+
349+
useEffect(() => {
350+
const scroller = terminalScrollingRef.current;
351+
if (!scroller) return undefined;
352+
const observer = new ResizeObserver(() => {
353+
if (!terminalPinnedRef.current) return;
354+
const available = scroller.scrollHeight - scroller.clientHeight;
355+
const margin = scroller.clientHeight * TERMINAL_SCROLL_MARGIN_RATIO;
356+
scroller.scrollTop = available > 0 ? Math.max(0, available - margin) : 0;
357+
});
358+
observer.observe(scroller);
359+
return () => observer.disconnect();
360+
}, []);
361+
323362

324363
useEffect(() => {
325364
let rafId;
@@ -407,32 +446,59 @@ const captionStageProgress =
407446
const terminalStageProgress =
408447
progress <= TERMINAL_STAGE_START ? 0 : clamp((progress - TERMINAL_STAGE_START) / (TERMINAL_STAGE_LENGTH || 0.0001), 0, 1);
409448
const terminalTypingProgress = totalTerminalChars > 0 ? terminalTypedChars / totalTerminalChars : 0;
449+
const terminalTypingState = useMemo(
450+
() => buildTerminalTypingState(terminalEntries, terminalTypingProgress),
451+
[terminalEntries, terminalTypingProgress]
452+
);
453+
const typedLineCount = useMemo(() => {
454+
let count = 0;
455+
terminalTypingState.forEach((entry) => {
456+
if (!entry) return;
457+
if (entry.command?.length) count += 1;
458+
entry.outputs?.forEach((line) => {
459+
if (line?.length) count += 1;
460+
});
461+
});
462+
return count;
463+
}, [terminalTypingState]);
464+
const terminalTotalLines = useMemo(() => {
465+
let total = 0;
466+
terminalEntries.forEach((entry) => {
467+
if (!entry) return;
468+
if (entry.command) total += 1;
469+
if (Array.isArray(entry.output)) total += entry.output.length;
470+
});
471+
return Math.max(total, 1);
472+
}, []);
410473

411474
useEffect(() => {
412475
const scroller = terminalScrollingRef.current;
413476
if (!scroller) return undefined;
414477

415-
const syncScrollPosition = () => {
416-
const totalHeight = scroller.scrollHeight;
417-
const viewportHeight = scroller.clientHeight;
418-
const available = totalHeight - viewportHeight;
478+
const syncScroll = () => {
479+
const available = scroller.scrollHeight - scroller.clientHeight;
419480
if (available <= 0) {
420481
scroller.scrollTop = 0;
421482
return;
422483
}
423-
const visibleRatio = clamp(viewportHeight / (totalHeight || 1), 0, 1);
424-
const scrollStart = clamp(visibleRatio - TERMINAL_SCROLL_HEADROOM, 0, 0.99);
425-
const effectiveRange = Math.max(1 - scrollStart, 0.0001);
426-
const effective = clamp((terminalTypingProgress - scrollStart) / effectiveRange, 0, 1);
427-
scroller.scrollTop = available * effective;
484+
485+
const linesBeyondStart = Math.max(typedLineCount - 5, 0);
486+
const totalBeyondStart = Math.max(terminalTotalLines - 5, 1);
487+
const ratio = clamp(linesBeyondStart / totalBeyondStart, 0, 1);
488+
489+
const margin = scroller.clientHeight * TERMINAL_SCROLL_MARGIN_RATIO;
490+
const maxScrollable = Math.max(available - margin, 0);
491+
const target = ratio * maxScrollable;
492+
493+
scroller.scrollTo({ top: target, behavior: 'smooth' });
428494
};
429495

430-
syncScrollPosition();
431-
const observer = new ResizeObserver(syncScrollPosition);
496+
syncScroll();
497+
const observer = new ResizeObserver(syncScroll);
432498
observer.observe(scroller);
433499

434500
return () => observer.disconnect();
435-
}, [terminalTypingProgress]);
501+
}, [typedLineCount, terminalTotalLines]);
436502

437503
const leaderboardEntries = leaderboardState.entries;
438504
const leaderboardStatus = leaderboardState.status;
@@ -515,11 +581,6 @@ const [topCaptionChars = [], bottomCaptionChars = []] = useMemo(
515581
const fakeTypingChars = buildLineCharacters(['â–ˆ'.repeat(100)], fakeStageProgress)[0] || [];
516582
const showFakeLine = fakeStageProgress > 0 && fakeStageProgress < 1;
517583
const textLayerOpacity = textRevealProgress * clamp(1 - terminalStageProgress * 2, 0, 1);
518-
const terminalTypingState = useMemo(
519-
() => buildTerminalTypingState(terminalEntries, terminalTypingProgress),
520-
[terminalEntries, terminalTypingProgress]
521-
);
522-
523584
return (
524585
<div className="relative h-full overflow-hidden bg-slate-950 text-white">
525586
<div

0 commit comments

Comments
 (0)