feat(settings): replicate the WorkBuddy keyboard shortcuts panel and wire global bindings (TODO 12)

Recover the WorkBuddy 5.8.3 快捷键 page from REA evidence (keyboard-shortcuts-DOrivHwf.css + tokens): 49 recovered module rules, scoped light/dark tokens, the 24-row 命令/按键绑定/操作 sheet (panel 638x620, header 39, scroll 638x533, rows 41/45/44, keycaps 22) and the recording/ESC-cancel interaction.

Wire the panel into SettingsDialog and keep only real bindings honest: 14 of 24 rows are marked supported, the remaining rows stay visually 1:1 but are labelled 尚未接入 and cannot pretend to save custom keys. Add Ctrl+K search focus, Ctrl+B / Ctrl+Shift+B left/right sidebar toggles, Ctrl+N new conversation (App -> Sidebar path) and Esc-to-stop while generating; fix the sidebar search focus race introduced by conditional mounting.

Verify with scripts/verify-shortcuts-settings.cjs: 32 assertions covering geometry, row/key order, search filtering, recording/cancel, supported ids, dark theme surfaces and the four new global shortcuts in a real main window. All existing regressions stay green.
This commit is contained in:
xiaohan.yuan committed 2026-10-17 09:57:30 +08:00
1 parent b36b52831b
commit 19460f0b13
19 files changed
+1008 -12

No files matched your search

+40 -5
View File
@@ -260,12 +260,47 @@ export default function App(): React.JSX.Element {
setResizing(true)
}
// Ctrl+, 打开设置
// 全局快捷键(TODO 12):与「设置 → 快捷键」中已接入的行保持一致。
// Ctrl+, 设置;Ctrl+K 聚焦全局搜索;Ctrl+B / Ctrl+Shift+B 切换左/右侧栏;
// Ctrl+N 新建对话;生成中按 Esc 停止。其余键位只在该页面展示,未接入时不宣称生效。
const shortcutActions = useRef({
openSettings: (): void => {},
focusSearch: (): void => {},
toggleSidebar: (): void => {},
toggleRightSidebar: (): void => {},
stopRun: (): void => {}
})
shortcutActions.current = {
openSettings,
focusSearch: () => {
setSidebarCollapsed(false)
if (narrow.isNarrowForSidebar) setLeftDrawerOpen(true)
window.dispatchEvent(new CustomEvent('ycode:focus-search'))
},
toggleSidebar: () => {
if (narrow.isNarrowForSidebar) setLeftDrawerOpen((value) => !value)
else setSidebarCollapsed((value) => !value)
},
toggleRightSidebar: () => setRightVisible((value) => !value),
stopRun: () => { if (useChatStore.getState().running) void useChatStore.getState().cancel() }
}
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
e.preventDefault()
openSettings()
const onKey = (event: KeyboardEvent): void => {
if (event.isComposing) return
const actions = shortcutActions.current
if (event.key === 'Escape' && !event.defaultPrevented) {
// 与 WorkBuddy 一致:Esc 只在生成中停止本次运行,不吞掉其他弹层的 Esc。
if (useChatStore.getState().running) { event.preventDefault(); actions.stopRun() }
return
}
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return
const key = event.key.toLowerCase()
if (key === ',') { event.preventDefault(); actions.openSettings(); return }
if (key === 'k' && !event.shiftKey) { event.preventDefault(); actions.focusSearch(); return }
if (key === 'b') { event.preventDefault(); if (event.shiftKey) actions.toggleRightSidebar(); else actions.toggleSidebar(); return }
if (key === 'n' && !event.shiftKey) {
event.preventDefault()
window.dispatchEvent(new CustomEvent('ycode:new-task'))
}
}
window.addEventListener('keydown', onKey)
@@ -3,6 +3,7 @@ import { useSettingsStore } from '../stores/settings'
import ModelSettingsPanel from './ModelSettingsPanel'
import GeneralSettingsPanel from './GeneralSettingsPanel'
import AppearanceSettingsPanel from './AppearanceSettingsPanel'
import ShortcutsSettingsPanel from './ShortcutsSettingsPanel'
import SourceSettingsNavigation from './SourceSettingsNavigation'
import { useSharedTheme } from '../useSharedTheme'
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
@@ -53,7 +54,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
return () => window.removeEventListener('keydown', escape)
}, [onClose])
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance']} visibleTabs={VISIBLE_SETTINGS_TABS}
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance', 'keyboardShortcuts']} visibleTabs={VISIBLE_SETTINGS_TABS}
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
<div className="settings-modal__content"><div className="settings-modal__panel">
{error && <p role="alert">{error}</p>}
@@ -61,6 +62,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
{section === 'model' && <ModelSettingsPanel models={settings.models} createRequest={createRequest} onCreateHandled={() => setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />}
{section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>}
{section === 'appearance' && <AppearanceSettingsPanel theme={theme} onThemeChange={onThemeChange} />}
{section === 'keyboardShortcuts' && <ShortcutsSettingsPanel />}
{section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>}
</div></div>
</section>
@@ -0,0 +1,162 @@
/**
* 设置 → 快捷键:WorkBuddy 5.8.3 原版快捷键页的 1:1 复刻(TODO 12)。
*
* 结构与几何来自 REA `ev_5f3148…`(浅色)与 `ev_f4fd41…`(深色);搜索、禁用「全部恢复默认」、
* 点击绑定进入「按下快捷键 / ESC 取消」录制态来自 REA `ev_b3e014…`。样式取自 REA 回收的
* `keyboard-shortcuts-DOrivHwf.css`(见 source-shortcuts-panel-recovered.css)。
*
* YCode 侧的真实性边界:
* - 搜索过滤、录制态/取消、深色主题是真实行为;
* - `shortcuts-catalog.ts` 中 supported=true 的 14 行与 YCode 现有绑定一致;
* - 其余行只还原原版展示,title 明确标注「YCode 尚未接入」,编辑后的自定义键位、删除单条键位和
* 整表「全部恢复默认」尚未接入,按钮保持禁用,不伪造成功。
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { SHORTCUT_ROWS, type ShortcutRow } from './shortcuts-catalog'
import './source-shortcuts-panel.css'
const UNSUPPORTED_HINT = 'YCode 尚未接入该快捷键'
const EDIT_UNSUPPORTED_HINT = '自定义键位尚未接入,已保留默认绑定'
function TrashIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.75 4.5h10.5M6.25 4.5V3.25c0-.414.336-.75.75-.75h2c.414 0 .75.336.75.75V4.5M4.25 4.5l.5 8.25c.02.414.36.75.775.75h5.45c.415 0 .755-.336.775-.75l.5-8.25M6.75 7v4M9.25 7v4" />
</svg>
)
}
function matches(row: ShortcutRow, needle: string): boolean {
if (!needle) return true
const haystack = `${row.label} ${row.prefix ?? ''} ${row.keys}`.toLocaleLowerCase()
return haystack.includes(needle)
}
export default function ShortcutsSettingsPanel(): React.JSX.Element {
const [query, setQuery] = useState('')
const [recordingId, setRecordingId] = useState<string | null>(null)
const [errorId, setErrorId] = useState<string | null>(null)
const errorTimer = useRef<number | null>(null)
const needle = query.trim().toLocaleLowerCase()
const rows = useMemo(() => SHORTCUT_ROWS.filter((row) => matches(row, needle)), [needle])
const cancelRecording = (): void => {
setRecordingId(null)
}
const startRecording = (row: ShortcutRow): void => {
if (!row.editable) return
if (errorTimer.current !== null) {
window.clearTimeout(errorTimer.current)
errorTimer.current = null
}
setErrorId(null)
setRecordingId(row.id)
}
useEffect(() => {
if (!recordingId) return
const onKeyDown = (event: KeyboardEvent): void => {
if (event.isComposing) return
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelRecording()
return
}
// 只按修饰键(Ctrl/Shift/Alt/Meta)时继续等待完整组合键。
if (['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) return
event.preventDefault()
event.stopPropagation()
setRecordingId(null)
setErrorId(recordingId)
if (errorTimer.current !== null) window.clearTimeout(errorTimer.current)
errorTimer.current = window.setTimeout(() => {
errorTimer.current = null
setErrorId(null)
}, 2600)
}
document.addEventListener('keydown', onKeyDown, true)
return () => document.removeEventListener('keydown', onKeyDown, true)
}, [recordingId])
useEffect(() => () => {
if (errorTimer.current !== null) window.clearTimeout(errorTimer.current)
}, [])
const panelClass = `keyboard-shortcuts-panel${recordingId ? ' keyboard-shortcuts-panel--recording' : ''}`
return (
<div className={panelClass}>
<div className="keyboard-shortcuts-panel__toolbar">
<div className="keyboard-shortcuts-panel__search">
<div className="wb-input-wrapper">
<input className="wb-input__inner" type="search" value={query} placeholder="搜索快捷键"
aria-label="搜索快捷键" onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Escape' && query) { event.preventDefault(); event.stopPropagation(); setQuery('') } }} />
</div>
</div>
<button type="button" className="wb-button wb-button--medium wb-button--secondary wb-button--disabled" disabled
title="暂无自定义键位,恢复默认暂未接入">全部恢复默认</button>
</div>
<div className="keyboard-shortcuts-panel__list">
<div className="keyboard-shortcuts-panel__column-header">
<span className="keyboard-shortcuts-panel__col-command">命令</span>
<span className="keyboard-shortcuts-panel__col-binding">按键绑定</span>
<span className="keyboard-shortcuts-panel__col-action">操作</span>
</div>
<div className="keyboard-shortcuts-panel__list-scroll">
{rows.length ? rows.map((row) => {
const recording = recordingId === row.id
const error = errorId === row.id
const title = row.supported
? `${row.label}:${row.action ?? row.keys}`
: `${UNSUPPORTED_HINT}(原版绑定:${row.keys})`
return (
<div key={row.id} data-shortcut-id={row.id} data-shortcut-supported={row.supported ? 'true' : 'false'}
className={`shortcut-item${row.editable ? '' : ' shortcut-item--not-editable'}${recording ? ' shortcut-item--recording' : ''}`}>
<div className="shortcut-item__label">{row.label}</div>
{recording ? (
<div className="shortcut-item__binding shortcut-item__binding--recording-layout">
<div className="shortcut-item__recording-row">
<span className={`shortcut-item__recording-box${error ? ' shortcut-item__recording-box--error' : ''}`}>
<span className={`shortcut-item__recording-text${error ? ' shortcut-item__recording-text--error' : ''}`}>按下快捷键</span>
<span className="shortcut-item__recording-cursor" aria-hidden="true" />
</span>
<button type="button" className="shortcut-item__esc-cancel" onClick={cancelRecording}>ESC 取消</button>
</div>
{error ? <span className="shortcut-item__error-hint" role="alert">{EDIT_UNSUPPORTED_HINT}</span> : null}
</div>
) : (
<div className="shortcut-item__binding">
{row.editable ? (
<button type="button" className="shortcut-item__binding-tooltip-anchor shortcut-item__binding-tooltip-anchor--clickable"
title={title} aria-label={`${row.label},当前绑定 ${row.prefix ? `${row.prefix} ` : ''}${row.keys}`}
onClick={() => startRecording(row)}>
<span className="shortcut-item__keys">{row.prefix ? <span className="shortcut-item__prefix">{row.prefix}</span> : null}<kbd className="shortcut-item__key">{row.keys}</kbd></span>
</button>
) : (
<div className="shortcut-item__binding-tooltip-anchor" title={title}>
<span className="shortcut-item__keys"><kbd className="shortcut-item__key">{row.keys}</kbd></span>
</div>
)}
{error ? <span className="shortcut-item__error-hint" role="alert">{EDIT_UNSUPPORTED_HINT}</span> : null}
</div>
)}
<div className="shortcut-item__actions">
{row.editable ? (
<div className="shortcut-item__action-btn-group">
<button type="button" className="shortcut-item__action-btn shortcut-item__action-btn--delete wb-button wb-button--xsmall wb-button--ghost"
aria-disabled="true" disabled title="删除自定义键位暂未接入">
<span className="wb-button__icon"><TrashIcon /></span>
</button>
</div>
) : <span className="shortcut-item__actions-placeholder" aria-hidden="true">—</span>}
</div>
</div>
)
}) : <div className="keyboard-shortcuts-panel__empty">未找到匹配的快捷键</div>}
</div>
</div>
</div>
)
}
+14
View File
@@ -151,6 +151,14 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
} catch (error) { setHint(error instanceof Error ? error.message : '无法新建任务') }
finally { taskStartLock.current = false }
}
// 全局快捷键 Ctrl+N(TODO 12):App 广播,复用「新建任务」按钮的同一条路径。
const newTaskRef = useRef(newTask)
newTaskRef.current = newTask
useEffect(() => {
const handler = (): void => { void newTaskRef.current() }
window.addEventListener('ycode:new-task', handler)
return () => window.removeEventListener('ycode:new-task', handler)
}, [])
const openSpaceMenu = (space: WorkspaceRecord, rect: { left: number; bottom: number; top: number }, moreButton = false): void => {
setSpaceMenu({ space, left: rect.left - (moreButton ? 16 : 0), top: rect.bottom + 4, anchorTop: rect.top })
}
@@ -164,6 +172,12 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
window.addEventListener('ycode:focus-search', onFocusSearch)
return () => window.removeEventListener('ycode:focus-search', onFocusSearch)
}, [])
// 搜索框是条件渲染:等提交完成后再聚焦,避免 requestAnimationFrame 先于 DOM 挂载导致焦点丢失。
useEffect(() => {
if (!searchVisible) return
searchRef.current?.focus()
searchRef.current?.select()
}, [searchVisible])
// 全局「尚未实现」提示(标题栏等外围入口共用同一反馈通道)
useEffect(() => {
@@ -0,0 +1,57 @@
/**
* 设置 → 快捷键:WorkBuddy 5.8.3 原版表格的 24 行命令与按键绑定。
*
* REA 依据(设置窗口 target 6D5A7FEE…,900×680):
* - `ev_5f3148eb51ea4a6e5b6f2ffea5f6e3dfb94e4009ecf2d16173fa676ce7c51b4b`:浅色顶部布局/计算样式/模块 CSS
* - `ev_7266946b025216056fc67f7c6d3c991894e985b6a8486a3153c3508e21771f73`:四段滚动采样(顶部/中/底/回顶)
* - `ev_b3e014e5cff4971d7c5e75fefbbfefb0088db9ce655ef7ff33114cd3c4caead9`:搜索、禁用恢复默认、录制/取消交互帧
* - `ev_f4fd417445481ef93c34a7e85be45e63327daa2acbccaf169706d7e19bacc531`:深色同页布局与计算样式
* - `ev_528724699462fa08e2e4119bb8504c0180483c86cd844461cc969422b915650b`:最终浅色顶部稳定态
*
* 文案与键位读自 REA PNG 帧;几何/样式读自 REA layout JSON。前 16 行在原版可编辑(有删除按钮),
* 后 8 行带 `shortcut-item--not-editable`、键帽不透明度 0.5、操作列显示「—」。
*
* `supported` 只描述 YCode 当前是否真的绑定了该动作:true 的行按键与实际行为一致;
* false 的行只还原原版展示,通过 title 明确标注「YCode 尚未接入」,不做能力宣称。
*/
export interface ShortcutRow {
id: string
label: string
/** 原版键帽文本(组合键用 `+` 连接、无空格)。 */
keys: string
/** 原版把「长按」渲染为键帽前的纯文本(仅语音录制开关一行)。 */
prefix?: string
editable: boolean
supported: boolean
/** 真实动作说明;仅 supported 行填写。 */
action?: string
}
export const SHORTCUT_ROWS: ReadonlyArray<ShortcutRow> = [
{ id: 'global-search-open', label: '打开全局搜索', keys: 'Ctrl+K', editable: true, supported: true, action: '展开侧栏并聚焦任务搜索框' },
{ id: 'open-settings', label: '打开设置', keys: 'Ctrl+,', editable: true, supported: true, action: '打开设置窗口' },
{ id: 'voice-record', label: '语音录制开关', keys: 'Alt', prefix: '长按', editable: true, supported: false },
{ id: 'conversation-search', label: '对话内搜索', keys: 'Ctrl+F', editable: true, supported: false },
{ id: 'global-search', label: '全局搜索', keys: 'Ctrl+K', editable: true, supported: true, action: '展开侧栏并聚焦任务搜索框' },
{ id: 'send-message', label: '发送消息', keys: 'Enter', editable: true, supported: true, action: '输入框内 Enter 发送' },
{ id: 'newline', label: '输入时换行', keys: 'Shift+Enter', editable: true, supported: true, action: '输入框内 Shift+Enter 换行' },
{ id: 'new-conversation', label: '新建对话', keys: 'Ctrl+N', editable: true, supported: true, action: '新建任务并切到对话页' },
{ id: 'stop-generation', label: '停止生成', keys: 'Esc', editable: true, supported: true, action: '生成中按 Esc 停止本次运行' },
{ id: 'previous-task', label: '上一个任务', keys: 'Ctrl+[', editable: true, supported: false },
{ id: 'next-task', label: '下一个任务', keys: 'Ctrl+]', editable: true, supported: false },
{ id: 'locate-task', label: '定位当前任务', keys: 'Ctrl+Shift+E', editable: true, supported: false },
{ id: 'toggle-left-sidebar', label: '切换左侧栏', keys: 'Ctrl+B', editable: true, supported: true, action: '收起/展开左侧栏' },
{ id: 'toggle-right-sidebar', label: '切换右侧栏', keys: 'Ctrl+Shift+B', editable: true, supported: true, action: '收起/展开右侧栏' },
{ id: 'toggle-fullscreen', label: '进入/退出全屏', keys: 'F11', editable: true, supported: false },
{ id: 'toggle-main-window', label: '唤起/隐藏主窗口', keys: 'Shift+Alt+W', editable: true, supported: false },
{ id: 'font-increase', label: '放大内容字号', keys: 'Ctrl+"+"', editable: false, supported: true, action: 'Ctrl+= / Ctrl+Shift+= 提升一档字号' },
{ id: 'font-decrease', label: '缩小内容字号', keys: 'Ctrl+"-"', editable: false, supported: true, action: 'Ctrl+- 降低一档字号' },
{ id: 'font-reset', label: '重置内容字号', keys: 'Ctrl+0', editable: false, supported: true, action: 'Ctrl+0 恢复标准字号' },
{ id: 'mention-picker', label: '唤起选择器', keys: '@', editable: false, supported: false },
{ id: 'slash-command', label: '唤起斜杠命令', keys: '/', editable: false, supported: false },
{ id: 'force-external-open', label: '按住后点击,始终外部打开', keys: 'Ctrl', editable: false, supported: false },
{ id: 'new-tab', label: '新建标签页', keys: 'Ctrl+T', editable: false, supported: true, action: '工作台焦点内新建标签页' },
{ id: 'close-tab', label: '关闭标签页', keys: 'Ctrl+W', editable: false, supported: true, action: '工作台焦点内关闭当前标签页' }
]
export const SUPPORTED_SHORTCUT_COUNT = SHORTCUT_ROWS.filter((row) => row.supported).length
@@ -0,0 +1,54 @@
/* REA ev_5f3148eb51ea4a6e5b6f2ffea5f6e3dfb94e4009ecf2d16173fa676ce7c51b4b; keyboard-shortcuts-DOrivHwf.css sha256=d2c570c8d65db87c1cb9df06b59e6aa191b772330857376b85fd923aed580ae8; tokens sha256=13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696. */
.keyboard-shortcuts-panel { box-sizing: border-box; flex-direction: column; width: 100%; min-width: 0px; height: 100%; padding: 0px; display: flex; position: relative; }
.keyboard-shortcuts-panel__toolbar { align-items: center; gap: var(--wb-spacing-4); margin-bottom: var(--wb-spacing-5); display: flex; }
.keyboard-shortcuts-panel__search { flex: 1 1 0%; min-width: 0px; }
.keyboard-shortcuts-panel__column-header { border-bottom: 1px solid var(--wb-border-subtle); z-index: 1; background: var(--wb-bg-primary); align-items: center; display: flex; position: sticky; top: 0px; }
.keyboard-shortcuts-panel__column-header span { color: var(--wb-color-text-tertiary); font-size: var(--wb-font-spec-body-md-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); letter-spacing: 0px; }
.keyboard-shortcuts-panel__col-command { width: 40%; min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); text-align: left; text-overflow: ellipsis; flex-shrink: 0; overflow: hidden; }
.keyboard-shortcuts-panel__col-binding { box-sizing: border-box; width: calc(60% - 112px); min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); text-align: left; flex-shrink: 0; overflow: hidden; }
.keyboard-shortcuts-panel__col-action { box-sizing: border-box; width: 112px; padding: var(--wb-spacing-3) var(--wb-spacing-5); text-align: left; flex-shrink: 0; }
.keyboard-shortcuts-panel--readonly .keyboard-shortcuts-panel__col-binding,.keyboard-shortcuts-panel--readonly .shortcut-item__binding { width: 60%; }
.keyboard-shortcuts-panel__list { border-radius: var(--wb-radius-xl); background: var(--wb-bg-primary); border: none; flex-direction: column; flex: 0 1 auto; min-height: 0px; display: flex; overflow: hidden; }
.keyboard-shortcuts-panel__list-scroll { scrollbar-gutter: stable; flex-direction: column; flex: 1 1 0%; min-height: 0px; display: flex; overflow: hidden auto; }
.keyboard-shortcuts-panel__list-scroll::-webkit-scrollbar { width: 6px; }
.keyboard-shortcuts-panel__list-scroll::-webkit-scrollbar-track { background: 0px 0px; }
.keyboard-shortcuts-panel__list-scroll::-webkit-scrollbar-thumb { border-radius: var(--wb-radius-sm); transition: background-color var(--wb-motion-duration-base) var(--wb-motion-easing-standard); background-color: rgba(0, 0, 0, 0); }
.keyboard-shortcuts-panel__list-scroll:hover::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--wb-color-text-secondary) 28%, transparent); }
.keyboard-shortcuts-panel__empty { padding: var(--wb-spacing-10) 0; color: var(--wb-color-text-tertiary); font-size: var(--wb-font-spec-body-md-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); text-align: center; }
.shortcut-item { background: var(--wb-bg-primary); max-width: 100%; transition: background-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); align-items: center; display: flex; }
.shortcut-item:not(:last-child) { border-bottom: 1px solid var(--wb-border-subtle); }
.shortcut-item:hover:not(.shortcut-item--recording) { background: var(--wb-bg-hover-subtle); }
.shortcut-item--recording { overflow: visible; }
.shortcut-item--not-editable .shortcut-item__key { opacity: 0.5; }
.shortcut-item__label { width: 40%; min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); color: var(--wb-color-text-primary); font-size: var(--wb-font-spec-body-md-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; overflow: hidden; }
.shortcut-item__binding { box-sizing: border-box; width: calc(60% - 112px); min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); flex-shrink: 0; align-items: center; gap: 0px; display: flex; overflow: hidden; }
.shortcut-item__binding--recording-layout { flex-direction: column; align-items: flex-start; overflow: visible; }
.shortcut-item__recording-row { align-items: center; gap: var(--wb-spacing-2); display: flex; }
.shortcut-item__binding-tooltip-anchor { align-items: center; display: inline-flex; }
.shortcut-item__binding-tooltip-anchor--clickable { border-radius: var(--wb-radius-sm); cursor: pointer; }
.shortcut-item__actions { justify-content: flex-start; align-items: center; gap: var(--wb-spacing-1); box-sizing: border-box; width: 112px; padding: var(--wb-spacing-3) var(--wb-spacing-5); flex-shrink: 0; display: flex; overflow: visible; }
.shortcut-item__action-btn-group { align-items: center; gap: var(--wb-spacing-3); display: flex; }
.shortcut-item__action-btn-placeholder { flex-shrink: 0; width: 28px; height: 28px; }
.shortcut-item__keys { align-items: center; gap: var(--wb-spacing-2); display: flex; }
.shortcut-item__key { border-radius: var(--wb-radius-md); background: var(--wb-color-bg-primary-default); min-width: 22px; height: 22px; color: var(--wb-color-text-primary); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); white-space: nowrap; justify-content: center; align-items: center; padding: 0px 6px; display: inline-flex; }
.shortcut-item__unassigned,.shortcut-item__recording-hint { color: var(--wb-color-text-disabled); font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); }
.shortcut-item__error-hint { color: var(--wb-status-error); font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); white-space: nowrap; }
.shortcut-item__recording-box { align-items: center; gap: var(--wb-spacing-1); min-width: 120px; min-height: 24px; padding: 0 var(--wb-spacing-3); border: 1px solid var(--wb-border-control); border-radius: var(--wb-radius-md); background: var(--wb-bg-primary); transition: border-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); display: inline-flex; }
.shortcut-item__recording-box--error { border-color: var(--wb-status-error); }
.shortcut-item__recording-text { color: var(--wb-color-text-primary); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); white-space: nowrap; }
.shortcut-item__recording-text--error { color: var(--wb-status-error); }
.shortcut-item__recording-cursor { background: var(--wb-color-text-primary); flex-shrink: 0; width: 1px; height: 14px; animation: 1s step-end 0s infinite normal none running recording-cursor-blink; display: inline-block; }
.shortcut-item__action-btn.wb-button .wb-button__icon { width: var(--wb-icon-size-md); height: var(--wb-icon-size-md); }
.shortcut-item__action-btn--reset.wb-button .wb-button__icon svg[fill="none"] { fill: none; }
.shortcut-item__action-btn:focus-visible { outline: 2px solid var(--wb-border-focus); outline-offset: var(--wb-spacing-1); }
.shortcut-item__actions-placeholder { width: 28px; height: 28px; color: var(--wb-color-text-secondary); font-size: var(--wb-font-body-size); opacity: 0.6; user-select: none; justify-content: center; align-items: center; display: inline-flex; }
.shortcut-item__esc-cancel { padding: var(--wb-spacing-1) var(--wb-spacing-3); border-radius: var(--wb-radius-sm); color: var(--wb-color-text-tertiary); font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); cursor: pointer; white-space: nowrap; transition: background-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard), color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); }
.shortcut-item__esc-cancel:hover { background: var(--wb-bg-hover); color: var(--wb-color-text-primary); }
.shortcut-item__esc-cancel:focus-visible { outline: 2px solid var(--wb-border-focus); outline-offset: var(--wb-spacing-1); }
[data-theme=dark] .shortcut-item__key { background: var(--wb-bg-hover-light); }
@keyframes recording-cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.keyboard-shortcuts-panel{--wb-spacing-4:12px;--wb-spacing-5:16px;--wb-border-subtle:var(--wb-palette-gray-3);--wb-bg-primary:var(--wb-palette-white-100);--wb-color-text-tertiary:var(--wb-palette-black-50);--wb-font-spec-body-md-size:var(--wb-font-size-5);--wb-font-weight-400:400;--wb-font-spec-body-md-line-height:var(--wb-font-line-height-5-2);--wb-spacing-3:8px;--wb-radius-xl:12px;--wb-radius-sm:4px;--wb-motion-duration-base:.2s;--wb-motion-easing-standard:cubic-bezier(.4, 0, .2, 1);--wb-color-text-secondary:var(--wb-palette-black-70);--wb-spacing-10:48px;--wb-motion-duration-fast:.15s;--wb-bg-hover-subtle:var(--wb-palette-gray-1);--wb-color-text-primary:var(--wb-palette-black-100);--wb-spacing-2:4px;--wb-spacing-1:2px;--wb-radius-md:6px;--wb-color-bg-primary-default:var(--wb-palette-gray-3);--wb-font-spec-body-sm-size:var(--wb-font-size-3);--wb-font-spec-body-sm-line-height:var(--wb-font-line-height-5);--wb-color-text-disabled:var(--wb-palette-black-30);--wb-status-error:var(--wb-palette-red-8);--wb-border-control:var(--wb-palette-gray-4);--wb-icon-size-md:var(--wb-size-4);--wb-border-focus:var(--wb-color-border-focus);--wb-font-body-size:var(--wb-font-size-5);--wb-bg-hover:color-mix(in srgb, var(--wb-palette-black-100) 5%, transparent);--wb-bg-hover-light:var(--wb-palette-gray-3);--wb-palette-gray-3:#f2f2f2;--wb-palette-white-100:#fff;--wb-palette-black-50:#00000080;--wb-font-size-5:14px;--wb-font-line-height-5-2:22px;--wb-palette-black-70:#000000b3;--wb-palette-gray-1:#fafafa;--wb-bg-tertiary:var(--wb-color-bg-primary-hover);--wb-palette-black-100:#000;--wb-font-size-3:12px;--wb-font-line-height-5:20px;--wb-palette-black-30:#0000004d;--wb-palette-red-8:#f64041;--wb-color-text-error-default:var(--wb-palette-red-8);--wb-palette-gray-4:#ebebeb;--wb-size-4:16px;--wb-color-border-focus:var(--wb-palette-black-75);--wb-color-bg-primary-hover:var(--wb-palette-gray-4);--wb-palette-red-7:#e85c5c;--wb-palette-black-75:#000000bf;--wb-palette-white-70:#ffffffb3}
:root[data-theme=dark] .keyboard-shortcuts-panel{--wb-spacing-4:12px;--wb-spacing-5:16px;--wb-border-subtle:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-bg-primary:var(--wb-palette-gray-3);--wb-color-text-tertiary:#ffffff80;--wb-font-spec-body-md-size:var(--wb-font-size-5);--wb-font-weight-400:400;--wb-font-spec-body-md-line-height:var(--wb-font-line-height-5-2);--wb-spacing-3:8px;--wb-radius-xl:12px;--wb-radius-sm:4px;--wb-motion-duration-base:.2s;--wb-motion-easing-standard:cubic-bezier(.4, 0, .2, 1);--wb-color-text-secondary:#ffffffa6;--wb-spacing-10:48px;--wb-motion-duration-fast:.15s;--wb-bg-hover-subtle:var(--wb-bg-tertiary);--wb-color-text-primary:#fff;--wb-spacing-2:4px;--wb-spacing-1:2px;--wb-radius-md:6px;--wb-color-bg-primary-default:var(--wb-palette-gray-3);--wb-font-spec-body-sm-size:var(--wb-font-size-3);--wb-font-spec-body-sm-line-height:var(--wb-font-line-height-5);--wb-color-text-disabled:#ffffff59;--wb-status-error:var(--wb-color-text-error-default);--wb-border-control:color-mix(in srgb, var(--wb-palette-white-100) 22%, transparent);--wb-icon-size-md:var(--wb-size-4);--wb-border-focus:var(--wb-color-border-focus);--wb-font-body-size:var(--wb-font-size-5);--wb-bg-hover:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-bg-hover-light:color-mix(in srgb, var(--wb-palette-white-100) 8%, transparent);--wb-palette-gray-3:#1f1f1f;--wb-palette-white-100:#fff;--wb-palette-black-50:#00000080;--wb-font-size-5:14px;--wb-font-line-height-5-2:22px;--wb-palette-black-70:#000000b3;--wb-palette-gray-1:#141414;--wb-bg-tertiary:var(--wb-palette-gray-4);--wb-palette-black-100:#000;--wb-font-size-3:12px;--wb-font-line-height-5:20px;--wb-palette-black-30:#0000004d;--wb-palette-red-8:#af191a;--wb-color-text-error-default:var(--wb-palette-red-7);--wb-palette-gray-4:#242424;--wb-size-4:16px;--wb-color-border-focus:var(--wb-palette-white-70);--wb-color-bg-primary-hover:var(--wb-palette-gray-4);--wb-palette-red-7:#a31717;--wb-palette-black-75:#000000bf;--wb-palette-white-70:#ffffffb3}
@@ -0,0 +1,153 @@
/*
* 设置 → 快捷键(TODO 12)。
* 第一段:REA 回收的原版模块样式(keyboard-shortcuts-DOrivHwf.css 的投影)。
* 第二段:本地适配——原版把输入/按钮基件放在 UI kit 样式表里,这里只补快捷键面板引用到的那几个基件,
* 取值来自 REA `ev_5f3148…` 的同页 computed style(浅色)与 tokens 深色取值。
*/
@import './source-shortcuts-panel-recovered.css';
/* ---- UI kit 基件适配:搜索输入框(原版 .wb-input-wrapper / .wb-input__inner) ---- */
.keyboard-shortcuts-panel .wb-input-wrapper {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-2);
box-sizing: border-box;
width: 100%;
height: 32px;
padding: 0 4px 0 8px;
background: var(--wb-bg-primary);
border: 1px solid var(--wb-input-border, #e6e6e6);
border-radius: 8px;
transition: border-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard);
}
.keyboard-shortcuts-panel .wb-input-wrapper:hover {
border-color: var(--wb-input-border-hover, #d9d9d9);
}
.keyboard-shortcuts-panel .wb-input-wrapper:focus-within {
border-color: var(--wb-color-border-focus);
}
.keyboard-shortcuts-panel .wb-input__inner {
flex: auto;
min-width: 0;
height: 100%;
padding: 0;
color: var(--wb-color-text-primary);
font: inherit;
font-size: var(--wb-font-spec-body-md-size);
line-height: var(--wb-font-spec-body-md-line-height);
background: transparent;
border: none;
outline: none;
}
.keyboard-shortcuts-panel .wb-input__inner::placeholder {
color: var(--wb-color-text-tertiary);
}
.keyboard-shortcuts-panel .wb-input__inner::-webkit-search-cancel-button {
display: none;
}
:root[data-theme=dark] .keyboard-shortcuts-panel .wb-input-wrapper {
border-color: color-mix(in srgb, var(--wb-palette-white-100) 15%, transparent);
}
/* ---- UI kit 基件适配:按钮(原版 .wb-button / .wb-button--medium / --xsmall / --ghost) ---- */
.keyboard-shortcuts-panel .wb-button {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: none;
outline: none;
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-weight: 500;
white-space: nowrap;
user-select: none;
cursor: pointer;
transition: background-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard),
color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard),
border-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard);
}
.keyboard-shortcuts-panel .wb-button--medium {
height: 32px;
padding: 0 12px;
gap: 4px;
font-size: var(--wb-font-spec-body-md-size);
line-height: var(--wb-font-spec-body-md-line-height);
border-radius: 8px;
}
.keyboard-shortcuts-panel .wb-button--secondary {
color: var(--wb-color-text-primary);
background: var(--wb-bg-primary);
border: 1px solid var(--wb-button-secondary-border, #ebebeb);
}
.keyboard-shortcuts-panel .wb-button--secondary.wb-button--disabled,
.keyboard-shortcuts-panel .wb-button--secondary:disabled {
color: var(--wb-color-text-disabled);
cursor: not-allowed;
}
.keyboard-shortcuts-panel .wb-button--xsmall {
/* REA ev_5f3148…:可编辑行高 41 = 8 + 24 + 8 + 1(行分隔线),删除按钮为 24×24。 */
width: 24px;
height: 24px;
padding: 0;
gap: 0;
border-radius: 6px;
}
.keyboard-shortcuts-panel .wb-button--xsmall.wb-button--icon-only {
width: 24px;
height: 24px;
}
.keyboard-shortcuts-panel .wb-button--ghost {
color: var(--wb-color-text-secondary);
background: transparent;
}
.keyboard-shortcuts-panel .wb-button--ghost:hover:not(:disabled) {
background: var(--wb-bg-hover);
color: var(--wb-color-text-primary);
}
.keyboard-shortcuts-panel .wb-button--ghost:disabled {
color: var(--wb-color-text-disabled);
cursor: not-allowed;
}
.keyboard-shortcuts-panel .wb-button__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--wb-icon-size-md);
height: var(--wb-icon-size-md);
flex-shrink: 0;
}
.keyboard-shortcuts-panel .wb-button__icon svg {
width: 100%;
height: 100%;
color: currentColor;
}
:root[data-theme=dark] .keyboard-shortcuts-panel .wb-button--secondary {
border-color: var(--wb-border-subtle);
}
/*
* REA ev_f4fd41…(深色同页 layout):`.shortcut-item__label` / `.shortcut-item__key` 的 color 为
* rgb(242,243,245)。原版设置窗口作用域把 `--wb-color-text-primary` 覆盖成 #f2f3f5,比全局 tokens
* 的 #fff 更具体;这里按同一优先级还原,避免正文变成纯白。
*/
:root[data-theme=dark] .keyboard-shortcuts-panel {
--wb-color-text-primary: #f2f3f5;
}
/* ---- 可点击绑定锚点:用 button 承载,去掉浏览器默认外观 ---- */
.keyboard-shortcuts-panel button.shortcut-item__binding-tooltip-anchor {
padding: 0;
color: inherit;
font: inherit;
background: none;
border: none;
}
.keyboard-shortcuts-panel .shortcut-item__content,
.keyboard-shortcuts-panel .shortcut-item__binding .shortcut-item__error-hint {
margin-left: var(--wb-spacing-3);
}
.keyboard-shortcuts-panel .shortcut-item__prefix {
margin-right: var(--wb-spacing-2);
color: var(--wb-color-text-primary);
font-size: var(--wb-font-spec-body-sm-size);
line-height: var(--wb-font-spec-body-sm-line-height);
}