Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -194,8 +201,9 @@ CanvasCast 是一个基于 React、TypeScript 和 Vite 构建的浏览器端白
4. 打开录制设置,调整画布比例、背景、画布颜色和画布样式。
5. 如有需要,打开提词器,提前准备讲解脚本。
6. 根据录制需要开启摄像头小窗,或只选择麦克风录制声音。
7. 点击录制按钮开始讲解。
8. 录制结束后导出视频文件。
7. 如有需要,可在 **设置 → 快捷键** 中自定义工具、编辑操作与录制相关快捷键。
8. 点击录制按钮开始讲解。
9. 录制结束后导出视频文件。

## 使用建议

Expand All @@ -222,6 +230,8 @@ CanvasCast 是一个基于 React、TypeScript 和 Vite 构建的浏览器端白
- 独立麦克风选择
- 提词器
- 浏览器端录制导出
- 可自定义快捷键(工具、编辑操作、调色板、缩放与录制)
- 分标签页的录制设置(画布、背景、摄像头、光标、快捷键)

## 技术栈

Expand Down Expand Up @@ -284,6 +294,10 @@ CanvasCast 支持多张幻灯片、缩略图、复制、删除、重命名和拖

内置提词器支持播放控制、滚动速度和透明度调整,适合需要脚本辅助的讲解和演示录制。

### 快捷键支持

支持自定义工具切换、撤销/重做、复制粘贴、调色板颜色、缩放以及录制相关操作的快捷键。常用编辑快捷键有默认值;录制相关快捷键默认不绑定,可在 **设置 → 快捷键** 中自行配置。已绑定的快捷键会在工具栏和录制控件上显示轻量提示,设置会保存在浏览器本地。按 **Esc** 可依次关闭已打开的面板或取消当前选中。

## 当前版本限制

- 当前主要面向电脑端浏览器。
Expand All @@ -294,7 +308,7 @@ CanvasCast 支持多张幻灯片、缩略图、复制、删除、重命名和拖

## 当前状态

CanvasCast 目前处于 MVP 阶段。核心白板编辑、幻灯片工作流、录制设置、背景图、摄像头小窗、麦克风选择、提词器和浏览器端录制功能已经完成
CanvasCast 目前处于 MVP 阶段。核心白板编辑、幻灯片工作流、录制设置、背景图、摄像头小窗、麦克风选择、提词器、可自定义快捷键和浏览器端录制功能已经完成

后续会继续优化导出能力、移动端适配、性能表现和更多录制体验。

Expand Down
83 changes: 82 additions & 1 deletion src/App.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
:root {
:root {
color: #1f2937;
background: #eef1f6;
font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
36 changes: 36 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -28,6 +30,7 @@ function CanvasCastApp() {
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
const [microphoneStream, setMicrophoneStream] = useState<MediaStream | null>(null);
const [mediaError, setMediaError] = useState<string | null>(null);
const [shortcutSettings, setShortcutSettings] = useState<ShortcutMap>(() => loadShortcutSettings());
const activeAspectItem = useMemo(
() => aspectRatioOptions.find((option) => option.key === activeAspect) ?? aspectRatioOptions[4],
[activeAspect]
Expand All @@ -44,6 +47,12 @@ function CanvasCastApp() {
const updateRecordingVisualSettings = useCallback((patch: Partial<RecordingVisualSettings>) => {
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) {
Expand Down Expand Up @@ -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 (
<div className="app-shell">
<WhiteboardPage
Expand All @@ -205,6 +236,8 @@ function CanvasCastApp() {
microphoneStream={microphoneStream}
recordingBackground={activeBackground}
recordingVisualSettings={recordingVisualSettings}
shortcutSettings={shortcutSettings}
isSettingsOpen={settingsOpen}
/>

{settingsOpen && (
Expand Down Expand Up @@ -237,6 +270,9 @@ function CanvasCastApp() {
cameraStream={cameraStream}
mediaError={mediaError}
onRefreshDevices={refreshDevices}
shortcutSettings={shortcutSettings}
onShortcutChange={updateShortcutSetting}
onShortcutReset={resetShortcutSettings}
onClose={() => setSettingsOpen(false)}
/>
</div>
Expand Down
6 changes: 4 additions & 2 deletions src/components/BackgroundSection.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
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({
options,
selectedBackgroundId,
onSelectBackground,
onRandomSelect,
showTitle = true,
}: BackgroundSectionProps) {
const hasBackgrounds = options.length > 0;

return (
<div className="section-block background-section">
<div className="section-title">{'\u80cc\u666f'}</div>
{showTitle ? <div className="section-title">{'\u80cc\u666f'}</div> : null}
<button
type="button"
className="background-random-button"
Expand Down
6 changes: 4 additions & 2 deletions src/components/CameraSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CameraSettings, MediaDeviceChoice } from '../cameraTypes';
import type { CameraSettings, MediaDeviceChoice } from '../cameraTypes';

type CameraSectionProps = {
settings: CameraSettings;
Expand All @@ -7,6 +7,7 @@ type CameraSectionProps = {
audioDevices: MediaDeviceChoice[];
mediaError: string | null;
onRefreshDevices: () => void;
showTitle?: boolean;
};

function CameraSection({
Expand All @@ -16,10 +17,11 @@ function CameraSection({
audioDevices,
mediaError,
onRefreshDevices,
showTitle = true,
}: CameraSectionProps) {
return (
<div className="section-block">
<div className="section-title">摄像头及麦克风</div>
{showTitle ? <div className="section-title">摄像头及麦克风</div> : null}

<div className="camera-settings-grid">
<label className="camera-setting-field">
Expand Down
Loading