fix(ux): address critic review on mobile landscape, dual hud fade and clipboard feedback

- Optimize short/landscape screen (< 520px height) viewports with compact padding, shrinkable containers and zero-overflow layout
- Synchronize dual-HUD immersive fading for both Header and Controls with 3s stillness and global wake-up interactions
- Enhance PasteModal with user-friendly error alerts and auto-focusing textarea on clipboard denial

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-20 09:24:00 +08:00
parent 1f7a6a2054
commit 47cae39718
6 changed files with 175 additions and 60 deletions

View File

@@ -215,7 +215,7 @@ export default function App() {
}
};
// Mouse activity detection for HUD fading
// Mouse/touch/key activity detection for HUD fading
const resetInactivity = useCallback(() => {
setIsUserActive(true);
if (inactivityTimerRef.current) {
@@ -224,7 +224,7 @@ export default function App() {
if (isPlaying && settings.autoHideControls) {
inactivityTimerRef.current = window.setTimeout(() => {
setIsUserActive(false);
}, 2200);
}, 3000);
}
}, [isPlaying, settings.autoHideControls]);
@@ -235,11 +235,28 @@ export default function App() {
}
return;
}
const handleInteraction = () => {
resetInactivity();
};
window.addEventListener('mousemove', handleInteraction);
window.addEventListener('keydown', handleInteraction);
window.addEventListener('touchstart', handleInteraction);
window.addEventListener('pointerdown', handleInteraction);
const timer = window.setTimeout(() => {
setIsUserActive(false);
}, 2200);
return () => window.clearTimeout(timer);
}, [isPlaying, settings.autoHideControls]);
}, 3000);
return () => {
window.clearTimeout(timer);
window.removeEventListener('mousemove', handleInteraction);
window.removeEventListener('keydown', handleInteraction);
window.removeEventListener('touchstart', handleInteraction);
window.removeEventListener('pointerdown', handleInteraction);
};
}, [isPlaying, settings.autoHideControls, resetInactivity]);
// Keyboard shortcuts
const anyModalOpen =
@@ -278,6 +295,9 @@ export default function App() {
const themeClass = `theme-${settings.theme}`;
const isHudDimmed = isPlaying && !isUserActive && settings.autoHideControls;
const hudClasses = isHudDimmed
? 'opacity-0 pointer-events-none transition-opacity duration-500'
: 'opacity-100 pointer-events-auto transition-opacity duration-300';
return (
<div
@@ -286,11 +306,7 @@ export default function App() {
className={`relative w-full h-full flex flex-col justify-between overflow-hidden transition-colors duration-300 font-sans ${themeClass}`}
>
{/* Top Header HUD */}
<div
className={`w-full z-20 transition-opacity duration-500 ${
isHudDimmed ? 'opacity-0 hover:opacity-100' : 'opacity-100'
}`}
>
<div className={`w-full z-20 shrink-0 ${hudClasses}`}>
<Header
book={activeBook}
currentChapter={currentChapter}
@@ -307,7 +323,7 @@ export default function App() {
</div>
{/* Main RSVP Focus Arena */}
<main className="flex-1 flex flex-col items-center justify-center px-4 z-10 w-full my-auto">
<main className="flex-1 min-h-0 flex flex-col items-center justify-center px-2 sm:px-4 z-10 w-full overflow-hidden my-auto">
{isLoading ? (
<div className="flex flex-col items-center space-y-3 text-neutral-400">
<div className="w-8 h-8 rounded-full border-2 border-rose-500 border-t-transparent animate-spin" />
@@ -330,11 +346,7 @@ export default function App() {
</main>
{/* Bottom Controls HUD */}
<div
className={`w-full pb-6 z-20 transition-opacity duration-500 ${
isHudDimmed ? 'opacity-10 hover:opacity-100' : 'opacity-100'
}`}
>
<div className={`w-full pb-2 sm:pb-6 short:pb-1 z-20 shrink-0 ${hudClasses}`}>
<Controls
currentIndex={currentIndex}
totalTokens={totalTokens}

View File

@@ -59,11 +59,11 @@ export const Controls: React.FC<ControlsProps> = ({
const remainingText = formatDuration(remainingSeconds);
return (
<div className="w-full max-w-3xl mx-auto flex flex-col space-y-4 px-4 select-none">
<div className="w-full max-w-3xl mx-auto flex flex-col space-y-2 sm:space-y-4 short:space-y-1 px-3 sm:px-4 select-none shrink-0">
{/* Progress & Timeline Bar */}
{settings.showProgressBar && (
<div className="flex flex-col space-y-1.5">
<div className="flex justify-between items-center text-xs font-mono text-neutral-400 dark:text-neutral-500">
<div className="flex flex-col space-y-0.5 sm:space-y-1.5">
<div className="flex justify-between items-center text-[10px] sm:text-xs font-mono text-neutral-400 dark:text-neutral-500">
<span>
{currentIndex + 1} / {totalTokens}{' '}
<span className="opacity-75">({percent.toFixed(1)}%)</span>
@@ -73,7 +73,7 @@ export const Controls: React.FC<ControlsProps> = ({
</span>
</div>
<div className="relative flex items-center group py-1 cursor-pointer">
<div className="relative flex items-center group py-0.5 sm:py-1 cursor-pointer">
{/* Chapter tick marks on the track */}
{chapters.length > 1 &&
chapters.map((chap, idx) => {
@@ -95,19 +95,19 @@ export const Controls: React.FC<ControlsProps> = ({
max={Math.max(0, totalTokens - 1)}
value={currentIndex}
onChange={(e) => onJumpTo(Number(e.target.value))}
className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-rose-500 transition-all focus:outline-none"
className="w-full h-1 sm:h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-rose-500 transition-all focus:outline-none"
/>
</div>
</div>
)}
{/* Main Playback Buttons */}
<div className="flex items-center justify-between sm:justify-center sm:space-x-4">
<div className="flex items-center justify-between sm:justify-center sm:space-x-4 short:space-x-2">
{/* Restart */}
<button
onClick={onRestart}
title="重新开始 [R]"
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
className="p-1.5 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
@@ -116,30 +116,30 @@ export const Controls: React.FC<ControlsProps> = ({
<button
onClick={() => onJumpBy(-10)}
title="后退 10 词 [←]"
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
className="p-1.5 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
<ChevronsLeft className="w-5 h-5" />
<ChevronsLeft className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
{/* Previous Sentence */}
<button
onClick={onJumpToPreviousSentence}
title="上一句 [Shift + ←]"
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
className="p-1.5 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
<ChevronLeft className="w-5 h-5" />
<ChevronLeft className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
{/* Primary Play / Pause Button */}
<button
onClick={onTogglePlay}
title="播放 / 暂停 [空格键]"
className="w-12 h-12 sm:w-14 sm:h-14 rounded-full bg-neutral-900 hover:bg-black dark:bg-neutral-100 dark:hover:bg-white text-white dark:text-black flex items-center justify-center shadow-lg active:scale-95 transition-all"
className="w-10 h-10 sm:w-14 sm:h-14 short:w-9 short:h-9 rounded-full bg-neutral-900 hover:bg-black dark:bg-neutral-100 dark:hover:bg-white text-white dark:text-black flex items-center justify-center shadow-lg active:scale-95 transition-all"
>
{isPlaying ? (
<Pause className="w-5 h-5 fill-current" />
<Pause className="w-4 h-4 sm:w-5 sm:h-5 fill-current" />
) : (
<Play className="w-5 h-5 fill-current ml-0.5" />
<Play className="w-4 h-4 sm:w-5 sm:h-5 fill-current ml-0.5" />
)}
</button>
@@ -147,44 +147,44 @@ export const Controls: React.FC<ControlsProps> = ({
<button
onClick={onJumpToNextSentence}
title="下一句 [Shift + →]"
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
className="p-1.5 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
<ChevronRight className="w-5 h-5" />
<ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
{/* Forward 10 */}
<button
onClick={() => onJumpBy(10)}
title="前进 10 词 [→]"
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
className="p-1.5 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
<ChevronsRight className="w-5 h-5" />
<ChevronsRight className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
{/* Quick Speed fine-tuning */}
<div className="flex items-center space-x-1 pl-2 border-l border-neutral-300 dark:border-neutral-800">
<div className="flex items-center space-x-1 pl-1.5 sm:pl-2 border-l border-neutral-300 dark:border-neutral-800">
<button
onClick={() => onChangeWpm(Math.max(100, settings.wpm - 25))}
title="减速 25 WPM [↓]"
className="p-1.5 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
className="p-1 sm:p-1.5 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
<Minus className="w-4 h-4" />
<Minus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</button>
<span className="text-xs font-mono font-semibold w-12 text-center text-neutral-700 dark:text-neutral-300">
<span className="text-[11px] sm:text-xs font-mono font-semibold w-9 sm:w-12 text-center text-neutral-700 dark:text-neutral-300">
{settings.wpm}
</span>
<button
onClick={() => onChangeWpm(Math.min(1200, settings.wpm + 25))}
title="加速 25 WPM [↑]"
className="p-1.5 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
className="p-1 sm:p-1.5 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
<Plus className="w-4 h-4" />
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</button>
</div>
</div>
{/* Preset WPM Chips */}
<div className="flex items-center justify-center flex-wrap gap-1.5 pt-1 text-xs">
{/* Preset WPM Chips (Hidden on short screens to prevent overflow) */}
<div className="flex items-center justify-center flex-wrap gap-1.5 pt-0.5 sm:pt-1 text-xs short:hidden">
<span className="text-neutral-400 text-[11px] mr-1 hidden sm:inline">
预设语速:
</span>

View File

@@ -43,12 +43,12 @@ const ORP_COLOR_MAP: Record<OrpColor, { text: string; bg: string; glow: string }
};
const FONT_SIZE_MAP: Record<FontSize, string> = {
sm: 'text-3xl sm:text-4xl h-24 sm:h-28',
base: 'text-4xl sm:text-5xl h-28 sm:h-32',
lg: 'text-5xl sm:text-6xl h-32 sm:h-36',
xl: 'text-6xl sm:text-7xl h-36 sm:h-44',
'2xl': 'text-7xl sm:text-8xl h-44 sm:h-52',
'3xl': 'text-8xl sm:text-9xl h-52 sm:h-64',
sm: 'text-3xl sm:text-4xl h-24 sm:h-28 short:h-20 short:text-2xl',
base: 'text-4xl sm:text-5xl h-28 sm:h-32 short:h-20 short:text-3xl',
lg: 'text-5xl sm:text-6xl h-32 sm:h-36 short:h-24 short:text-3xl',
xl: 'text-6xl sm:text-7xl h-36 sm:h-44 short:h-24 short:text-4xl',
'2xl': 'text-7xl sm:text-8xl h-44 sm:h-52 short:h-24 short:text-4xl',
'3xl': 'text-8xl sm:text-9xl h-52 sm:h-64 short:h-24 short:text-4xl',
};
const FONT_FAMILY_MAP: Record<FontFamily, string> = {
@@ -76,17 +76,17 @@ export const FocusDisplay: React.FC<FocusDisplayProps> = ({
return (
<div
onClick={onTogglePlay}
className="relative w-full max-w-4xl mx-auto flex flex-col items-center justify-center cursor-pointer select-none py-6 sm:py-10 transition-colors"
className="relative w-full max-w-4xl mx-auto flex flex-col items-center justify-center cursor-pointer select-none py-2 sm:py-6 short:py-1 transition-colors flex-1 min-h-0"
title="点击屏幕或按空格键 暂停/继续"
>
{/* Contextual Peripheral Ghost Words (Top line) */}
{showContextPreview && (
<div className="h-7 mb-3 flex items-center justify-center space-x-6 text-sm text-neutral-400 dark:text-neutral-500 opacity-60 transition-opacity">
<span className="truncate max-w-[140px] text-right">
<div className="h-5 sm:h-7 mb-1 sm:mb-2 short:hidden flex items-center justify-center space-x-4 sm:space-x-6 text-xs sm:text-sm text-neutral-400 dark:text-neutral-500 opacity-60 transition-opacity shrink-0">
<span className="truncate max-w-[100px] sm:max-w-[140px] text-right">
{previousToken ? previousToken.text : ''}
</span>
<span className="w-1 h-1 rounded-full bg-neutral-600/40" />
<span className="truncate max-w-[140px] text-left">
<span className="truncate max-w-[100px] sm:max-w-[140px] text-left">
{nextToken ? nextToken.text : ''}
</span>
</div>
@@ -94,7 +94,7 @@ export const FocusDisplay: React.FC<FocusDisplayProps> = ({
{/* Main RSVP Focal Chamber */}
<div
className={`relative w-full flex items-center justify-center border-y border-neutral-700/30 dark:border-neutral-800/60 bg-neutral-900/10 dark:bg-black/20 rounded-xl px-4 ${sizeClasses} ${familyClasses}`}
className={`relative w-full flex items-center justify-center border-y border-neutral-700/30 dark:border-neutral-800/60 bg-neutral-900/10 dark:bg-black/20 rounded-xl px-2 sm:px-4 ${sizeClasses} ${familyClasses}`}
>
{/* Top & Bottom ORP Crosshair Guide Notches */}
{showGuideLines && (
@@ -153,7 +153,7 @@ export const FocusDisplay: React.FC<FocusDisplayProps> = ({
</div>
{/* Punctuation / Pause indicator dot */}
<div className="h-4 mt-2 flex items-center justify-center">
<div className="h-4 mt-1 sm:mt-2 short:hidden flex items-center justify-center shrink-0">
{token?.isParagraphEnd && (
<span className="text-[11px] font-mono tracking-widest text-neutral-400 dark:text-neutral-500 uppercase">
段落停顿

View File

@@ -40,7 +40,7 @@ export const Header: React.FC<HeaderProps> = ({
onCycleTheme,
}) => {
return (
<header className="w-full flex items-center justify-between px-4 py-3 border-b border-neutral-200/50 dark:border-neutral-800/80 bg-white/50 dark:bg-black/30 backdrop-blur-md select-none">
<header className="w-full flex items-center justify-between px-3 sm:px-4 py-1.5 sm:py-3 short:py-1 border-b border-neutral-200/50 dark:border-neutral-800/80 bg-white/50 dark:bg-black/30 backdrop-blur-md select-none shrink-0">
{/* Left: App Brand & Current Book Info */}
<div className="flex items-center space-x-3 truncate">
{/* Logo */}

View File

@@ -1,5 +1,5 @@
import { Clipboard, Play, X } from 'lucide-react';
import React, { useState } from 'react';
import { AlertCircle, Clipboard, Play, X } from 'lucide-react';
import React, { useRef, useState } from 'react';
import { tokenizeText } from '../core/tokenizer';
import type { Book } from '../types';
@@ -19,22 +19,35 @@ export const PasteModal: React.FC<PasteModalProps> = ({
const [content, setContent] = useState('');
const [title, setTitle] = useState('');
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
const [clipboardError, setClipboardError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
if (!isOpen) return null;
const handlePasteFromClipboard = async () => {
try {
setIsReadingClipboard(true);
setClipboardError(null);
if (!navigator.clipboard || !navigator.clipboard.readText) {
throw new Error('Clipboard API not supported');
}
const text = await navigator.clipboard.readText();
if (text) {
if (text && text.trim()) {
setContent(text);
if (!title) {
const firstLine = text.trim().split('\n')[0].substring(0, 30);
setTitle(firstLine || '剪贴板快读');
}
} else {
setClipboardError('剪贴板中未检测到有效文本,请直接在下方文本框手动粘贴 (Ctrl+V)');
textareaRef.current?.focus();
}
} catch (err) {
console.warn('Clipboard read failed', err);
console.warn('Clipboard auto-read failed:', err);
setClipboardError('无法自动读取剪贴板,请直接在文本框粘贴 (Ctrl+V)');
textareaRef.current?.focus();
} finally {
setIsReadingClipboard(false);
}
@@ -67,6 +80,7 @@ export const PasteModal: React.FC<PasteModalProps> = ({
onClose();
setContent('');
setTitle('');
setClipboardError(null);
};
const charCount = content.length;
@@ -94,6 +108,21 @@ export const PasteModal: React.FC<PasteModalProps> = ({
{/* Body */}
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
{clipboardError && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/25 text-amber-700 dark:text-amber-400 text-xs flex items-center justify-between animate-fade-in">
<div className="flex items-center space-x-2">
<AlertCircle className="w-4 h-4 shrink-0 text-amber-500" />
<span>{clipboardError}</span>
</div>
<button
onClick={() => setClipboardError(null)}
className="text-amber-600 dark:text-amber-400 hover:opacity-75 text-sm font-bold ml-2"
>
×
</button>
</div>
)}
<div>
<label className="block text-xs font-semibold text-neutral-500 mb-1">
标题(选填)
@@ -122,10 +151,14 @@ export const PasteModal: React.FC<PasteModalProps> = ({
</button>
</div>
<textarea
ref={textareaRef}
rows={8}
placeholder="在此粘贴任意中英文文本..."
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={(e) => {
setContent(e.target.value);
if (clipboardError) setClipboardError(null);
}}
className="w-full p-3 text-sm bg-neutral-100 dark:bg-neutral-800 rounded-lg border border-neutral-300 dark:border-neutral-700 focus:outline-none focus:border-rose-500 text-neutral-900 dark:text-neutral-100 resize-none font-mono"
/>
</div>

View File

@@ -69,3 +69,73 @@ input[type="range"]::-moz-range-thumb {
cursor: pointer;
box-shadow: 0 0 6px rgba(244, 63, 94, 0.4);
}
/* Short / Landscape screen optimizations (< 520px viewport height) */
@media (max-height: 520px) {
.short\:py-0\.5 {
padding-top: 0.125rem !important;
padding-bottom: 0.125rem !important;
}
.short\:py-1 {
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
}
.short\:py-2 {
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
.short\:pb-1 {
padding-bottom: 0.25rem !important;
}
.short\:my-0 {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.short\:mb-1 {
margin-bottom: 0.25rem !important;
}
.short\:mt-0\.5 {
margin-top: 0.125rem !important;
}
.short\:h-20 {
height: 5rem !important;
}
.short\:h-24 {
height: 6rem !important;
}
.short\:h-5 {
height: 1.25rem !important;
}
.short\:h-3 {
height: 0.75rem !important;
}
.short\:text-2xl {
font-size: 1.5rem !important;
line-height: 2rem !important;
}
.short\:text-3xl {
font-size: 1.875rem !important;
line-height: 2.25rem !important;
}
.short\:text-4xl {
font-size: 2.25rem !important;
line-height: 2.5rem !important;
}
.short\:text-xs {
font-size: 0.75rem !important;
line-height: 1rem !important;
}
.short\:space-y-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: 0.25rem !important;
margin-bottom: 0 !important;
}
.short\:space-y-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: 0.5rem !important;
margin-bottom: 0 !important;
}
.short\:hidden {
display: none !important;
}
}