diff --git a/README.md b/README.md index fd89309..f5e3cfa 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ It is designed for technical explanations, teaching videos, visual notes, produc 4. Open the recording settings panel. 5. Choose the canvas ratio, canvas color, canvas pattern and frame background. 6. Optionally enable the camera overlay, choose a microphone and use the teleprompter. -7. Start recording and present your slides. -8. Export the recording as a video file. +7. Optionally open **Settings → Shortcuts** to customize keyboard bindings for tools, editing and recording. +8. Start recording and present your slides. +9. Export the recording as a video file. ## Desktop-first Notice @@ -67,6 +68,8 @@ For the best experience, use a modern Chromium-based browser such as Microsoft E - Independent microphone device selection - Teleprompter with adjustable speed and opacity - Browser-based recording output +- Customizable keyboard shortcuts (tools, edit actions, palette colors, zoom and recording) +- Tabbed recording settings panel (canvas, background, camera, cursor, shortcuts) ## Screenshots @@ -143,11 +146,15 @@ Camera overlay and microphone input are controlled separately. Users can record CanvasCast includes a floating teleprompter with playback controls, adjustable scrolling speed and opacity settings for smoother recording sessions. +### Keyboard shortcuts + +CanvasCast supports customizable shortcuts for tools, undo/redo, copy/paste, palette colors, zoom and recording controls. Defaults cover common editor actions; recording shortcuts are left unassigned so you can bind them in **Settings → Shortcuts**. Assigned keys are shown as small hints on the toolbar and recording controls. Settings are saved in the browser. Press **Escape** to close open panels or clear the current selection. + ## Current Status CanvasCast is currently in the MVP stage. -Core whiteboard editing, slide workflow, recording settings, frame backgrounds, camera overlay, teleprompter and browser-based recording have been implemented. +Core whiteboard editing, slide workflow, recording settings, frame backgrounds, camera overlay, teleprompter, customizable keyboard shortcuts and browser-based recording have been implemented. ## Roadmap @@ -194,8 +201,9 @@ CanvasCast 是一个基于 React、TypeScript 和 Vite 构建的浏览器端白 4. 打开录制设置,调整画布比例、背景、画布颜色和画布样式。 5. 如有需要,打开提词器,提前准备讲解脚本。 6. 根据录制需要开启摄像头小窗,或只选择麦克风录制声音。 -7. 点击录制按钮开始讲解。 -8. 录制结束后导出视频文件。 +7. 如有需要,可在 **设置 → 快捷键** 中自定义工具、编辑操作与录制相关快捷键。 +8. 点击录制按钮开始讲解。 +9. 录制结束后导出视频文件。 ## 使用建议 @@ -222,6 +230,8 @@ CanvasCast 是一个基于 React、TypeScript 和 Vite 构建的浏览器端白 - 独立麦克风选择 - 提词器 - 浏览器端录制导出 +- 可自定义快捷键(工具、编辑操作、调色板、缩放与录制) +- 分标签页的录制设置(画布、背景、摄像头、光标、快捷键) ## 技术栈 @@ -284,6 +294,10 @@ CanvasCast 支持多张幻灯片、缩略图、复制、删除、重命名和拖 内置提词器支持播放控制、滚动速度和透明度调整,适合需要脚本辅助的讲解和演示录制。 +### 快捷键支持 + +支持自定义工具切换、撤销/重做、复制粘贴、调色板颜色、缩放以及录制相关操作的快捷键。常用编辑快捷键有默认值;录制相关快捷键默认不绑定,可在 **设置 → 快捷键** 中自行配置。已绑定的快捷键会在工具栏和录制控件上显示轻量提示,设置会保存在浏览器本地。按 **Esc** 可依次关闭已打开的面板或取消当前选中。 + ## 当前版本限制 - 当前主要面向电脑端浏览器。 @@ -294,7 +308,7 @@ CanvasCast 支持多张幻灯片、缩略图、复制、删除、重命名和拖 ## 当前状态 -CanvasCast 目前处于 MVP 阶段。核心白板编辑、幻灯片工作流、录制设置、背景图、摄像头小窗、麦克风选择、提词器和浏览器端录制功能已经完成。 +CanvasCast 目前处于 MVP 阶段。核心白板编辑、幻灯片工作流、录制设置、背景图、摄像头小窗、麦克风选择、提词器、可自定义快捷键和浏览器端录制功能已经完成。 后续会继续优化导出能力、移动端适配、性能表现和更多录制体验。 diff --git a/src/App.css b/src/App.css index 606d7a7..0d333f5 100644 --- a/src/App.css +++ b/src/App.css @@ -1,4 +1,4 @@ -:root { +:root { color: #1f2937; background: #eef1f6; font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; @@ -321,10 +321,42 @@ button { margin: 0; } +.settings-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); +} + +.settings-tab { + padding: 8px 14px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(248, 250, 252, 0.9); + color: #475569; + font-size: 0.84rem; + font-weight: 700; + transition: background-color 0.18s, color 0.18s, border-color 0.18s, box-shadow 0.18s; +} + +.settings-tab:hover { + background: rgba(241, 245, 249, 0.95); + color: #1e293b; +} + +.settings-tab--active { + background: rgba(109, 93, 252, 0.12); + border-color: rgba(109, 93, 252, 0.28); + color: #4338ca; + box-shadow: inset 0 0 0 1px rgba(109, 93, 252, 0.12); +} + .settings-content { flex: 1; min-height: 0; display: flex; + padding-top: 14px; } .settings-scroll { @@ -1987,3 +2019,52 @@ button { border-color: #8b5cf6 !important; box-sizing: border-box; } + +.shortcut-settings__description { + margin: 0; + color: #475569; + font-size: 0.84rem; + line-height: 1.6; +} + +.shortcut-settings__description code { + font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.78rem; + background: rgba(148, 163, 184, 0.2); + border-radius: 6px; + padding: 1px 6px; +} + +.shortcut-settings__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; +} + +.shortcut-settings__field { + display: flex; + flex-direction: column; + gap: 6px; + color: #334155; + font-size: 0.82rem; + font-weight: 600; +} + +.shortcut-settings__field input { + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 10px; + background: #ffffff; + color: #0f172a; + padding: 8px 10px; + font-size: 0.82rem; + font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; +} + +.shortcut-settings__field input:focus { + outline: 2px solid rgba(59, 130, 246, 0.32); + border-color: rgba(59, 130, 246, 0.6); +} + +.shortcut-settings__actions { + margin-top: 12px; +} diff --git a/src/App.tsx b/src/App.tsx index b2e8cd3..1729f07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,8 @@ import { DEFAULT_CAMERA_SETTINGS, DEFAULT_RECORDING_VISUAL_SETTINGS } from './ca import type { CameraSettings, MediaDeviceChoice, RecordingVisualSettings } from './cameraTypes'; import { aspectRatioOptions } from './mockOptions'; import { frameBackgroundPresets } from './frameBackgrounds'; +import { DEFAULT_SHORTCUTS, loadShortcutSettings, saveShortcutSettings } from './shortcuts'; +import type { ShortcutAction, ShortcutMap } from './shortcuts'; const DESKTOP_MIN_WIDTH = 900; @@ -28,6 +30,7 @@ function CanvasCastApp() { const [cameraStream, setCameraStream] = useState(null); const [microphoneStream, setMicrophoneStream] = useState(null); const [mediaError, setMediaError] = useState(null); + const [shortcutSettings, setShortcutSettings] = useState(() => loadShortcutSettings()); const activeAspectItem = useMemo( () => aspectRatioOptions.find((option) => option.key === activeAspect) ?? aspectRatioOptions[4], [activeAspect] @@ -44,6 +47,12 @@ function CanvasCastApp() { const updateRecordingVisualSettings = useCallback((patch: Partial) => { setRecordingVisualSettings((current) => ({ ...current, ...patch })); }, []); + const updateShortcutSetting = useCallback((action: ShortcutAction, value: string) => { + setShortcutSettings((current) => ({ ...current, [action]: value })); + }, []); + const resetShortcutSettings = useCallback(() => { + setShortcutSettings({ ...DEFAULT_SHORTCUTS }); + }, []); const refreshDevices = useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) { @@ -194,6 +203,28 @@ function CanvasCastApp() { }; }, [cameraSettings.audioDeviceId, refreshDevices]); + useEffect(() => { + saveShortcutSettings(shortcutSettings); + }, [shortcutSettings]); + + useEffect(() => { + if (!settingsOpen) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + + event.preventDefault(); + setSettingsOpen(false); + }; + + window.addEventListener('keydown', handleEscape, { capture: true }); + return () => window.removeEventListener('keydown', handleEscape, { capture: true }); + }, [settingsOpen]); + return (
{settingsOpen && ( @@ -237,6 +270,9 @@ function CanvasCastApp() { cameraStream={cameraStream} mediaError={mediaError} onRefreshDevices={refreshDevices} + shortcutSettings={shortcutSettings} + onShortcutChange={updateShortcutSetting} + onShortcutReset={resetShortcutSettings} onClose={() => setSettingsOpen(false)} />
diff --git a/src/components/BackgroundSection.tsx b/src/components/BackgroundSection.tsx index 390979e..e819c05 100644 --- a/src/components/BackgroundSection.tsx +++ b/src/components/BackgroundSection.tsx @@ -1,10 +1,11 @@ -import type { FrameBackgroundPreset } from '../frameBackgrounds'; +import type { FrameBackgroundPreset } from '../frameBackgrounds'; type BackgroundSectionProps = { options: FrameBackgroundPreset[]; selectedBackgroundId: string; onSelectBackground: (id: string) => void; onRandomSelect: () => void; + showTitle?: boolean; }; function BackgroundSection({ @@ -12,12 +13,13 @@ function BackgroundSection({ selectedBackgroundId, onSelectBackground, onRandomSelect, + showTitle = true, }: BackgroundSectionProps) { const hasBackgrounds = options.length > 0; return (
-
{'\u80cc\u666f'}
+ {showTitle ?
{'\u80cc\u666f'}
: null} ); } +function ControlHint({ binding }: { binding: string | undefined }) { + const hint = formatShortcutHint(binding); + if (!hint) { + return null; + } + + return {hint}; +} + function FloatingControlBar({ onOpenSettings, onEnterPreparing, @@ -68,40 +84,66 @@ function FloatingControlBar({ onToggleTeleprompter, recordingStatus, recordingElapsedLabel, + shortcutSettings, }: FloatingControlBarProps) { return (
{recordingStatus === 'idle' ? ( <> - - + + ) : null} {recordingStatus === 'preparing' ? ( <> - + ) : null} {recordingStatus === 'recording' ? ( <> - + {recordingElapsedLabel} @@ -109,12 +151,19 @@ function FloatingControlBar({ {recordingStatus === 'paused' ? ( <> - + {recordingElapsedLabel} diff --git a/src/components/LeftPropertiesPanel.tsx b/src/components/LeftPropertiesPanel.tsx index a891395..85c3eba 100644 --- a/src/components/LeftPropertiesPanel.tsx +++ b/src/components/LeftPropertiesPanel.tsx @@ -1,5 +1,7 @@ import { useRef } from 'react'; import type { CSSProperties, ChangeEvent } from 'react'; +import { formatShortcutHint } from '../shortcuts'; +import type { ShortcutMap } from '../shortcuts'; import type { ColorStyle, LayerAction, StrokeStyle, TextStyle, ToolType } from '../whiteboard/types'; import { BOARD_COLOR_OPTIONS, @@ -52,6 +54,7 @@ type LeftPropertiesPanelProps = { showCropImageAction: boolean; canCropImage: boolean; onCropImage: () => void; + shortcutSettings: ShortcutMap; }; type PanelIconName = @@ -121,6 +124,7 @@ function LeftPropertiesPanel({ showCropImageAction, canCropImage, onCropImage, + shortcutSettings, }: LeftPropertiesPanelProps) { const customColorInputRef = useRef(null); const customFillColorInputRef = useRef(null); @@ -223,17 +227,20 @@ function LeftPropertiesPanel({ const renderColorPalette = () => (
- {EXTENDED_COLOR_OPTIONS.map((color) => { + {EXTENDED_COLOR_OPTIONS.map((color, index) => { const isActive = activeColor === color; + const paletteBinding = index < 9 ? shortcutSettings[`palette${index + 1}` as keyof ShortcutMap] : undefined; return ( -
); })} ))}
@@ -541,6 +557,15 @@ function LeftPropertiesPanel({ ); } +function ShortcutHint({ binding }: { binding: string | undefined }) { + const hint = formatShortcutHint(binding); + if (!hint) { + return null; + } + + return {hint}; +} + function renderActionLabel(label: string) { const splitLabels: Record = { ['\u6c34\u5e73\u7ffb\u8f6c']: ['\u6c34\u5e73', '\u7ffb\u8f6c'], diff --git a/src/components/RecordingSettingsModal.tsx b/src/components/RecordingSettingsModal.tsx index 5391e2c..9db192c 100644 --- a/src/components/RecordingSettingsModal.tsx +++ b/src/components/RecordingSettingsModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import AspectRatioSection from './AspectRatioSection'; import BackgroundSection from './BackgroundSection'; import CameraSection from './CameraSection'; @@ -7,6 +7,8 @@ import PreviewPanel from './PreviewPanel'; import { getCanvasPatternColor, normalizeCanvasBackgroundColor } from '../canvasBackground'; import { aspectRatioOptions } from '../mockOptions'; import { frameBackgroundPresets } from '../frameBackgrounds'; +import { DEFAULT_SHORTCUTS, SHORTCUT_SECTIONS } from '../shortcuts'; +import type { ShortcutAction, ShortcutMap } from '../shortcuts'; const CANVAS_PADDING_MAX = 80; const CANVAS_BACKGROUND_SPACING_MIN = 40; @@ -20,6 +22,16 @@ const canvasBackgroundColors = [ { label: '深色', value: '#242424' }, ]; +type SettingsTabId = 'canvas' | 'background' | 'camera' | 'cursor' | 'shortcuts'; + +const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [ + { id: 'canvas', label: '画布' }, + { id: 'background', label: '背景' }, + { id: 'camera', label: '摄像头' }, + { id: 'cursor', label: '光标' }, + { id: 'shortcuts', label: '快捷键' }, +]; + const canvasBackgroundPatterns: Array<{ label: string; value: CanvasBackgroundPattern }> = [ { label: '无', value: 'none' }, { label: '单线', value: 'ruled' }, @@ -41,6 +53,9 @@ type RecordingSettingsModalProps = { cameraStream: MediaStream | null; mediaError: string | null; onRefreshDevices: () => void; + shortcutSettings: ShortcutMap; + onShortcutChange: (action: ShortcutAction, value: string) => void; + onShortcutReset: () => void; onClose?: () => void; }; @@ -58,8 +73,12 @@ function RecordingSettingsModal({ cameraStream, mediaError, onRefreshDevices, + shortcutSettings, + onShortcutChange, + onShortcutReset, onClose, }: RecordingSettingsModalProps) { + const [activeTab, setActiveTab] = useState('canvas'); const selectedBackground = useMemo( () => frameBackgroundPresets.find((option) => option.id === activeBackgroundId) ?? null, [activeBackgroundId] @@ -99,40 +118,81 @@ function RecordingSettingsModal({
+
+ {SETTINGS_TABS.map((tab) => ( + + ))} +
+
-
- -
- -
- -
- -
- -
- -
- -
+ {activeTab === 'canvas' ? ( +
+ +
+ ) : null} + + {activeTab === 'background' ? ( +
+ +
+ ) : null} + + {activeTab === 'camera' ? ( +
+ +
+ ) : null} + + {activeTab === 'cursor' ? ( +
+ +
+ ) : null} + + {activeTab === 'shortcuts' ? ( +
+ +
+ ) : null}
@@ -146,11 +206,13 @@ function CanvasSection({ onAspectChange, settings, onChange, + showTitle = true, }: { activeAspect: string; onAspectChange: (aspect: string) => void; settings: RecordingVisualSettings; onChange: (patch: Partial) => void; + showTitle?: boolean; }) { const canvasPaddingValue = Math.min(settings.canvasPadding, CANVAS_PADDING_MAX); const canvasBackgroundSpacing = clampBackgroundSpacing(settings.canvasBackgroundSpacing); @@ -163,7 +225,7 @@ function CanvasSection({ return (
-
画布
+ {showTitle ?
画布
: null}
画布比例
) => void; + showTitle?: boolean; }) { return (
-
光标
+ {showTitle ?
光标
: null}
+
+
+ ); +} + function getCanvasBackgroundPreviewCss( backgroundColor: string, pattern: CanvasBackgroundPattern, diff --git a/src/components/TopToolbar.tsx b/src/components/TopToolbar.tsx index a9db381..a7cf7ff 100644 --- a/src/components/TopToolbar.tsx +++ b/src/components/TopToolbar.tsx @@ -1,5 +1,7 @@ import { useRef } from 'react'; import type { ChangeEvent, ReactNode } from 'react'; +import { formatShortcutHint, TOOL_SHORTCUT_ACTIONS } from '../shortcuts'; +import type { ShortcutMap } from '../shortcuts'; import type { ToolType } from '../whiteboard/types'; type TopToolbarProps = { @@ -10,6 +12,7 @@ type TopToolbarProps = { canRedo: boolean; onUndo: () => void; onRedo: () => void; + shortcutSettings: ShortcutMap; }; type ToolbarItem = { @@ -51,6 +54,7 @@ function TopToolbar({ canRedo, onUndo, onRedo, + shortcutSettings, }: TopToolbarProps) { const imageInputRef = useRef(null); const actionItems: ToolbarActionItem[] = [ @@ -90,6 +94,7 @@ function TopToolbar({ > {item.label} + ); })} @@ -107,6 +112,7 @@ function TopToolbar({ > {item.label} + ))}
@@ -122,6 +128,20 @@ function TopToolbar({ ); } +function ShortcutHint({ binding }: { binding: string | undefined }) { + const hint = formatShortcutHint(binding); + if (!hint) { + return null; + } + + return {hint}; +} + +function getToolShortcutBinding(tool: ToolType, shortcutSettings: ShortcutMap) { + const action = TOOL_SHORTCUT_ACTIONS[tool as keyof typeof TOOL_SHORTCUT_ACTIONS]; + return action ? shortcutSettings[action] : undefined; +} + function ToolbarIcon({ type }: { type: ToolType | ToolbarActionItem['key'] }) { const icon = getToolbarIcon(type); return ( diff --git a/src/components/WhiteboardPage.tsx b/src/components/WhiteboardPage.tsx index 3203055..ec6e3e4 100644 --- a/src/components/WhiteboardPage.tsx +++ b/src/components/WhiteboardPage.tsx @@ -9,6 +9,8 @@ import type { CameraSettings, RecordingVisualSettings } from '../cameraTypes'; import { drawCanvasBackgroundPattern, getCanvasBackgroundCss, isDarkCanvasBackground, normalizeCanvasBackgroundColor } from '../canvasBackground'; import { DEFAULT_FRAME_BACKGROUND_COLOR, type FrameBackgroundPreset } from '../frameBackgrounds'; import { getRecordingCompositionLayout } from '../recordingLayout'; +import { matchShortcut, PALETTE_SHORTCUT_COLORS } from '../shortcuts'; +import type { ShortcutMap } from '../shortcuts'; import type { BoardElement, BoardPoint, @@ -69,6 +71,8 @@ type WhiteboardPageProps = { microphoneStream: MediaStream | null; recordingBackground: FrameBackgroundPreset | null; recordingVisualSettings: RecordingVisualSettings; + shortcutSettings: ShortcutMap; + isSettingsOpen: boolean; }; type ElementScopeType = 'slide' | 'freeboard'; @@ -166,6 +170,8 @@ function WhiteboardPage({ microphoneStream, recordingBackground, recordingVisualSettings, + shortcutSettings, + isSettingsOpen, }: WhiteboardPageProps) { const initialSlideRef = useRef(null); if (!initialSlideRef.current) { @@ -559,67 +565,158 @@ function WhiteboardPage({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const isTypingTarget = - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target?.isContentEditable; + if (isTypingEventTarget(event.target)) { + return; + } - if (isTypingTarget) { + if (matchShortcut(event, shortcutSettings.undo)) { + event.preventDefault(); + undo(); return; } - const key = event.key.toLowerCase(); - const hasModifier = event.metaKey || event.ctrlKey; + if (matchShortcut(event, shortcutSettings.redo)) { + event.preventDefault(); + redo(); + return; + } - if (hasModifier && key === 'z') { + if (matchShortcut(event, shortcutSettings.deleteSelection)) { event.preventDefault(); - if (event.shiftKey) { - redo(); - } else { - undo(); + if (activeTool === 'select' && selectedIds.length > 0) { + const nextElements = elements.filter((element) => !selectedIds.includes(element.id)); + onCommitElementsChange(elements, nextElements); + setSelectedIds([]); + setTextEditor((current) => (current && selectedIds.includes(current.elementId) ? null : current)); } return; } - if (event.ctrlKey && key === 'y') { + if (matchShortcut(event, shortcutSettings.copy)) { event.preventDefault(); - redo(); + if (selectedIds.length > 0) { + setClipboard(cloneElements(elements.filter((element) => selectedIds.includes(element.id)))); + setPasteCount(0); + } return; } - if ((event.key === 'Delete' || event.key === 'Backspace') && selectedIds.length > 0) { + if (matchShortcut(event, shortcutSettings.paste)) { event.preventDefault(); - const nextElements = elements.filter((element) => !selectedIds.includes(element.id)); - onCommitElementsChange(elements, nextElements); - setSelectedIds([]); - setTextEditor((current) => (current && selectedIds.includes(current.elementId) ? null : current)); + if (clipboard.length > 0) { + const offsetStep = 24 * (pasteCount + 1); + const pastedElements = duplicateElements(clipboard, generateElementId).map((element) => + offsetElement(element, offsetStep, offsetStep) + ); + const nextElements = [...elements, ...pastedElements]; + onCommitElementsChange(elements, nextElements); + setSelectedIds(pastedElements.map((element) => element.id)); + setPasteCount((current) => current + 1); + } return; } - if (hasModifier && key === 'c' && selectedIds.length > 0) { + if (matchShortcut(event, shortcutSettings.duplicate)) { event.preventDefault(); - setClipboard(cloneElements(elements.filter((element) => selectedIds.includes(element.id)))); - setPasteCount(0); + const duplicatedElements = duplicateElements( + elements.filter((element) => selectedIds.includes(element.id)), + generateElementId + ).map((element) => offsetElement(element, 24, 24)); + if (duplicatedElements.length > 0) { + onCommitElementsChange(elements, [...elements, ...duplicatedElements]); + setSelectedIds(duplicatedElements.map((element) => element.id)); + setPasteCount((current) => current + 1); + } return; } - if (hasModifier && key === 'v' && clipboard.length > 0) { - event.preventDefault(); - const offsetStep = 24 * (pasteCount + 1); - const pastedElements = duplicateElements(clipboard, generateElementId).map((element) => - offsetElement(element, offsetStep, offsetStep) - ); - const nextElements = [...elements, ...pastedElements]; - onCommitElementsChange(elements, nextElements); - setSelectedIds(pastedElements.map((element) => element.id)); - setPasteCount((current) => current + 1); + if (recordingStatus === 'idle') { + const toolShortcuts: Array<{ shortcut: string; tool: ToolType }> = [ + { shortcut: shortcutSettings.toolSelect, tool: 'select' }, + { shortcut: shortcutSettings.toolHand, tool: 'hand' }, + { shortcut: shortcutSettings.toolEraser, tool: 'eraser' }, + { shortcut: shortcutSettings.toolDraw, tool: 'draw' }, + { shortcut: shortcutSettings.toolRectangle, tool: 'rectangle' }, + { shortcut: shortcutSettings.toolEllipse, tool: 'ellipse' }, + { shortcut: shortcutSettings.toolArrow, tool: 'arrow' }, + { shortcut: shortcutSettings.toolLine, tool: 'line' }, + { shortcut: shortcutSettings.toolText, tool: 'text' }, + { shortcut: shortcutSettings.toolImage, tool: 'image' }, + ]; + + for (const item of toolShortcuts) { + if (matchShortcut(event, item.shortcut)) { + event.preventDefault(); + handleToolChange(item.tool); + return; + } + } + + const paletteShortcutBindings = [ + shortcutSettings.palette1, + shortcutSettings.palette2, + shortcutSettings.palette3, + shortcutSettings.palette4, + shortcutSettings.palette5, + shortcutSettings.palette6, + shortcutSettings.palette7, + shortcutSettings.palette8, + shortcutSettings.palette9, + ]; + + const paletteIndex = paletteShortcutBindings.findIndex((binding) => matchShortcut(event, binding)); + if (paletteIndex >= 0) { + event.preventDefault(); + const paletteColor = PALETTE_SHORTCUT_COLORS[paletteIndex]; + if (!paletteColor) { + return; + } + + if (activeTool === 'select') { + const selectedColorIds = new Set( + elements.filter((element) => selectedIds.includes(element.id) && isColorEditableElement(element)).map((element) => element.id) + ); + if (selectedColorIds.size > 0) { + const nextElements = elements.map((element) => + selectedColorIds.has(element.id) && isColorEditableElement(element) + ? { + ...element, + color: paletteColor, + } + : element + ); + onCommitElementsChange(elements, nextElements); + } + return; + } + + if (activeTool === 'text') { + setTextDefaults((current) => ({ ...current, color: paletteColor })); + return; + } + + if (isShapeColorTool(activeTool)) { + setShapeDefaults((current) => ({ ...current, color: paletteColor })); + } + } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [clipboard, elements, onCommitElementsChange, pasteCount, redo, selectedIds, undo]); + }, [ + activeTool, + clipboard, + elements, + handleToolChange, + onCommitElementsChange, + pasteCount, + recordingStatus, + redo, + selectedIds, + shortcutSettings, + undo, + ]); @@ -1046,19 +1143,12 @@ function WhiteboardPage({ useEffect(() => { const handleZoomKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const isTypingTarget = - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target?.isContentEditable; - - if (isTypingTarget || !(event.ctrlKey || event.metaKey)) { + if (isTypingEventTarget(event.target)) { return; } - const isZoomOutKey = event.key === '-' || event.code === 'Minus' || event.code === 'NumpadSubtract'; - const isZoomInKey = event.key === '=' || event.key === '+' || event.code === 'Equal' || event.code === 'NumpadAdd'; - + const isZoomOutKey = matchShortcut(event, shortcutSettings.zoomOut); + const isZoomInKey = matchShortcut(event, shortcutSettings.zoomIn); if (!isZoomOutKey && !isZoomInKey) { return; } @@ -1078,7 +1168,7 @@ function WhiteboardPage({ window.addEventListener('keydown', handleZoomKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleZoomKeyDown, { capture: true }); - }, [recordingStatus, zoomIn, zoomOut]); + }, [recordingStatus, shortcutSettings.zoomIn, shortcutSettings.zoomOut, zoomIn, zoomOut]); const handleLayerAction = useCallback( @@ -1155,7 +1245,6 @@ function WhiteboardPage({ setSelectedIds([]); setTextEditor((current) => (current && selectedIds.includes(current.elementId) ? null : current)); }, [activeTool, elements, onCommitElementsChange, selectedIds]); - const handleInsertImage = async (file: File) => { const src = await readFileAsDataUrl(file); const dimensions = await readImageDimensions(src); @@ -1682,6 +1771,76 @@ function WhiteboardPage({ restoreNormalViewport(); }, [restoreNormalViewport]); + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || isSettingsOpen) { + return; + } + + if (isClearConfirmOpen) { + event.preventDefault(); + cancelClearBoard(); + return; + } + + if (isTeleprompterOpen) { + event.preventDefault(); + setIsTeleprompterOpen(false); + return; + } + + if (imageCrop) { + event.preventDefault(); + handleCancelImageCrop(); + return; + } + + if (recordingStatus === 'preparing') { + event.preventDefault(); + cancelRecordingPreparing(); + return; + } + + if (textEditor) { + const target = event.target; + if (target instanceof HTMLTextAreaElement && target.classList.contains('board-text-editor')) { + return; + } + + event.preventDefault(); + setTextEditor(null); + return; + } + + if (selectedIds.length > 0) { + event.preventDefault(); + setSelectedIds([]); + return; + } + + if (CREATION_TOOLS.has(activeTool)) { + event.preventDefault(); + handleToolChange('select'); + } + }; + + window.addEventListener('keydown', handleEscape, { capture: true }); + return () => window.removeEventListener('keydown', handleEscape, { capture: true }); + }, [ + activeTool, + cancelClearBoard, + cancelRecordingPreparing, + handleCancelImageCrop, + handleToolChange, + imageCrop, + isClearConfirmOpen, + isSettingsOpen, + isTeleprompterOpen, + recordingStatus, + selectedIds.length, + textEditor, + ]); + useEffect(() => { if (recordingStatus === 'idle' || !recordingTarget) { return; @@ -1722,28 +1881,30 @@ function WhiteboardPage({ useEffect(() => { const handleRecordingSlideKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const isTypingTarget = - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target?.isContentEditable; - - if (isTypingTarget || recordingStatus === 'idle' || recordingTarget?.mode !== 'slide') { + if (isTypingEventTarget(event.target) || recordingStatus === 'idle' || recordingTarget?.mode !== 'slide') { return; } - if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + const goPrev = matchShortcut(event, shortcutSettings.recordingPrevSlide); + const goNext = matchShortcut(event, shortcutSettings.recordingNextSlide); + if (!goPrev && !goNext) { return; } event.preventDefault(); event.stopPropagation(); - goToRecordingSlideOffset(event.key === 'ArrowLeft' ? -1 : 1); + goToRecordingSlideOffset(goPrev ? -1 : 1); }; window.addEventListener('keydown', handleRecordingSlideKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleRecordingSlideKeyDown, { capture: true }); - }, [goToRecordingSlideOffset, recordingStatus, recordingTarget]); + }, [ + goToRecordingSlideOffset, + recordingStatus, + recordingTarget, + shortcutSettings.recordingNextSlide, + shortcutSettings.recordingPrevSlide, + ]); const handleToolbarTextStyleChange = (patch: Partial) => { setTextDefaults((current) => ({ ...current, ...patch })); @@ -1969,6 +2130,86 @@ function WhiteboardPage({ setRecordingError('Recording could not be started in this browser.'); } }, [activeSlideId, elements, getCurrentRecordingFrame, microphoneStream, recordingTarget, resetRecordingTimer, restoreNormalViewport, stageSlides, startRecordingTimer, viewport]); + + useEffect(() => { + const handleRecordingControlKeyDown = (event: KeyboardEvent) => { + if (isTypingEventTarget(event.target) || isSettingsOpen) { + return; + } + + if (matchShortcut(event, shortcutSettings.openSettings) && recordingStatus === 'idle') { + event.preventDefault(); + onOpenSettings(); + return; + } + + if (matchShortcut(event, shortcutSettings.toggleTeleprompter)) { + event.preventDefault(); + setIsTeleprompterOpen((current) => !current); + return; + } + + if (matchShortcut(event, shortcutSettings.recordingEnterPreparing) && recordingStatus === 'idle') { + event.preventDefault(); + enterRecordingPreparing(); + return; + } + + if (matchShortcut(event, shortcutSettings.recordingCancelPreparing) && recordingStatus === 'preparing') { + event.preventDefault(); + cancelRecordingPreparing(); + return; + } + + if (matchShortcut(event, shortcutSettings.recordingStart) && recordingStatus === 'preparing') { + event.preventDefault(); + startRecording(); + return; + } + + if (matchShortcut(event, shortcutSettings.recordingPause) && recordingStatus === 'recording') { + event.preventDefault(); + pauseRecording(); + return; + } + + if (matchShortcut(event, shortcutSettings.recordingResume) && recordingStatus === 'paused') { + event.preventDefault(); + resumeRecording(); + return; + } + + if ( + matchShortcut(event, shortcutSettings.recordingStop) && + (recordingStatus === 'recording' || recordingStatus === 'paused') + ) { + event.preventDefault(); + stopRecording(); + } + }; + + window.addEventListener('keydown', handleRecordingControlKeyDown); + return () => window.removeEventListener('keydown', handleRecordingControlKeyDown); + }, [ + cancelRecordingPreparing, + enterRecordingPreparing, + isSettingsOpen, + onOpenSettings, + pauseRecording, + recordingStatus, + resumeRecording, + shortcutSettings.openSettings, + shortcutSettings.recordingCancelPreparing, + shortcutSettings.recordingEnterPreparing, + shortcutSettings.recordingPause, + shortcutSettings.recordingResume, + shortcutSettings.recordingStart, + shortcutSettings.recordingStop, + shortcutSettings.toggleTeleprompter, + startRecording, + stopRecording, + ]); + const handleToolbarColorChange = ( patch: Partial, options: { commit?: boolean; target?: 'selection' | 'tool' } = {} @@ -2223,6 +2464,7 @@ function WhiteboardPage({ canRedo={history.future.length > 0} onUndo={undo} onRedo={redo} + shortcutSettings={shortcutSettings} />
setIsTeleprompterOpen((current) => !current)} recordingStatus={recordingStatus} recordingElapsedLabel={recordingElapsedLabel} + shortcutSettings={shortcutSettings} /> 0} onRotateSelection={handleRotateSelection} onFlipSelection={handleFlipSelection} + shortcutSettings={shortcutSettings} />
{isTeleprompterOpen ? ( @@ -4569,6 +4813,14 @@ async function readImageDimensions(src: string) { }); } +function isTypingEventTarget(target: EventTarget | null) { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ); +} + function isColorTool(tool: ToolType) { return tool === 'text' || isShapeColorTool(tool); } diff --git a/src/shortcuts.ts b/src/shortcuts.ts new file mode 100644 index 0000000..083d542 --- /dev/null +++ b/src/shortcuts.ts @@ -0,0 +1,418 @@ +export type ShortcutAction = + | 'undo' + | 'redo' + | 'copy' + | 'paste' + | 'duplicate' + | 'deleteSelection' + | 'toolSelect' + | 'toolHand' + | 'toolEraser' + | 'toolDraw' + | 'toolRectangle' + | 'toolEllipse' + | 'toolArrow' + | 'toolLine' + | 'toolText' + | 'toolImage' + | 'zoomIn' + | 'zoomOut' + | 'openSettings' + | 'toggleTeleprompter' + | 'recordingEnterPreparing' + | 'recordingCancelPreparing' + | 'recordingStart' + | 'recordingPause' + | 'recordingResume' + | 'recordingStop' + | 'recordingPrevSlide' + | 'recordingNextSlide' + | 'palette1' + | 'palette2' + | 'palette3' + | 'palette4' + | 'palette5' + | 'palette6' + | 'palette7' + | 'palette8' + | 'palette9'; + +export type ShortcutMap = Record; + +type ShortcutRule = { + mod: boolean; + ctrl: boolean; + meta: boolean; + shift: boolean; + alt: boolean; + key: string; +}; + +const SHORTCUT_STORAGE_KEY = 'canvascast.shortcuts.v1'; + +export const DEFAULT_SHORTCUTS: ShortcutMap = { + undo: 'Mod+Z', + redo: 'Mod+Y', + copy: 'Mod+C', + paste: 'Mod+V', + duplicate: 'Mod+D', + deleteSelection: 'Delete|Backspace', + toolSelect: 'V', + toolHand: 'H', + toolEraser: 'E', + toolDraw: 'P', + toolRectangle: 'R', + toolEllipse: 'O', + toolArrow: 'A', + toolLine: 'L', + toolText: 'T', + toolImage: 'I', + zoomIn: 'Mod+=', + zoomOut: 'Mod+-', + openSettings: '', + toggleTeleprompter: '', + recordingEnterPreparing: '', + recordingCancelPreparing: '', + recordingStart: '', + recordingPause: '', + recordingResume: '', + recordingStop: '', + recordingPrevSlide: 'ArrowLeft', + recordingNextSlide: 'ArrowRight', + palette1: '1', + palette2: '2', + palette3: '3', + palette4: '4', + palette5: '5', + palette6: '6', + palette7: '7', + palette8: '8', + palette9: '9', +}; + +export const PALETTE_SHORTCUT_COLORS: string[] = [ + '#111827', + '#2563eb', + '#dc2626', + '#059669', + '#7c3aed', + '#ea580c', + '#6b7280', + '#ffffff', + '#facc15', +]; + +export type ShortcutSection = { + id: string; + label: string; + actions: Array<{ action: ShortcutAction; label: string }>; +}; + +export const SHORTCUT_SECTIONS: ShortcutSection[] = [ + { + id: 'edit', + label: '编辑', + actions: [ + { action: 'undo', label: '撤销' }, + { action: 'redo', label: '重做' }, + { action: 'copy', label: '复制选中对象' }, + { action: 'paste', label: '粘贴选中对象' }, + { action: 'duplicate', label: '复制并偏移选中对象' }, + { action: 'deleteSelection', label: '删除选中对象' }, + ], + }, + { + id: 'tools', + label: '工具', + actions: [ + { action: 'toolSelect', label: '选择工具' }, + { action: 'toolHand', label: '平移工具' }, + { action: 'toolEraser', label: '橡皮工具' }, + { action: 'toolDraw', label: '画笔工具' }, + { action: 'toolRectangle', label: '矩形工具' }, + { action: 'toolEllipse', label: '圆形工具' }, + { action: 'toolArrow', label: '箭头工具' }, + { action: 'toolLine', label: '直线工具' }, + { action: 'toolText', label: '文本工具' }, + { action: 'toolImage', label: '插图工具' }, + ], + }, + { + id: 'palette', + label: '调色板(1-9)', + actions: [ + { action: 'palette1', label: '调色板颜色 1' }, + { action: 'palette2', label: '调色板颜色 2' }, + { action: 'palette3', label: '调色板颜色 3' }, + { action: 'palette4', label: '调色板颜色 4' }, + { action: 'palette5', label: '调色板颜色 5' }, + { action: 'palette6', label: '调色板颜色 6' }, + { action: 'palette7', label: '调色板颜色 7' }, + { action: 'palette8', label: '调色板颜色 8' }, + { action: 'palette9', label: '调色板颜色 9' }, + ], + }, + { + id: 'view', + label: '视图', + actions: [ + { action: 'zoomIn', label: '放大' }, + { action: 'zoomOut', label: '缩小' }, + ], + }, + { + id: 'recording', + label: '录制', + actions: [ + { action: 'openSettings', label: '打开录制设置' }, + { action: 'toggleTeleprompter', label: '开关提词器' }, + { action: 'recordingEnterPreparing', label: '进入录制准备' }, + { action: 'recordingCancelPreparing', label: '取消录制准备' }, + { action: 'recordingStart', label: '开始录制' }, + { action: 'recordingPause', label: '暂停录制' }, + { action: 'recordingResume', label: '继续录制' }, + { action: 'recordingStop', label: '停止录制' }, + { action: 'recordingPrevSlide', label: '录制时上一张幻灯片' }, + { action: 'recordingNextSlide', label: '录制时下一张幻灯片' }, + ], + }, +]; + +export const TOOL_SHORTCUT_ACTIONS = { + select: 'toolSelect', + hand: 'toolHand', + eraser: 'toolEraser', + draw: 'toolDraw', + rectangle: 'toolRectangle', + ellipse: 'toolEllipse', + arrow: 'toolArrow', + line: 'toolLine', + text: 'toolText', + image: 'toolImage', +} as const satisfies Partial>; + +export function formatShortcutHint(binding: string | undefined) { + if (!binding) { + return ''; + } + + const primary = (binding.split('|')[0] ?? '').trim(); + if (!primary) { + return ''; + } + + const tokens = primary.split('+').map((token) => token.trim()).filter(Boolean); + const rendered = tokens.map((token) => formatShortcutTokenForDisplay(token)); + const isApple = isApplePlatform(); + + if (isApple && rendered.length === 2 && rendered[0] === '⌘' && rendered[1].length <= 2) { + return `[${rendered[0]}${rendered[1]}]`; + } + + return `[${rendered.join('+')}]`; +} + +function formatShortcutTokenForDisplay(token: string) { + if (token === 'Mod') { + return isApplePlatform() ? '⌘' : 'Ctrl'; + } + if (token === 'Shift') { + return 'Shift'; + } + if (token === 'Alt') { + return isApplePlatform() ? '⌥' : 'Alt'; + } + if (token === 'Delete') { + return 'Del'; + } + if (token === 'Backspace') { + return '⌫'; + } + if (token === 'ArrowLeft') { + return '←'; + } + if (token === 'ArrowRight') { + return '→'; + } + if (token.length === 1) { + return token.toUpperCase(); + } + + return token; +} + +function isApplePlatform() { + if (typeof navigator === 'undefined') { + return false; + } + + return /Mac|iPhone|iPad|iPod/.test(navigator.platform) || navigator.userAgent.includes('Mac'); +} + +const SHORTCUT_KEYS = Object.keys(DEFAULT_SHORTCUTS) as ShortcutAction[]; + +export function loadShortcutSettings(): ShortcutMap { + if (typeof window === 'undefined') { + return { ...DEFAULT_SHORTCUTS }; + } + + try { + const rawValue = window.localStorage.getItem(SHORTCUT_STORAGE_KEY); + if (!rawValue) { + return { ...DEFAULT_SHORTCUTS }; + } + + const parsed = JSON.parse(rawValue) as Partial>; + const merged: ShortcutMap = { ...DEFAULT_SHORTCUTS }; + for (const key of SHORTCUT_KEYS) { + const value = parsed[key]; + if (typeof value === 'string') { + merged[key] = value.trim(); + } + } + + return merged; + } catch { + return { ...DEFAULT_SHORTCUTS }; + } +} + +export function saveShortcutSettings(settings: ShortcutMap) { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(SHORTCUT_STORAGE_KEY, JSON.stringify(settings)); + } catch { + // Ignore storage errors and continue with in-memory state. + } +} + +export function matchShortcut(event: KeyboardEvent, binding: string | undefined) { + if (!binding || binding.trim().length === 0) { + return false; + } + + const rules = parseShortcutBinding(binding); + if (rules.length === 0) { + return false; + } + + return rules.some((rule) => isShortcutRuleMatch(event, rule)); +} + +function parseShortcutBinding(binding: string): ShortcutRule[] { + return binding + .split('|') + .map((part) => parseSingleShortcut(part)) + .filter((rule): rule is ShortcutRule => rule !== null); +} + +function parseSingleShortcut(binding: string): ShortcutRule | null { + const tokens = binding + .split('+') + .map((token) => token.trim()) + .filter(Boolean); + + if (tokens.length === 0) { + return null; + } + + let mod = false; + let ctrl = false; + let meta = false; + let shift = false; + let alt = false; + let key = ''; + + for (const token of tokens) { + const normalized = token.toLowerCase(); + if (normalized === 'mod') { + mod = true; + continue; + } + if (normalized === 'ctrl' || normalized === 'control') { + ctrl = true; + continue; + } + if (normalized === 'cmd' || normalized === 'command' || normalized === 'meta') { + meta = true; + continue; + } + if (normalized === 'shift') { + shift = true; + continue; + } + if (normalized === 'alt' || normalized === 'option') { + alt = true; + continue; + } + + key = normalizeShortcutToken(token); + } + + return key ? { mod, ctrl, meta, shift, alt, key } : null; +} + +function isShortcutRuleMatch(event: KeyboardEvent, rule: ShortcutRule) { + const hasMod = event.ctrlKey || event.metaKey; + if (rule.mod) { + if (!hasMod) { + return false; + } + } else { + if (rule.ctrl !== event.ctrlKey) { + return false; + } + if (rule.meta !== event.metaKey) { + return false; + } + } + if (rule.shift !== event.shiftKey) { + return false; + } + if (rule.alt !== event.altKey) { + return false; + } + + return doesKeyMatch(event.key, rule.key); +} + +function doesKeyMatch(eventKey: string, expectedKey: string) { + const normalizedEventKey = normalizeShortcutToken(eventKey); + if (normalizedEventKey === expectedKey) { + return true; + } + + if (expectedKey === '=' && (normalizedEventKey === '+' || normalizedEventKey === '=')) { + return true; + } + + if (expectedKey === '+' && (normalizedEventKey === '+' || normalizedEventKey === '=')) { + return true; + } + + if (expectedKey === '-' && (normalizedEventKey === '-' || normalizedEventKey === '_')) { + return true; + } + + return false; +} + +function normalizeShortcutToken(token: string) { + if (token.length === 1) { + return token.toLowerCase(); + } + + const normalized = token.toLowerCase(); + if (normalized === ' ') { + return 'space'; + } + + if (normalized === 'spacebar') { + return 'space'; + } + + return normalized; +} diff --git a/src/whiteboard.css b/src/whiteboard.css index 4137bd7..eb087c0 100644 --- a/src/whiteboard.css +++ b/src/whiteboard.css @@ -100,6 +100,14 @@ line-height: 1; } +.board-toolbar__hint { + font-size: 0.56rem; + font-weight: 600; + line-height: 1; + color: rgba(100, 116, 139, 0.82); + letter-spacing: 0.02em; +} + .board-toolbar__input { display: none; } @@ -372,6 +380,21 @@ gap: 7px; } +.board-properties-panel__palette-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.board-properties-panel__hint { + font-size: 0.52rem; + font-weight: 600; + line-height: 1; + color: rgba(100, 116, 139, 0.78); + letter-spacing: 0.02em; +} + .board-properties-panel__color-swatch { width: 30px; height: 30px; @@ -720,6 +743,14 @@ user-select: none; } +.floating-controls__hint { + font-size: 0.52rem; + font-weight: 600; + line-height: 1; + color: rgba(100, 116, 139, 0.78); + letter-spacing: 0.02em; +} + .floating-controls__button { min-width: 72px; padding: 14px 16px; @@ -728,6 +759,10 @@ border: 1px solid rgba(15, 23, 42, 0.08); font-weight: 700; color: #334155; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 3px; } .floating-controls__button--record {