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:
44
src/App.tsx
44
src/App.tsx
@@ -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(() => {
|
const resetInactivity = useCallback(() => {
|
||||||
setIsUserActive(true);
|
setIsUserActive(true);
|
||||||
if (inactivityTimerRef.current) {
|
if (inactivityTimerRef.current) {
|
||||||
@@ -224,7 +224,7 @@ export default function App() {
|
|||||||
if (isPlaying && settings.autoHideControls) {
|
if (isPlaying && settings.autoHideControls) {
|
||||||
inactivityTimerRef.current = window.setTimeout(() => {
|
inactivityTimerRef.current = window.setTimeout(() => {
|
||||||
setIsUserActive(false);
|
setIsUserActive(false);
|
||||||
}, 2200);
|
}, 3000);
|
||||||
}
|
}
|
||||||
}, [isPlaying, settings.autoHideControls]);
|
}, [isPlaying, settings.autoHideControls]);
|
||||||
|
|
||||||
@@ -235,11 +235,28 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleInteraction = () => {
|
||||||
|
resetInactivity();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleInteraction);
|
||||||
|
window.addEventListener('keydown', handleInteraction);
|
||||||
|
window.addEventListener('touchstart', handleInteraction);
|
||||||
|
window.addEventListener('pointerdown', handleInteraction);
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setIsUserActive(false);
|
setIsUserActive(false);
|
||||||
}, 2200);
|
}, 3000);
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [isPlaying, settings.autoHideControls]);
|
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
|
// Keyboard shortcuts
|
||||||
const anyModalOpen =
|
const anyModalOpen =
|
||||||
@@ -278,6 +295,9 @@ export default function App() {
|
|||||||
|
|
||||||
const themeClass = `theme-${settings.theme}`;
|
const themeClass = `theme-${settings.theme}`;
|
||||||
const isHudDimmed = isPlaying && !isUserActive && settings.autoHideControls;
|
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 (
|
return (
|
||||||
<div
|
<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}`}
|
className={`relative w-full h-full flex flex-col justify-between overflow-hidden transition-colors duration-300 font-sans ${themeClass}`}
|
||||||
>
|
>
|
||||||
{/* Top Header HUD */}
|
{/* Top Header HUD */}
|
||||||
<div
|
<div className={`w-full z-20 shrink-0 ${hudClasses}`}>
|
||||||
className={`w-full z-20 transition-opacity duration-500 ${
|
|
||||||
isHudDimmed ? 'opacity-0 hover:opacity-100' : 'opacity-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Header
|
<Header
|
||||||
book={activeBook}
|
book={activeBook}
|
||||||
currentChapter={currentChapter}
|
currentChapter={currentChapter}
|
||||||
@@ -307,7 +323,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main RSVP Focus Arena */}
|
{/* 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 ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center space-y-3 text-neutral-400">
|
<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" />
|
<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>
|
</main>
|
||||||
|
|
||||||
{/* Bottom Controls HUD */}
|
{/* Bottom Controls HUD */}
|
||||||
<div
|
<div className={`w-full pb-2 sm:pb-6 short:pb-1 z-20 shrink-0 ${hudClasses}`}>
|
||||||
className={`w-full pb-6 z-20 transition-opacity duration-500 ${
|
|
||||||
isHudDimmed ? 'opacity-10 hover:opacity-100' : 'opacity-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Controls
|
<Controls
|
||||||
currentIndex={currentIndex}
|
currentIndex={currentIndex}
|
||||||
totalTokens={totalTokens}
|
totalTokens={totalTokens}
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ export const Controls: React.FC<ControlsProps> = ({
|
|||||||
const remainingText = formatDuration(remainingSeconds);
|
const remainingText = formatDuration(remainingSeconds);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Progress & Timeline Bar */}
|
||||||
{settings.showProgressBar && (
|
{settings.showProgressBar && (
|
||||||
<div className="flex flex-col space-y-1.5">
|
<div className="flex flex-col space-y-0.5 sm: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 justify-between items-center text-[10px] sm:text-xs font-mono text-neutral-400 dark:text-neutral-500">
|
||||||
<span>
|
<span>
|
||||||
{currentIndex + 1} / {totalTokens}{' '}
|
{currentIndex + 1} / {totalTokens}{' '}
|
||||||
<span className="opacity-75">({percent.toFixed(1)}%)</span>
|
<span className="opacity-75">({percent.toFixed(1)}%)</span>
|
||||||
@@ -73,7 +73,7 @@ export const Controls: React.FC<ControlsProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Chapter tick marks on the track */}
|
||||||
{chapters.length > 1 &&
|
{chapters.length > 1 &&
|
||||||
chapters.map((chap, idx) => {
|
chapters.map((chap, idx) => {
|
||||||
@@ -95,19 +95,19 @@ export const Controls: React.FC<ControlsProps> = ({
|
|||||||
max={Math.max(0, totalTokens - 1)}
|
max={Math.max(0, totalTokens - 1)}
|
||||||
value={currentIndex}
|
value={currentIndex}
|
||||||
onChange={(e) => onJumpTo(Number(e.target.value))}
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Playback Buttons */}
|
{/* 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 */}
|
{/* Restart */}
|
||||||
<button
|
<button
|
||||||
onClick={onRestart}
|
onClick={onRestart}
|
||||||
title="重新开始 [R]"
|
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" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -116,30 +116,30 @@ export const Controls: React.FC<ControlsProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onJumpBy(-10)}
|
onClick={() => onJumpBy(-10)}
|
||||||
title="后退 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>
|
</button>
|
||||||
|
|
||||||
{/* Previous Sentence */}
|
{/* Previous Sentence */}
|
||||||
<button
|
<button
|
||||||
onClick={onJumpToPreviousSentence}
|
onClick={onJumpToPreviousSentence}
|
||||||
title="上一句 [Shift + ←]"
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Primary Play / Pause Button */}
|
{/* Primary Play / Pause Button */}
|
||||||
<button
|
<button
|
||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
title="播放 / 暂停 [空格键]"
|
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 ? (
|
{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>
|
</button>
|
||||||
|
|
||||||
@@ -147,44 +147,44 @@ export const Controls: React.FC<ControlsProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onJumpToNextSentence}
|
onClick={onJumpToNextSentence}
|
||||||
title="下一句 [Shift + →]"
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Forward 10 */}
|
{/* Forward 10 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onJumpBy(10)}
|
onClick={() => onJumpBy(10)}
|
||||||
title="前进 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>
|
</button>
|
||||||
|
|
||||||
{/* Quick Speed fine-tuning */}
|
{/* 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
|
<button
|
||||||
onClick={() => onChangeWpm(Math.max(100, settings.wpm - 25))}
|
onClick={() => onChangeWpm(Math.max(100, settings.wpm - 25))}
|
||||||
title="减速 25 WPM [↓]"
|
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>
|
</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}
|
{settings.wpm}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onChangeWpm(Math.min(1200, settings.wpm + 25))}
|
onClick={() => onChangeWpm(Math.min(1200, settings.wpm + 25))}
|
||||||
title="加速 25 WPM [↑]"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preset WPM Chips */}
|
{/* Preset WPM Chips (Hidden on short screens to prevent overflow) */}
|
||||||
<div className="flex items-center justify-center flex-wrap gap-1.5 pt-1 text-xs">
|
<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 className="text-neutral-400 text-[11px] mr-1 hidden sm:inline">
|
||||||
预设语速:
|
预设语速:
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ const ORP_COLOR_MAP: Record<OrpColor, { text: string; bg: string; glow: string }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FONT_SIZE_MAP: Record<FontSize, string> = {
|
const FONT_SIZE_MAP: Record<FontSize, string> = {
|
||||||
sm: 'text-3xl sm:text-4xl h-24 sm:h-28',
|
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',
|
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',
|
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',
|
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',
|
'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',
|
'3xl': 'text-8xl sm:text-9xl h-52 sm:h-64 short:h-24 short:text-4xl',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FONT_FAMILY_MAP: Record<FontFamily, string> = {
|
const FONT_FAMILY_MAP: Record<FontFamily, string> = {
|
||||||
@@ -76,17 +76,17 @@ export const FocusDisplay: React.FC<FocusDisplayProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onTogglePlay}
|
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="点击屏幕或按空格键 暂停/继续"
|
title="点击屏幕或按空格键 暂停/继续"
|
||||||
>
|
>
|
||||||
{/* Contextual Peripheral Ghost Words (Top line) */}
|
{/* Contextual Peripheral Ghost Words (Top line) */}
|
||||||
{showContextPreview && (
|
{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">
|
<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-[140px] text-right">
|
<span className="truncate max-w-[100px] sm:max-w-[140px] text-right">
|
||||||
{previousToken ? previousToken.text : ''}
|
{previousToken ? previousToken.text : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-1 h-1 rounded-full bg-neutral-600/40" />
|
<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 : ''}
|
{nextToken ? nextToken.text : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +94,7 @@ export const FocusDisplay: React.FC<FocusDisplayProps> = ({
|
|||||||
|
|
||||||
{/* Main RSVP Focal Chamber */}
|
{/* Main RSVP Focal Chamber */}
|
||||||
<div
|
<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 */}
|
{/* Top & Bottom ORP Crosshair Guide Notches */}
|
||||||
{showGuideLines && (
|
{showGuideLines && (
|
||||||
@@ -153,7 +153,7 @@ export const FocusDisplay: React.FC<FocusDisplayProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Punctuation / Pause indicator dot */}
|
{/* 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 && (
|
{token?.isParagraphEnd && (
|
||||||
<span className="text-[11px] font-mono tracking-widest text-neutral-400 dark:text-neutral-500 uppercase">
|
<span className="text-[11px] font-mono tracking-widest text-neutral-400 dark:text-neutral-500 uppercase">
|
||||||
¶ 段落停顿
|
¶ 段落停顿
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
onCycleTheme,
|
onCycleTheme,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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 */}
|
{/* Left: App Brand & Current Book Info */}
|
||||||
<div className="flex items-center space-x-3 truncate">
|
<div className="flex items-center space-x-3 truncate">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Clipboard, Play, X } from 'lucide-react';
|
import { AlertCircle, Clipboard, Play, X } from 'lucide-react';
|
||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { tokenizeText } from '../core/tokenizer';
|
import { tokenizeText } from '../core/tokenizer';
|
||||||
import type { Book } from '../types';
|
import type { Book } from '../types';
|
||||||
|
|
||||||
@@ -19,22 +19,35 @@ export const PasteModal: React.FC<PasteModalProps> = ({
|
|||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||||
|
const [clipboardError, setClipboardError] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handlePasteFromClipboard = async () => {
|
const handlePasteFromClipboard = async () => {
|
||||||
try {
|
try {
|
||||||
setIsReadingClipboard(true);
|
setIsReadingClipboard(true);
|
||||||
|
setClipboardError(null);
|
||||||
|
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
||||||
|
throw new Error('Clipboard API not supported');
|
||||||
|
}
|
||||||
|
|
||||||
const text = await navigator.clipboard.readText();
|
const text = await navigator.clipboard.readText();
|
||||||
if (text) {
|
if (text && text.trim()) {
|
||||||
setContent(text);
|
setContent(text);
|
||||||
if (!title) {
|
if (!title) {
|
||||||
const firstLine = text.trim().split('\n')[0].substring(0, 30);
|
const firstLine = text.trim().split('\n')[0].substring(0, 30);
|
||||||
setTitle(firstLine || '剪贴板快读');
|
setTitle(firstLine || '剪贴板快读');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setClipboardError('剪贴板中未检测到有效文本,请直接在下方文本框手动粘贴 (Ctrl+V)');
|
||||||
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Clipboard read failed', err);
|
console.warn('Clipboard auto-read failed:', err);
|
||||||
|
setClipboardError('无法自动读取剪贴板,请直接在文本框粘贴 (Ctrl+V)');
|
||||||
|
textareaRef.current?.focus();
|
||||||
} finally {
|
} finally {
|
||||||
setIsReadingClipboard(false);
|
setIsReadingClipboard(false);
|
||||||
}
|
}
|
||||||
@@ -67,6 +80,7 @@ export const PasteModal: React.FC<PasteModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
setContent('');
|
setContent('');
|
||||||
setTitle('');
|
setTitle('');
|
||||||
|
setClipboardError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const charCount = content.length;
|
const charCount = content.length;
|
||||||
@@ -94,6 +108,21 @@ export const PasteModal: React.FC<PasteModalProps> = ({
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
|
<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>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-neutral-500 mb-1">
|
<label className="block text-xs font-semibold text-neutral-500 mb-1">
|
||||||
标题(选填)
|
标题(选填)
|
||||||
@@ -122,10 +151,14 @@ export const PasteModal: React.FC<PasteModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
rows={8}
|
rows={8}
|
||||||
placeholder="在此粘贴任意中英文文本..."
|
placeholder="在此粘贴任意中英文文本..."
|
||||||
value={content}
|
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"
|
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>
|
</div>
|
||||||
|
|||||||
@@ -69,3 +69,73 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 6px rgba(244, 63, 94, 0.4);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user