Skip to content

Commit 22005bb

Browse files
feat: implement Ctrl+C subtitle copy with lightweight toast notification (#140)
* feat(player): add subtitle copy shortcut with Ctrl+C - Add copy_subtitle shortcut to constants with CommandOrControl+C - Implement copy functionality in usePlayerShortcuts hook - Support copying selected text or current subtitle based on display mode - Add success/error notifications for copy operations - Remove obsolete keyboard handling from SubtitleContent component - Add i18n label support for copy_subtitle shortcut * fix(player): enable text selection in subtitle tokens for copy functionality - Change WordToken user-select from 'none' to 'text' to allow native text selection - Add debug logging to copy function to help diagnose selection issues - This enables Ctrl+C to copy selected subtitle text properly * fix(player): use custom subtitle selection for copy functionality - Use selectedText from useSubtitleOverlayUI instead of window.getSelection() - Integrate with custom selection system used by SubtitleContent component - Restore WordToken user-select: none to maintain custom selection behavior - Update copy logic to properly detect custom selected text vs full subtitle - Remove window.getSelection() debugging code as it's no longer used * debug: add logging to copy function to diagnose selectedText state * feat(player): add DOM-based selected text detection for copy - Add fallback to DOM query when selectedText state is empty - Check for selected tokens by background color style - Maintains both state-based and DOM-based selection detection - This should fix the issue where selectedText state gets cleared before copy * simplify: remove word selection feature, keep only full subtitle copy - Remove all selected text detection logic (selectedText state and DOM queries) - Simplify copy function to only copy current subtitle based on display mode - Remove dependency on useSubtitleOverlayUI hook - Clean up notification message to show copied character count - This makes the copy functionality simple and reliable * feat(player): add lightweight toast notifications for subtitle copy - Add Ctrl+C shortcut to copy current subtitle based on display mode - Replace intrusive NotificationService with lightweight toast positioned above subtitle overlay - Implement custom event system for decoupled toast communication between components - Add i18n support for copy success/failure messages in zh-cn.json - Toast automatically hides after 2 seconds with smooth fade transition - Support copying original, translated, or bilingual subtitle content based on current display mode - Position toast relative to subtitle overlay for better UX during immersive video watching This improves user experience by providing non-intrusive feedback while maintaining clean component architecture and supporting internationalization. * Update src/renderer/src/pages/player/components/SubtitleOverlay.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/renderer/src/pages/player/components/SubtitleOverlay.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/renderer/src/pages/player/components/SubtitleOverlay.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * 📝 Add docstrings to `copy-subtitle-shortcut` (#142) Docstrings generation was requested by @mkdir700. * #140 (comment) The following files were modified: * `src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts` Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update * refactor: use design tokens in Toast components and add theming best practices - Replace hardcoded values with design tokens in ToastContainer and ToastContent - Use ANIMATION_DURATION, EASING, FONT_SIZES, FONT_WEIGHTS, SPACING, Z_INDEX, GLASS_EFFECT tokens - Reduce toast display duration from 2000ms to 800ms for better UX - Add comprehensive theming best practices to CLAUDE.md - Document mixed approach: CSS variables for theme-related properties, JS variables for design constants --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent e2857e9 commit 22005bb

7 files changed

Lines changed: 213 additions & 25 deletions

File tree

CLAUDE.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,44 @@
1010
- 在布局实现上,后续优先使用 flex 布局(尽量避免使用 grid 作为默认方案)。
1111
- 项目优先使用 antd 组件库,如果组件可以被 antd 复用则优先使用 antd 而不是自定义开发
1212

13+
## 主题变量使用最佳实践
14+
15+
项目启用了 Ant Design 的 CSS 变量模式 (`cssVar: true`),在 styled-components 中应采用分类使用策略:
16+
17+
### 使用 CSS 变量的场景(主题相关属性):
18+
19+
- 颜色系统:`var(--ant-color-bg-elevated, fallback)`
20+
- 阴影效果:`var(--ant-box-shadow-secondary, fallback)`
21+
- 主题切换时会发生变化的属性
22+
23+
### 使用 JS 变量的场景(设计系统常量):
24+
25+
- 尺寸间距:`${SPACING.XS}px``${BORDER_RADIUS.SM}px`
26+
- 动画配置:`${ANIMATION_DURATION.SLOW}``${EASING.APPLE}`
27+
- 层级关系:`${Z_INDEX.MODAL}`
28+
- 字体配置:`${FONT_SIZES.SM}px``${FONT_WEIGHTS.MEDIUM}`
29+
- 毛玻璃效果:`${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT}`
30+
31+
### 推荐模式:
32+
33+
```typescript
34+
const StyledComponent = styled.div`
35+
/* 主题相关:使用 CSS 变量 */
36+
background: var(--ant-color-bg-elevated, rgba(0, 0, 0, ${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT}));
37+
color: var(--ant-color-white, #ffffff);
38+
box-shadow: var(--ant-box-shadow-secondary, ${SHADOWS.SM});
39+
40+
/* 设计系统常量:使用 JS 变量 */
41+
padding: ${SPACING.XS}px ${SPACING.MD}px;
42+
border-radius: ${BORDER_RADIUS.SM}px;
43+
font-size: ${FONT_SIZES.SM}px;
44+
transition: opacity ${ANIMATION_DURATION.SLOW} ${EASING.APPLE};
45+
z-index: ${Z_INDEX.MODAL};
46+
`
47+
```
48+
49+
这种混合模式既保持了类型安全和构建时优化,又支持主题的运行时切换,是当前架构下的最佳实践。
50+
1351
# State Management
1452

1553
- 项目使用 Zustand 作为状态管理库,配合 Immer 中间件支持不可变状态更新,使用自定义的中间件栈包含持久化、DevTools 和订阅选择器功能

src/renderer/src/i18n/label.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ const shortcutKeys = [
179179
'subtitle_mode_none',
180180
'subtitle_mode_original',
181181
'subtitle_mode_translated',
182-
'subtitle_mode_bilingual'
182+
'subtitle_mode_bilingual',
183+
'copy_subtitle'
183184
] as const
184185

185186
const shortcutKeyMap = shortcutKeys.reduce(

src/renderer/src/i18n/locales/zh-cn.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@
106106
"fullscreen": {
107107
"enter": "全屏",
108108
"exit": "退出全屏"
109+
},
110+
"copy": {
111+
"success": "已复制",
112+
"failed": "复制失败,无法访问剪贴板"
109113
}
110114
},
111115
"subtitles": {
@@ -290,6 +294,7 @@
290294
"clear_shortcut": "清除快捷键",
291295
"clear_topic": "清空消息",
292296
"copy_last_message": "复制上一条消息",
297+
"copy_subtitle": "复制字幕",
293298
"enabled": "启用",
294299
"escape_fullscreen": "退出全屏",
295300
"exit_fullscreen": "退出全屏",

src/renderer/src/infrastructure/constants/shortcuts.const.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,12 @@ export const DEFAULT_SHORTCUTS: Shortcut[] = [
130130
editable: true,
131131
enabled: true,
132132
system: false
133+
},
134+
{
135+
key: 'copy_subtitle',
136+
shortcut: ['CommandOrControl', 'C'],
137+
editable: true,
138+
enabled: true,
139+
system: false
133140
}
134141
]

src/renderer/src/pages/player/components/SubtitleContent.tsx

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -235,26 +235,6 @@ export const SubtitleContent = memo(function SubtitleContent({
235235
return undefined
236236
}, [selectionState.startIndex, selectionState.endIndex, handleGlobalClick])
237237

238-
// === 键盘复制支持 ===
239-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
240-
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
241-
const selection = window.getSelection()
242-
const selectedText = selection?.toString()
243-
244-
if (selectedText) {
245-
// 复制到剪贴板
246-
navigator.clipboard
247-
.writeText(selectedText)
248-
.then(() => {
249-
logger.info('字幕文本已复制到剪贴板', { length: selectedText.length })
250-
})
251-
.catch((error) => {
252-
logger.error('复制到剪贴板失败', { error })
253-
})
254-
}
255-
}
256-
}, [])
257-
258238
// === 渲染分词文本 ===
259239
const renderTokenizedText = useCallback(
260240
(tokens: WordToken[]) => {
@@ -366,8 +346,6 @@ export const SubtitleContent = memo(function SubtitleContent({
366346
ref={containerRef}
367347
className={className}
368348
style={style}
369-
onKeyDown={handleKeyDown}
370-
tabIndex={0} // 使元素可聚焦,支持键盘操作
371349
role="region"
372350
data-testid="subtitle-content"
373351
>

src/renderer/src/pages/player/components/SubtitleOverlay.tsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,21 @@
1111
*/
1212

1313
import { loggerService } from '@logger'
14+
import {
15+
ANIMATION_DURATION,
16+
BORDER_RADIUS,
17+
EASING,
18+
FONT_SIZES,
19+
FONT_WEIGHTS,
20+
GLASS_EFFECT,
21+
SHADOWS,
22+
SPACING,
23+
Z_INDEX
24+
} from '@renderer/infrastructure/styles/theme'
1425
import { usePlayerStore } from '@renderer/state'
1526
import { SubtitleBackgroundType, SubtitleDisplayMode } from '@types'
1627
import { Tooltip } from 'antd'
17-
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
28+
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
1829
import { useTranslation } from 'react-i18next'
1930
import styled, { css } from 'styled-components'
2031

@@ -70,6 +81,37 @@ export const SubtitleOverlay = memo(function SubtitleOverlay({
7081

7182
// === 本地状态 ===
7283
const overlayRef = useRef<HTMLDivElement>(null)
84+
const [toastVisible, setToastVisible] = useState(false)
85+
const [toastMessage, setToastMessage] = useState('')
86+
const hideToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
87+
88+
// === 复制成功toast监听器 ===
89+
useEffect(() => {
90+
const handleSubtitleCopied = (event: CustomEvent<{ message: string }>) => {
91+
const { message } = event.detail
92+
setToastMessage(message)
93+
setToastVisible(true)
94+
95+
// 2秒后自动隐藏toast(防抖)
96+
if (hideToastTimerRef.current) {
97+
clearTimeout(hideToastTimerRef.current)
98+
}
99+
hideToastTimerRef.current = setTimeout(() => {
100+
setToastVisible(false)
101+
hideToastTimerRef.current = null
102+
}, 800)
103+
}
104+
105+
window.addEventListener('subtitle-copied', handleSubtitleCopied as EventListener)
106+
107+
return () => {
108+
if (hideToastTimerRef.current) {
109+
clearTimeout(hideToastTimerRef.current)
110+
hideToastTimerRef.current = null
111+
}
112+
window.removeEventListener('subtitle-copied', handleSubtitleCopied as EventListener)
113+
}
114+
}, [])
73115

74116
// === 初始化和容器边界更新 ===
75117
useEffect(() => {
@@ -434,6 +476,10 @@ export const SubtitleOverlay = memo(function SubtitleOverlay({
434476
data-testid="subtitle-resize-handle"
435477
/>
436478
</Tooltip>
479+
480+
<ToastContainer $visible={toastVisible} role="status" aria-live="polite" aria-atomic="true">
481+
<ToastContent>{toastMessage}</ToastContent>
482+
</ToastContainer>
437483
</OverlayContainer>
438484
)
439485
})
@@ -569,3 +615,28 @@ const ResizeHandle = styled.div<{ $visible: boolean }>`
569615
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
570616
}
571617
`
618+
619+
const ToastContainer = styled.div<{ $visible: boolean }>`
620+
position: absolute;
621+
top: -40px;
622+
left: 50%;
623+
transform: translateX(-50%);
624+
opacity: ${(props) => (props.$visible ? 1 : 0)};
625+
visibility: ${(props) => (props.$visible ? 'visible' : 'hidden')};
626+
transition: opacity ${ANIMATION_DURATION.SLOW} ${EASING.APPLE};
627+
z-index: ${Z_INDEX.MODAL};
628+
pointer-events: none;
629+
`
630+
631+
const ToastContent = styled.div`
632+
background: rgba(0, 0, 0, ${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT});
633+
color: #ffffff;
634+
padding: ${SPACING.XS}px ${SPACING.MD}px;
635+
border-radius: ${BORDER_RADIUS.SM}px;
636+
font-size: ${FONT_SIZES.SM}px;
637+
font-weight: ${FONT_WEIGHTS.MEDIUM};
638+
white-space: nowrap;
639+
backdrop-filter: blur(${GLASS_EFFECT.BLUR_STRENGTH.SUBTLE}px);
640+
border: 1px solid rgba(255, 255, 255, ${GLASS_EFFECT.BORDER_ALPHA.SUBTLE});
641+
box-shadow: ${SHADOWS.SM};
642+
`

src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,99 @@ import { loggerService } from '@logger'
22
import { useShortcut } from '@renderer/infrastructure/hooks/useShortcut'
33
import { usePlayerStore } from '@renderer/state/stores/player.store'
44
import { SubtitleDisplayMode } from '@types'
5+
import { useCallback } from 'react'
6+
import { useTranslation } from 'react-i18next'
57

68
import { usePlayerCommands } from './usePlayerCommands'
79
import useSubtitleOverlay from './useSubtitleOverlay'
810

911
const logger = loggerService.withContext('TransportBar')
1012

13+
/**
14+
* Registers global keyboard shortcuts for player controls and subtitle-related actions.
15+
*
16+
* Sets up shortcuts for playback (play/pause, seek, volume, loop), subtitle navigation
17+
* (previous/next, replay), subtitle display mode toggles (none/original/translated/bilingual),
18+
* subtitle panel toggle, cycling favorite playback rates, and copying the current subtitle to the clipboard.
19+
*
20+
* The copy action selects text according to the current subtitle display mode:
21+
* - ORIGINAL: original text
22+
* - TRANSLATED: translated text, falling back to original if missing
23+
* - BILINGUAL: original and translated joined by a newline
24+
* - NONE or unsupported: no copy performed
25+
*
26+
* Side effects:
27+
* - Invokes player command functions and store actions.
28+
* - Writes subtitle text to the clipboard via `navigator.clipboard.writeText`.
29+
* - Emits a `CustomEvent` named `subtitle-copied` with a localized success or failure message.
30+
* - Logs informational and error events via the module logger.
31+
*/
1132
export function usePlayerShortcuts() {
33+
const { t } = useTranslation()
1234
const cmd = usePlayerCommands()
13-
const { setDisplayMode } = useSubtitleOverlay()
35+
const { setDisplayMode, currentSubtitle } = useSubtitleOverlay()
1436
const { toggleSubtitlePanel, cycleFavoriteRateNext, cycleFavoriteRatePrev } = usePlayerStore()
37+
const displayMode = usePlayerStore((s) => s.subtitleOverlay.displayMode)
38+
39+
// 复制字幕内容处理函数
40+
const handleCopySubtitle = useCallback(async () => {
41+
try {
42+
let textToCopy = ''
43+
44+
if (currentSubtitle) {
45+
// 根据显示模式复制相应的字幕内容
46+
switch (displayMode) {
47+
case SubtitleDisplayMode.ORIGINAL:
48+
textToCopy = currentSubtitle.originalText
49+
break
50+
case SubtitleDisplayMode.TRANSLATED:
51+
textToCopy = currentSubtitle.translatedText || currentSubtitle.originalText
52+
break
53+
case SubtitleDisplayMode.BILINGUAL: {
54+
const texts = [currentSubtitle.originalText, currentSubtitle.translatedText].filter(
55+
Boolean
56+
)
57+
textToCopy = texts.join('\n')
58+
break
59+
}
60+
default:
61+
logger.warn('当前显示模式不支持复制')
62+
return // NONE 模式不复制
63+
}
64+
logger.info('复制字幕内容', {
65+
mode: displayMode,
66+
length: textToCopy.length
67+
})
68+
} else {
69+
logger.warn('没有当前字幕内容')
70+
return
71+
}
72+
73+
if (textToCopy) {
74+
await navigator.clipboard.writeText(textToCopy)
75+
76+
// 触发自定义事件显示toast
77+
window.dispatchEvent(
78+
new CustomEvent('subtitle-copied', {
79+
detail: {
80+
message: t('player.controls.copy.success')
81+
}
82+
})
83+
)
84+
}
85+
} catch (error) {
86+
logger.error('复制字幕失败', { error })
87+
88+
// 错误情况下也使用toast显示
89+
window.dispatchEvent(
90+
new CustomEvent('subtitle-copied', {
91+
detail: {
92+
message: t('player.controls.copy.failed')
93+
}
94+
})
95+
)
96+
}
97+
}, [currentSubtitle, displayMode, t])
1598

1699
useShortcut('play_pause', () => {
17100
cmd.playPause()
@@ -87,4 +170,9 @@ export function usePlayerShortcuts() {
87170
cycleFavoriteRatePrev()
88171
logger.info('播放速度切换: 上一个常用速度')
89172
})
173+
174+
// 复制字幕内容
175+
useShortcut('copy_subtitle', () => {
176+
handleCopySubtitle()
177+
})
90178
}

0 commit comments

Comments
 (0)