Merge pull request #16 from dev-Flyblue/feat/i18n-zh-CN

feat(i18n): add Simplified Chinese (zh-CN) localization as well as spanish
This commit is contained in:
webadderall
2026-03-15 19:06:01 +11:00
committed by GitHub
38 changed files with 2448 additions and 1334 deletions
+85
View File
@@ -0,0 +1,85 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
| Command | Description |
|---------|-------------|
| `npm run dev` | Start Vite dev server + Electron |
| `npm run build` | Full production build (native helpers → TypeScript → Vite → Electron Builder) |
| `npm run build:win` | Windows build (includes WGC capture) |
| `npm run build:mac` | macOS build |
| `npm run build:linux` | Linux build |
| `npm run lint` | Biome lint check |
| `npm run lint:fix` | Biome auto-fix |
| `npm run format` | Biome format |
| `npm run test` | Run Vitest (single run) |
| `npm run test:watch` | Vitest in watch mode |
| `npm run i18n:check` | Validate i18n locale file structure against `en` |
Run a single test file: `npx vitest run src/lib/exporter/gifExporter.test.ts`
## Architecture
**Electron + React + Vite** desktop screen recorder and video editor.
### Process Model
- **Main process** (`electron/main.ts`): Window lifecycle, tray icon, native recording, file I/O, permissions, auto-cleanup
- **Preload** (`electron/preload.ts`): contextBridge API exposing ~40 IPC methods to renderer
- **IPC handlers** (`electron/ipc/handlers.ts`): All main↔renderer communication; recording pipelines, file operations, project persistence, shortcuts, cursor telemetry
- **Renderer** (`src/`): React SPA routed by `?windowType=` URL parameter (no router library)
### Three Window Types
1. **HUD Overlay** (`?windowType=hud-overlay`): 500×155 floating bar at screen bottom for recording controls. Transparent, always-on-top.
2. **Editor** (`?windowType=editor`): Main video editor window (1200×800, maximized). Houses timeline, playback, annotations, export.
3. **Source Selector** (`?windowType=source-selector`): 620×420 popup for picking capture sources.
`App.tsx` switches components based on the `windowType` query param.
### Recording Pipeline (Platform-Specific)
- **macOS**: ScreenCaptureKit via compiled Swift helpers (`scripts/build-native-helpers.mjs`)
- **Windows**: WGC (Windows.Graphics.Capture) via C++ CMake build (`scripts/build-wgc-capture.mjs`), fallback to FFmpeg
- **Linux**: Chromium `getDisplayMedia`
- Core recording logic: `src/hooks/useScreenRecorder.ts` (60 FPS target, adaptive bitrate)
### Export Engine (`src/lib/exporter/`)
GPU-accelerated pipeline: WebCodec streaming decode → PIXI.js frame rendering (zoom, crop, annotations, cursor) → MP4 mux (mp4box) or GIF (gif.js with Web Workers). Key files: `videoExporter.ts`, `gifExporter.ts`, `frameRenderer.ts`, `streamingDecoder.ts`, `muxer.ts`.
### State Management
No external state library. `VideoEditor.tsx` uses ~50 `useState` hooks. Undo/redo via `useRef` history stacks. Only two React Contexts: `I18nContext` and `ShortcutsContext`.
### i18n System
- Config: `src/i18n/config.ts` — languages: `en` (source), `es`
- Locale files: `src/i18n/locales/{lang}/{namespace}.json`
- 7 namespaces: `common`, `launch`, `editor`, `timeline`, `settings`, `dialogs`, `shortcuts`
- Implementation: `src/contexts/I18nContext.tsx` — compile-time JSON import, recursive key lookup, `{{var}}` interpolation
- Fallback chain: current language → English → provided fallback → raw key
- Run `npm run i18n:check` after any locale changes
- See `TRANSLATION_GUIDE.md` for contributor workflow
### UI Layer
- Base components: `src/components/ui/` — Radix UI primitives wrapped with shadcn/ui patterns
- Styling: Tailwind CSS with CSS variable theming, class-based dark mode
- Icons: lucide-react + react-icons
- Timeline: `dnd-timeline` library for drag-and-drop editing
- Path alias: `@/` → `src/`
### Project File Format
`.recordly` files (legacy `.openscreen` supported). Serialization in `src/components/video-editor/projectPersistence.ts`.
## Code Quality
- **Biome** is the primary linter/formatter (tab indent, LF, 100 char width)
- **Strict TypeScript**: `noExplicitAny: error`, no unused variables/params
- **Hook rules enforced**: `useHookAtTopLevel: error`
- **Import organization**: automatic via Biome
- Tests use Vitest + fast-check (property-based testing). Test files: `src/**/*.{test,spec}.{ts,tsx}`
+11 -9
View File
@@ -9,6 +9,7 @@ import { RxDragHandleDots2 } from "react-icons/rx";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useScopedT } from "../../contexts/I18nContext";
import { Button } from "../ui/button";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { ContentClamp } from "../ui/content-clamp";
@@ -25,6 +26,7 @@ import styles from "./LaunchWindow.module.css";
export function LaunchWindow() {
const { locale, setLocale } = useI18n();
const t = useScopedT('launch');
const LOCALE_LABELS: Record<string, string> = { en: "EN", es: "ES", "zh-CN": "中文" };
const {
@@ -224,7 +226,7 @@ export function LaunchWindow() {
size="icon"
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
title={systemAudioEnabled ? t('recording.disableSystemAudio') : t('recording.enableSystemAudio')}
className="text-white/80 hover:bg-transparent"
>
{systemAudioEnabled ? <MdVolumeUp size={16} className="text-[#2563EB]" /> : <MdVolumeOff size={16} className="text-white/35" />}
@@ -234,7 +236,7 @@ export function LaunchWindow() {
size="icon"
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
title={microphoneEnabled ? t('recording.disableMicrophone') : t('recording.enableMicrophone')}
className="text-white/80 hover:bg-transparent"
>
{microphoneEnabled ? <MdMic size={16} className="text-[#2563EB]" /> : <MdMicOff size={16} className="text-white/35" />}
@@ -258,7 +260,7 @@ export function LaunchWindow() {
) : (
<>
<BsRecordCircle size={14} className={hasSelectedSource ? "text-white/85" : "text-white/35"} />
<span className={hasSelectedSource ? "text-white/80" : "text-white/35"}>Record</span>
<span className={hasSelectedSource ? "text-white/80" : "text-white/35"}>{t('recording.record')}</span>
</>
)}
</Button>
@@ -268,10 +270,10 @@ export function LaunchWindow() {
size="sm"
onClick={chooseRecordingsDirectory}
disabled={recording}
title={recordingsDirectory ? `Recording folder: ${recordingsDirectory}` : "Choose recordings folder"}
title={recordingsDirectory ? t('recording.recordingFolder', undefined, { path: recordingsDirectory }) : t('recording.chooseRecordingsFolder')}
className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`}
>
<ContentClamp truncateLength={18}>{`Path: /${recordingsDirectoryName}/`}</ContentClamp>
<ContentClamp truncateLength={18}>{t('recording.folderPath', undefined, { name: recordingsDirectoryName })}</ContentClamp>
</Button>
<div className="ml-auto flex items-center gap-0.5">
@@ -281,7 +283,7 @@ export function LaunchWindow() {
size="icon"
onClick={openVideoFile}
disabled={recording}
title="Open video file"
title={t('recording.openVideoFile')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<MdVideoFile size={15} />
@@ -291,7 +293,7 @@ export function LaunchWindow() {
size="icon"
onClick={openProjectFile}
disabled={recording}
title="Open project"
title={t('recording.openProject')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FaFolderOpen size={14} />
@@ -330,7 +332,7 @@ export function LaunchWindow() {
variant="link"
size="icon"
onClick={sendHudOverlayHide}
title="Hide HUD"
title={t('recording.hideHud')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FiMinus size={16} />
@@ -339,7 +341,7 @@ export function LaunchWindow() {
variant="link"
size="icon"
onClick={sendHudOverlayClose}
title="Close App"
title={t('recording.closeApp')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FiX size={16} />
+9 -7
View File
@@ -3,6 +3,7 @@ import { Button } from "../ui/button";
import { MdCheck } from "react-icons/md";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Card } from "../ui/card";
import { useScopedT } from "../../contexts/I18nContext";
import styles from "./SourceSelector.module.css";
interface DesktopSource {
@@ -50,6 +51,7 @@ function parseSourceMetadata(source: ProcessedDesktopSource) {
}
export function SourceSelector() {
const t = useScopedT('launch');
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [activeTab, setActiveTab] = useState<'screens' | 'windows'>('screens');
@@ -118,7 +120,7 @@ export function SourceSelector() {
<div className={`h-full flex items-center justify-center ${styles.glassContainer}`} style={{ minHeight: '100vh' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-zinc-600 mx-auto mb-2" />
<p className="text-xs text-zinc-300">Loading sources...</p>
<p className="text-xs text-zinc-300">{t('sourceSelector.loadingSources')}</p>
</div>
</div>
);
@@ -130,10 +132,10 @@ export function SourceSelector() {
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'screens' | 'windows')}>
<TabsList className="grid grid-cols-2 mb-3 bg-zinc-900/40 rounded-full">
<TabsTrigger value="screens" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">
Screens ({screenSources.length})
{t('sourceSelector.screens')} ({screenSources.length})
</TabsTrigger>
<TabsTrigger value="windows" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">
Windows ({windowSources.length})
{t('sourceSelector.windows')} ({windowSources.length})
</TabsTrigger>
</TabsList>
<div className="h-72 flex flex-col justify-stretch">
@@ -171,7 +173,7 @@ export function SourceSelector() {
</div>
</TabsContent>
<TabsContent value="windows" className="h-full">
<p className="text-[10px] text-zinc-500 mb-1 px-1">Only visible (non-minimized) windows can be recorded.</p>
<p className="text-[10px] text-zinc-500 mb-1 px-1">{t('sourceSelector.windowsNote')}</p>
<div className={`grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative ${styles.sourceGridScroll}`}>
{windowSources.length === 0 && (
<div className="col-span-2 text-center text-xs text-zinc-500 py-8">No windows available</div>
@@ -202,7 +204,7 @@ export function SourceSelector() {
) : (
<div className="w-8 h-8 rounded-md bg-zinc-800 border border-zinc-700" />
)}
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">Window</div>
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">{t('sourceSelector.windowPlaceholder')}</div>
</div>
)}
{selectedSource?.id === source.id && (
@@ -233,8 +235,8 @@ export function SourceSelector() {
</div>
<div className="border-t border-zinc-800 p-2 w-full max-w-xl">
<div className="flex justify-center gap-2">
<Button variant="outline" onClick={() => window.close()} className="px-4 py-1 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 hover:bg-zinc-700">Cancel</Button>
<Button onClick={handleShare} disabled={!selectedSource} className="px-4 py-1 text-xs bg-[#2563EB] text-white hover:bg-[#2563EB]/80 disabled:opacity-50 disabled:bg-zinc-700">Share</Button>
<Button variant="outline" onClick={() => window.close()} className="px-4 py-1 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 hover:bg-zinc-700">{t('sourceSelector.cancel')}</Button>
<Button onClick={handleShare} disabled={!selectedSource} className="px-4 py-1 text-xs bg-[#2563EB] text-white hover:bg-[#2563EB]/80 disabled:opacity-50 disabled:bg-zinc-700">{t('sourceSelector.share')}</Button>
</div>
</div>
</div>
@@ -19,12 +19,14 @@ import {
isValidGoogleFontsUrl,
type CustomFont,
} from '@/lib/customFonts';
import { useScopedT } from '../../contexts/I18nContext';
interface AddCustomFontDialogProps {
onFontAdded?: (font: CustomFont) => void;
}
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const t = useScopedT('dialogs');
const [open, setOpen] = useState(false);
const [importUrl, setImportUrl] = useState('');
const [fontName, setFontName] = useState('');
@@ -45,17 +47,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const handleAdd = async () => {
// Validate inputs
if (!importUrl.trim()) {
toast.error('Please enter a Google Fonts import URL');
toast.error(t('addFont.enterUrl'));
return;
}
if (!isValidGoogleFontsUrl(importUrl)) {
toast.error('Please enter a valid Google Fonts URL');
toast.error(t('addFont.invalidUrl'));
return;
}
if (!fontName.trim()) {
toast.error('Please enter a font name');
toast.error(t('addFont.enterName'));
return;
}
@@ -65,7 +67,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error('Could not extract font family from URL');
toast.error(t('addFont.extractFailed'));
setLoading(false);
return;
}
@@ -86,7 +88,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
onFontAdded(newFont);
}
toast.success(`Font "${fontName}" added successfully`);
toast.success(t('addFont.addSuccess', undefined, { name: fontName }));
// Reset and close
setImportUrl('');
@@ -95,10 +97,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
} catch (error) {
console.error('Failed to add custom font:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to load font';
toast.error('Failed to add font', {
toast.error(t('addFont.addFailed'), {
description: errorMessage.includes('timeout')
? 'Font took too long to load. Please check the URL and try again.'
: 'The font could not be loaded. Please verify the Google Fonts URL is correct.',
? t('addFont.loadTimeout')
: t('addFont.loadFailed'),
});
} finally {
setLoading(false);
@@ -114,47 +116,47 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add Google Font
{t('addFont.title')}
</Button>
</DialogTrigger>
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<DialogHeader>
<DialogTitle>Add Google Font</DialogTitle>
<DialogTitle>{t('addFont.heading')}</DialogTitle>
<DialogDescription className="text-slate-400">
Add a custom font from Google Fonts to use in your annotations.
{t('addFont.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="import-url" className="text-slate-200">
Google Fonts Import URL
{t('addFont.urlLabel')}
</Label>
<Input
id="import-url"
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
placeholder={t('addFont.urlPlaceholder')}
value={importUrl}
onChange={(e) => handleImportUrlChange(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
{t('addFont.urlHelp')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="font-name" className="text-slate-200">
Display Name
{t('addFont.nameLabel')}
</Label>
<Input
id="font-name"
placeholder="My Custom Font"
placeholder={t('addFont.namePlaceholder')}
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
This is how the font will appear in the font selector
{t('addFont.nameHelp')}
</p>
</div>
@@ -164,14 +166,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
onClick={() => setOpen(false)}
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
>
Cancel
{t('addFont.cancel')}
</Button>
<Button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loading ? 'Adding...' : 'Add Font'}
{loading ? t('addFont.adding') : t('addFont.addFont')}
</Button>
</div>
</div>
@@ -179,4 +181,4 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
</Dialog>
);
}
@@ -1,4 +1,4 @@
import { useRef, useState, useEffect } from "react";
import { useRef, useState, useEffect, useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info } from "lucide-react";
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getCustomFonts, type CustomFont } from "@/lib/customFonts";
import { useScopedT } from "../../contexts/I18nContext";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
@@ -23,15 +24,15 @@ interface AnnotationSettingsPanelProps {
onDelete: () => void;
}
const FONT_FAMILIES = [
{ value: 'system-ui, -apple-system, sans-serif', label: 'Classic' },
{ value: 'Georgia, serif', label: 'Editor' },
{ value: 'Impact, Arial Black, sans-serif', label: 'Strong' },
{ value: 'Courier New, monospace', label: 'Typewriter' },
{ value: 'Brush Script MT, cursive', label: 'Deco' },
{ value: 'Arial, sans-serif', label: 'Simple' },
{ value: 'Verdana, sans-serif', label: 'Modern' },
{ value: 'Trebuchet MS, sans-serif', label: 'Clean' },
const FONT_FAMILY_VALUES = [
{ value: 'system-ui, -apple-system, sans-serif', labelKey: 'fontStyles.classic' },
{ value: 'Georgia, serif', labelKey: 'fontStyles.editor' },
{ value: 'Impact, Arial Black, sans-serif', labelKey: 'fontStyles.strong' },
{ value: 'Courier New, monospace', labelKey: 'fontStyles.typewriter' },
{ value: 'Brush Script MT, cursive', labelKey: 'fontStyles.deco' },
{ value: 'Arial, sans-serif', labelKey: 'fontStyles.simple' },
{ value: 'Verdana, sans-serif', labelKey: 'fontStyles.modern' },
{ value: 'Trebuchet MS, sans-serif', labelKey: 'fontStyles.clean' },
];
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
@@ -44,9 +45,15 @@ export function AnnotationSettingsPanel({
onFigureDataChange,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT('editor');
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
const fontFamilies = useMemo(() =>
FONT_FAMILY_VALUES.map((f) => ({ value: f.value, label: t(f.labelKey) })),
[t],
);
// Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
@@ -82,8 +89,8 @@ export function AnnotationSettingsPanel({
// Validate file type
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error('Invalid file type', {
description: 'Please upload a JPG, PNG, GIF, or WebP image file.',
toast.error(t('annotations.imageUploadError'), {
description: t('annotations.imageUploadErrorDescription'),
});
event.target.value = '';
return;
@@ -95,13 +102,13 @@ export function AnnotationSettingsPanel({
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success('Image uploaded successfully!');
toast.success(t('annotations.imageUploadSuccess'));
}
};
reader.onerror = () => {
toast.error('Failed to upload image', {
description: 'There was an error reading the file.',
toast.error(t('annotations.imageUploadFailed'), {
description: t('annotations.imageUploadFailedDescription'),
});
};
@@ -113,9 +120,9 @@ export function AnnotationSettingsPanel({
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
<span className="text-sm font-medium text-slate-200">{t('annotations.settings')}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#2563EB] bg-[#2563EB]/10 px-2 py-1 rounded-full">
Active
{t('annotations.active')}
</span>
</div>
@@ -124,28 +131,28 @@ export function AnnotationSettingsPanel({
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<TabsTrigger value="text" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<Type className="w-4 h-4" />
Text
{t('annotations.text')}
</TabsTrigger>
<TabsTrigger value="image" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<ImageIcon className="w-4 h-4" />
Image
{t('annotations.image')}
</TabsTrigger>
<TabsTrigger value="figure" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Arrow
{t('annotations.arrow')}
</TabsTrigger>
</TabsList>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">{t('annotations.textContent')}</label>
<textarea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder="Enter your text..."
placeholder={t('annotations.textPlaceholder')}
rows={5}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent resize-none"
/>
@@ -156,16 +163,16 @@ export function AnnotationSettingsPanel({
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Font Style</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">{t('annotations.fontStyle')}</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) => onStyleChange({ fontFamily: value })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Select style" />
<SelectValue placeholder={t('annotations.selectStyle')} />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
{FONT_FAMILIES.map((font) => (
{fontFamilies.map((font) => (
<SelectItem key={font.value} value={font.value} style={{ fontFamily: font.value }}>
{font.label}
</SelectItem>
@@ -190,13 +197,13 @@ export function AnnotationSettingsPanel({
</Select>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">{t('annotations.size')}</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Size" />
<SelectValue placeholder={t('annotations.size')} />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
{FONT_SIZES.map((size) => (
@@ -224,7 +231,7 @@ export function AnnotationSettingsPanel({
<ToggleGroup type="multiple" className="justify-start bg-white/5 p-1 rounded-lg border border-white/5">
<ToggleGroupItem
value="bold"
aria-label="Toggle bold"
aria-label={t('annotations.toggleBold')}
data-state={annotation.style.fontWeight === 'bold' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontWeight: annotation.style.fontWeight === 'bold' ? 'normal' : 'bold' })}
className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
@@ -233,7 +240,7 @@ export function AnnotationSettingsPanel({
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label="Toggle italic"
aria-label={t('annotations.toggleItalic')}
data-state={annotation.style.fontStyle === 'italic' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontStyle: annotation.style.fontStyle === 'italic' ? 'normal' : 'italic' })}
className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
@@ -242,7 +249,7 @@ export function AnnotationSettingsPanel({
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label="Toggle underline"
aria-label={t('annotations.toggleUnderline')}
data-state={annotation.style.textDecoration === 'underline' ? 'on' : 'off'}
onClick={() => onStyleChange({ textDecoration: annotation.style.textDecoration === 'underline' ? 'none' : 'underline' })}
className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
@@ -254,7 +261,7 @@ export function AnnotationSettingsPanel({
<ToggleGroup type="single" value={annotation.style.textAlign} className="justify-start bg-white/5 p-1 rounded-lg border border-white/5">
<ToggleGroupItem
value="left"
aria-label="Align left"
aria-label={t('annotations.alignLeft')}
onClick={() => onStyleChange({ textAlign: 'left' })}
className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -262,7 +269,7 @@ export function AnnotationSettingsPanel({
</ToggleGroupItem>
<ToggleGroupItem
value="center"
aria-label="Align center"
aria-label={t('annotations.alignCenter')}
onClick={() => onStyleChange({ textAlign: 'center' })}
className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -270,7 +277,7 @@ export function AnnotationSettingsPanel({
</ToggleGroupItem>
<ToggleGroupItem
value="right"
aria-label="Align right"
aria-label={t('annotations.alignRight')}
onClick={() => onStyleChange({ textAlign: 'right' })}
className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -282,7 +289,7 @@ export function AnnotationSettingsPanel({
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Color</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">{t('annotations.textColor')}</label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -314,7 +321,7 @@ export function AnnotationSettingsPanel({
</Popover>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Background</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">{t('annotations.background')}</label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -331,7 +338,7 @@ export function AnnotationSettingsPanel({
/>
</div>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.backgroundColor === 'transparent' ? 'None' : 'Color'}
{annotation.style.backgroundColor === 'transparent' ? t('annotations.none') : 'Color'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
@@ -355,7 +362,7 @@ export function AnnotationSettingsPanel({
onStyleChange({ backgroundColor: 'transparent' });
}}
>
Clear Background
{t('annotations.clearBackground')}
</Button>
</PopoverContent>
</Popover>
@@ -381,7 +388,7 @@ export function AnnotationSettingsPanel({
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all py-8"
>
<Upload className="w-5 h-5" />
Upload Image
{t('annotations.uploadImage')}
</Button>
{annotation.content && annotation.content.startsWith('data:image') && (
@@ -395,13 +402,13 @@ export function AnnotationSettingsPanel({
)}
<p className="text-xs text-slate-500 text-center leading-relaxed">
Supported formats: JPG, PNG, GIF, WebP
{t('annotations.supportedFormats')}
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-3 block">Arrow Direction</label>
<label className="text-xs font-medium text-slate-200 mb-3 block">{t('annotations.arrowDirection')}</label>
<div className="grid grid-cols-4 gap-2">
{([
'up', 'down', 'left', 'right',
@@ -437,7 +444,7 @@ export function AnnotationSettingsPanel({
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
{t('annotations.strokeWidth', undefined, { width: annotation.figureData?.strokeWidth || 4 })}
</label>
<Slider
value={[annotation.figureData?.strokeWidth || 4]}
@@ -456,7 +463,7 @@ export function AnnotationSettingsPanel({
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">{t('annotations.arrowColor')}</label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -501,18 +508,18 @@ export function AnnotationSettingsPanel({
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
Delete Annotation
{t('annotations.deleteAnnotation')}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">Shortcuts & Tips</span>
<span className="text-xs font-medium">{t('annotations.shortcutsAndTips')}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>Move playhead to overlapping annotation section and select an item.</li>
<li>Use <kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to cycle through overlapping items.</li>
<li>Use <kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Shift+Tab</kbd> to cycle backwards.</li>
<li>{t('annotations.tipSelectAnnotation')}</li>
<li>{t('annotations.tipCycleForward')}</li>
<li>{t('annotations.tipCycleBackward')}</li>
</ul>
</div>
</div>
+19 -17
View File
@@ -3,6 +3,7 @@ import { X, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ExportProgress } from '@/lib/exporter';
import { toast } from 'sonner'; // Add this import
import { useScopedT } from "../../contexts/I18nContext";
interface ExportDialogProps {
@@ -30,6 +31,7 @@ export function ExportDialog({
exportFormat = 'mp4',
exportedFilePath, // Add this line
}: ExportDialogProps) {
const t = useScopedT('dialogs');
const [showSuccess, setShowSuccess] = useState(false);
// Reset showSuccess when a new export starts or dialog reopens
@@ -68,21 +70,21 @@ export function ExportDialog({
// Get status message based on phase
const getStatusMessage = () => {
if (error) return 'Please try again';
if (error) return t('export.pleaseTryAgain');
if (isCompiling || isFinalizing) {
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
return t('export.compilingGifProgress', undefined, { progress: renderProgress });
}
return 'Compiling GIF... This may take a while';
return t('export.compilingGifWait');
}
return 'This may take a moment...';
return t('export.takeMoment');
};
// Get title based on phase
const getTitle = () => {
if (error) return 'Export Failed';
if (isCompiling || isFinalizing) return 'Compiling GIF';
return `Exporting ${formatLabel}`;
if (error) return t('export.exportFailed');
if (isCompiling || isFinalizing) return t('export.compilingGifTitle');
return t('export.exportingFormat', undefined, { format: formatLabel });
};
const handleClickShowInFolder = async () => {
@@ -117,15 +119,15 @@ export function ExportDialog({
<Download className="w-6 h-6 text-[#2563EB]" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
<span className="text-sm text-slate-400">Your {formatLabel.toLowerCase()} is ready</span>
<span className="text-xl font-bold text-slate-200 block">{t('export.exportComplete')}</span>
<span className="text-sm text-slate-400">{t('export.formatReady', undefined, { format: formatLabel.toLowerCase() })}</span>
{exportedFilePath && (
<Button
variant="secondary"
onClick={handleClickShowInFolder}
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
>
Show in Folder
{t('export.showInFolder')}
</Button>
)}
{exportedFilePath && (
@@ -192,7 +194,7 @@ export function ExportDialog({
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
<span>{isCompiling || isFinalizing ? 'Compiling' : 'Rendering Frames'}</span>
<span>{isCompiling || isFinalizing ? t('export.compiling') : t('export.renderingFrames')}</span>
<span className="font-mono text-slate-200">
{isCompiling || isFinalizing ? (
renderProgress !== undefined && renderProgress > 0 ? (
@@ -200,7 +202,7 @@ export function ExportDialog({
) : (
<span className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Processing...
{t('export.processing')}
</span>
)
) : (
@@ -244,14 +246,14 @@ export function ExportDialog({
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
{isCompiling || isFinalizing ? 'Status' : 'Format'}
{isCompiling || isFinalizing ? t('export.status') : t('export.format')}
</div>
<div className="text-slate-200 font-medium text-sm">
{isCompiling || isFinalizing ? 'Compiling...' : formatLabel}
{isCompiling || isFinalizing ? t('export.compilingStatus') : formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Frames</div>
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">{t('export.frames')}</div>
<div className="text-slate-200 font-medium text-sm">
{progress.currentFrame} / {progress.totalFrames}
</div>
@@ -265,7 +267,7 @@ export function ExportDialog({
variant="destructive"
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
>
Cancel Export
{t('export.cancelExport')}
</Button>
</div>
)}
@@ -275,7 +277,7 @@ export function ExportDialog({
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">
{formatLabel} saved successfully!
{t('export.savedSuccess', undefined, { format: formatLabel })}
</p>
</div>
)}
+70 -68
View File
@@ -1,78 +1,80 @@
import { Film, Image } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ExportFormat } from '@/lib/exporter/types';
import { Film, Image } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import type { ExportFormat } from "@/lib/exporter/types";
import { cn } from "@/lib/utils";
interface FormatSelectorProps {
selectedFormat: ExportFormat;
onFormatChange: (format: ExportFormat) => void;
disabled?: boolean;
selectedFormat: ExportFormat;
onFormatChange: (format: ExportFormat) => void;
disabled?: boolean;
}
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const formatOptions: FormatOption[] = [
{
value: 'mp4',
label: 'MP4 Video',
description: 'High quality video file',
icon: <Film className="w-5 h-5" />,
},
{
value: 'gif',
label: 'GIF Animation',
description: 'Animated image for sharing',
icon: <Image className="w-5 h-5" />,
},
];
export function FormatSelector({
selectedFormat,
onFormatChange,
disabled = false,
selectedFormat,
onFormatChange,
disabled = false,
}: FormatSelectorProps) {
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onFormatChange(option.value)}
className={cn(
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-[#2563EB]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
isSelected
? 'bg-[#2563EB]/10 border-[#2563EB]/50 text-white'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
isSelected ? 'bg-[#2563EB]/20 text-[#2563EB]' : 'bg-white/5'
)}
>
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#2563EB]" />
)}
</button>
);
})}
</div>
);
}
const t = useScopedT("editor");
const formatOptions: FormatOption[] = [
{
value: "mp4",
label: t("format.mp4Video"),
description: t("format.mp4Description"),
icon: <Film className="w-5 h-5" />,
},
{
value: "gif",
label: t("format.gifAnimation"),
description: t("format.gifDescription"),
icon: <Image className="w-5 h-5" />,
},
];
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onFormatChange(option.value)}
className={cn(
"relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-[#2563EB]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]",
isSelected
? "bg-[#2563EB]/10 border-[#2563EB]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200",
disabled && "opacity-50 cursor-not-allowed",
)}
>
<div
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
isSelected ? "bg-[#2563EB]/20 text-[#2563EB]" : "bg-white/5",
)}
>
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#2563EB]" />
)}
</button>
);
})}
</div>
);
}
+107 -100
View File
@@ -1,111 +1,118 @@
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, type GifFrameRate, type GifSizePreset } from '@/lib/exporter/types';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useScopedT } from "@/contexts/I18nContext";
import {
GIF_FRAME_RATES,
GIF_SIZE_PRESETS,
type GifFrameRate,
type GifSizePreset,
} from "@/lib/exporter/types";
interface GifOptionsPanelProps {
frameRate: GifFrameRate;
onFrameRateChange: (rate: GifFrameRate) => void;
loop: boolean;
onLoopChange: (loop: boolean) => void;
sizePreset: GifSizePreset;
onSizePresetChange: (preset: GifSizePreset) => void;
outputDimensions: { width: number; height: number };
disabled?: boolean;
frameRate: GifFrameRate;
onFrameRateChange: (rate: GifFrameRate) => void;
loop: boolean;
onLoopChange: (loop: boolean) => void;
sizePreset: GifSizePreset;
onSizePresetChange: (preset: GifSizePreset) => void;
outputDimensions: { width: number; height: number };
disabled?: boolean;
}
export function GifOptionsPanel({
frameRate,
onFrameRateChange,
loop,
onLoopChange,
sizePreset,
onSizePresetChange,
outputDimensions,
disabled = false,
frameRate,
onFrameRateChange,
loop,
onLoopChange,
sizePreset,
onSizePresetChange,
outputDimensions,
disabled = false,
}: GifOptionsPanelProps) {
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
value: key as GifSizePreset,
label: value.label,
}));
const t = useScopedT("editor");
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
value: key as GifSizePreset,
label: value.label,
}));
return (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
{/* Frame Rate */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Frame Rate
</label>
<Select
value={String(frameRate)}
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{GIF_FRAME_RATES.map((rate) => (
<SelectItem
key={rate.value}
value={String(rate.value)}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{rate.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
return (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
{/* Frame Rate */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
{t("gifOptions.frameRate")}
</label>
<Select
value={String(frameRate)}
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{GIF_FRAME_RATES.map((rate) => (
<SelectItem
key={rate.value}
value={String(rate.value)}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{rate.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Size Preset */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Output Size
</label>
<Select
value={sizePreset}
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{sizePresetOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-slate-500">
Output: {outputDimensions.width} × {outputDimensions.height}px
</div>
</div>
{/* Size Preset */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
{t("gifOptions.outputSize")}
</label>
<Select
value={sizePreset}
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{sizePresetOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-slate-500">
{t("gifOptions.outputDimensions", undefined, {
width: String(outputDimensions.width),
height: String(outputDimensions.height),
})}
</div>
</div>
{/* Loop Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
<p className="text-xs text-slate-500">GIF will play continuously</p>
</div>
<Switch
checked={loop}
onCheckedChange={onLoopChange}
disabled={disabled}
/>
</div>
</div>
);
{/* Loop Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<label className="text-sm font-medium text-slate-200">
{t("gifOptions.loopAnimation")}
</label>
<p className="text-xs text-slate-500">{t("gifOptions.loopDescription")}</p>
</div>
<Switch checked={loop} onCheckedChange={onLoopChange} disabled={disabled} />
</div>
</div>
);
}
@@ -1,66 +1,78 @@
import { HelpCircle, Settings2 } from "lucide-react";
import { useState, useEffect } from "react";
import { formatShortcut } from "@/utils/platformUtils";
import { useEffect, useState } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
import { formatShortcut } from "@/utils/platformUtils";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
const { shortcuts, isMac, openConfig } = useShortcuts();
const t = useScopedT("editor");
const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' });
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
zoom: "Ctrl + Scroll",
});
useEffect(() => {
Promise.all([
formatShortcut(['shift', 'mod', 'Scroll']),
formatShortcut(['mod', 'Scroll']),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
useEffect(() => {
Promise.all([
formatShortcut(["shift", "mod", "Scroll"]),
formatShortcut(["mod", "Scroll"]),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#2563EB] transition-colors cursor-help" />
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#2563EB] transition-colors cursor-help" />
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
<button
type="button"
onClick={openConfig}
title="Customize shortcuts"
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#2563EB] transition-colors"
>
<Settings2 className="w-3 h-3" />
Customize
</button>
</div>
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">
{t("keyboardShortcuts.title")}
</span>
<button
type="button"
onClick={openConfig}
title={t("keyboardShortcuts.customizeTooltip")}
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#2563EB] transition-colors"
>
<Settings2 className="w-3 h-3" />
{t("keyboardShortcuts.customize")}
</button>
</div>
<div className="space-y-1.5 text-[10px]">
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
<div className="space-y-1.5 text-[10px]">
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
<div className="pt-1 border-t border-white/5 mt-1">
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">{scrollLabels.pan}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">{scrollLabels.zoom}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Cycle Annotations</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">Tab</kbd>
</div>
</div>
</div>
</div>
</div>
);
<div className="pt-1 border-t border-white/5 mt-1">
<div className="flex items-center justify-between">
<span className="text-slate-400">{t("keyboardShortcuts.panTimeline")}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">
{scrollLabels.pan}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">{t("keyboardShortcuts.zoomTimeline")}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">
{scrollLabels.zoom}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">{t("keyboardShortcuts.cycleAnnotations")}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#2563EB] font-mono">
{t("keyboardShortcuts.tab")}
</kbd>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,93 +1,91 @@
import { Button } from "../ui/button";
import { Play, Pause } from "lucide-react";
import { Pause, Play } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
interface PlaybackControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
onTogglePlayPause: () => void;
onSeek: (time: number) => void;
isPlaying: boolean;
currentTime: number;
duration: number;
onTogglePlayPause: () => void;
onSeek: (time: number) => void;
}
export default function PlaybackControls({
isPlaying,
currentTime,
duration,
onTogglePlayPause,
onSeek,
isPlaying,
currentTime,
duration,
onTogglePlayPause,
onSeek,
}: PlaybackControlsProps) {
function formatTime(seconds: number) {
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
const t = useScopedT("editor");
function formatTime(seconds: number) {
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
onSeek(parseFloat(e.target.value));
}
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
onSeek(parseFloat(e.target.value));
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
<Button
onClick={onTogglePlayPause}
size="icon"
className={cn(
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
isPlaying
? "bg-white/10 text-white hover:bg-white/20"
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]"
)}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause className="w-3.5 h-3.5 fill-current" />
) : (
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
)}
</Button>
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
{formatTime(currentTime)}
</span>
<div className="flex-1 relative h-6 flex items-center group">
{/* Custom Track Background */}
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-[#2563EB] rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
{/* Interactive Input */}
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
onChange={handleSeekChange}
step="0.01"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
{/* Custom Thumb (visual only, follows progress) */}
<div
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
style={{
left: `${progress}%`,
transform: 'translateX(-50%)'
}}
/>
</div>
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
{formatTime(duration)}
</span>
</div>
);
return (
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
<Button
onClick={onTogglePlayPause}
size="icon"
className={cn(
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
isPlaying
? "bg-white/10 text-white hover:bg-white/20"
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
)}
aria-label={isPlaying ? t("playback.pause") : t("playback.play")}
>
{isPlaying ? (
<Pause className="w-3.5 h-3.5 fill-current" />
) : (
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
)}
</Button>
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
{formatTime(currentTime)}
</span>
<div className="flex-1 relative h-6 flex items-center group">
{/* Custom Track Background */}
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-[#2563EB] rounded-full" style={{ width: `${progress}%` }} />
</div>
{/* Interactive Input */}
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
onChange={handleSeekChange}
step="0.01"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
{/* Custom Thumb (visual only, follows progress) */}
<div
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
style={{
left: `${progress}%`,
transform: "translateX(-50%)",
}}
/>
</div>
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
{formatTime(duration)}
</span>
</div>
);
}
+49 -46
View File
@@ -10,6 +10,7 @@ import { useState } from "react";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react";
import { toast } from "sonner";
import { useI18n, useScopedT } from "../../contexts/I18nContext";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
import { SPEED_OPTIONS } from "./types";
import { CropControl } from "./CropControl";
@@ -185,6 +186,8 @@ export function SettingsPanel({
onSpeedChange,
onSpeedDelete,
}: SettingsPanelProps) {
const tSettings = useScopedT('settings');
const { t } = useI18n();
const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -252,8 +255,8 @@ export function SettingsPanel({
// Validate file type - only allow JPG/JPEG
const validTypes = ['image/jpeg', 'image/jpg'];
if (!validTypes.includes(file.type)) {
toast.error('Invalid file type', {
description: 'Please upload a JPG or JPEG image file.',
toast.error(tSettings('background.uploadError'), {
description: tSettings('background.uploadErrorDescription'),
});
event.target.value = '';
return;
@@ -266,13 +269,13 @@ export function SettingsPanel({
if (dataUrl) {
setCustomImages(prev => [...prev, dataUrl]);
onWallpaperChange(dataUrl);
toast.success('Custom image uploaded successfully!');
toast.success(tSettings('background.uploadSuccess'));
}
};
reader.onerror = () => {
toast.error('Failed to upload image', {
description: 'There was an error reading the file.',
toast.error(t('common.failedToUploadImage'), {
description: t('common.errorReadingFile'),
});
};
@@ -314,7 +317,7 @@ export function SettingsPanel({
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
<span className="text-sm font-medium text-slate-200">{tSettings('zoom.level')}</span>
<div className="flex items-center gap-2">
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#2563EB] bg-[#2563EB]/10 px-2 py-0.5 rounded-full">
@@ -348,7 +351,7 @@ export function SettingsPanel({
})}
</div>
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a zoom region to adjust</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{tSettings('zoom.selectRegion')}</p>
)}
{zoomEnabled && (
<Button
@@ -358,7 +361,7 @@ export function SettingsPanel({
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Zoom
{tSettings('zoom.deleteZoom')}
</Button>
)}
</div>
@@ -372,14 +375,14 @@ export function SettingsPanel({
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Trim Region
{tSettings('trim.deleteRegion')}
</Button>
</div>
)}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
<span className="text-sm font-medium text-slate-200">{tSettings('speed.playbackSpeed')}</span>
{selectedSpeedId && selectedSpeedValue && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
{SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`}
@@ -410,7 +413,7 @@ export function SettingsPanel({
})}
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a speed region to adjust</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{tSettings('speed.selectRegion')}</p>
)}
{selectedSpeedId && (
<Button
@@ -420,7 +423,7 @@ export function SettingsPanel({
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Speed Region
{tSettings('speed.deleteRegion')}
</Button>
)}
</div>
@@ -430,13 +433,13 @@ export function SettingsPanel({
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#2563EB]" />
<span className="text-xs font-medium">Video Effects</span>
<span className="text-xs font-medium">{tSettings('effects.title')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">Show Cursor</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.showCursor')}</div>
<Switch
checked={showCursor}
onCheckedChange={onShowCursorChange}
@@ -445,7 +448,7 @@ export function SettingsPanel({
</div>
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div>
<div className="text-[10px] font-medium text-slate-300">Loop cursor</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.loopCursor')}</div>
</div>
<Switch
checked={loopCursor}
@@ -455,7 +458,7 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Background Blur</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.backgroundBlur')}</div>
<span className="text-[10px] text-slate-500 font-mono">{backgroundBlur.toFixed(1)}px</span>
</div>
<Slider
@@ -472,7 +475,7 @@ export function SettingsPanel({
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Zoom Motion Blur</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.zoomMotionBlur')}</div>
<span className="text-[10px] text-slate-500 font-mono">{zoomMotionBlur.toFixed(2)}×</span>
</div>
<Slider
@@ -486,7 +489,7 @@ export function SettingsPanel({
</div>
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">Connect Zooms</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.connectZooms')}</div>
<Switch
checked={connectZooms}
onCheckedChange={onConnectZoomsChange}
@@ -498,7 +501,7 @@ export function SettingsPanel({
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Cursor Size</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.cursorSize')}</div>
<span className="text-[10px] text-slate-500 font-mono">{cursorSize.toFixed(2)}×</span>
</div>
<Slider
@@ -512,8 +515,8 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Cursor Smoothing</div>
<span className="text-[10px] text-slate-500 font-mono">{cursorSmoothing <= 0 ? 'Off' : cursorSmoothing.toFixed(2)}</span>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.cursorSmoothing')}</div>
<span className="text-[10px] text-slate-500 font-mono">{cursorSmoothing <= 0 ? tSettings('effects.off') : cursorSmoothing.toFixed(2)}</span>
</div>
<Slider
value={[cursorSmoothing]}
@@ -529,7 +532,7 @@ export function SettingsPanel({
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Cursor Motion Blur</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.cursorMotionBlur')}</div>
<span className="text-[10px] text-slate-500 font-mono">{cursorMotionBlur.toFixed(2)}×</span>
</div>
<Slider
@@ -543,7 +546,7 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Cursor Click Bounce</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.cursorClickBounce')}</div>
<span className="text-[10px] text-slate-500 font-mono">{cursorClickBounce.toFixed(2)}×</span>
</div>
<Slider
@@ -560,7 +563,7 @@ export function SettingsPanel({
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.shadow')}</div>
<span className="text-[10px] text-slate-500 font-mono">{Math.round(shadowIntensity * 100)}%</span>
</div>
<Slider
@@ -574,7 +577,7 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Roundness</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.roundness')}</div>
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
</div>
<Slider
@@ -588,7 +591,7 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Padding</div>
<div className="text-[10px] font-medium text-slate-300">{tSettings('effects.padding')}</div>
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
</div>
<Slider
@@ -608,7 +611,7 @@ export function SettingsPanel({
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
>
<Crop className="w-3 h-3" />
Crop Video
{tSettings('crop.title')}
</Button>
</AccordionContent>
</AccordionItem>
@@ -617,15 +620,15 @@ export function SettingsPanel({
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-[#2563EB]" />
<span className="text-xs font-medium">Background</span>
<span className="text-xs font-medium">{tSettings('background.title')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<Tabs defaultValue="image" className="w-full">
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
<TabsTrigger value="image" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">Image</TabsTrigger>
<TabsTrigger value="color" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">Color</TabsTrigger>
<TabsTrigger value="gradient" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">Gradient</TabsTrigger>
<TabsTrigger value="image" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">{tSettings('background.image')}</TabsTrigger>
<TabsTrigger value="color" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">{tSettings('background.color')}</TabsTrigger>
<TabsTrigger value="gradient" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">{tSettings('background.gradient')}</TabsTrigger>
</TabsList>
<div className="max-h-[min(200px,25vh)] overflow-y-auto custom-scrollbar">
@@ -643,7 +646,7 @@ export function SettingsPanel({
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all h-7 text-[10px]"
>
<Upload className="w-3 h-3" />
Upload Custom
{tSettings('background.uploadCustom')}
</Button>
<div className="grid grid-cols-7 gap-1.5">
@@ -759,8 +762,8 @@ export function SettingsPanel({
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div>
<span className="text-xl font-bold text-slate-200">Crop Video</span>
<p className="text-sm text-slate-400 mt-2">Drag on each side to adjust the crop area</p>
<span className="text-xl font-bold text-slate-200">{tSettings('crop.title')}</span>
<p className="text-sm text-slate-400 mt-2">{tSettings('crop.instruction')}</p>
</div>
<Button
variant="ghost"
@@ -783,7 +786,7 @@ export function SettingsPanel({
size="lg"
className="bg-[#2563EB] hover:bg-[#2563EB]/90 text-white"
>
Done
{t('common.actions.done')}
</Button>
</div>
</div>
@@ -802,7 +805,7 @@ export function SettingsPanel({
)}
>
<Film className="w-3.5 h-3.5" />
MP4
{tSettings('export.mp4')}
</button>
<button
onClick={() => onExportFormatChange?.('gif')}
@@ -814,7 +817,7 @@ export function SettingsPanel({
)}
>
<Image className="w-3.5 h-3.5" />
GIF
{tSettings('export.gif')}
</button>
</div>
@@ -827,7 +830,7 @@ export function SettingsPanel({
exportQuality === 'medium' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
Low
{tSettings('export.quality.low')}
</button>
<button
onClick={() => onExportQualityChange?.('good')}
@@ -836,7 +839,7 @@ export function SettingsPanel({
exportQuality === 'good' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
Medium
{tSettings('export.quality.medium')}
</button>
<button
onClick={() => onExportQualityChange?.('source')}
@@ -845,7 +848,7 @@ export function SettingsPanel({
exportQuality === 'source' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
High
{tSettings('export.quality.high')}
</button>
</div>
)}
@@ -885,7 +888,7 @@ export function SettingsPanel({
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-500">{gifOutputDimensions.width} × {gifOutputDimensions.height}px</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400">Loop</span>
<span className="text-[10px] text-slate-400">{tSettings('export.loop')}</span>
<Switch
checked={gifLoop}
onCheckedChange={onGifLoopChange}
@@ -904,7 +907,7 @@ export function SettingsPanel({
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Project
{tSettings('export.loadProject')}
</Button>
<Button
type="button"
@@ -913,7 +916,7 @@ export function SettingsPanel({
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<Save className="w-3.5 h-3.5" />
Save Project
{tSettings('export.saveProject')}
</Button>
</div>
@@ -924,7 +927,7 @@ export function SettingsPanel({
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#2563EB] text-white rounded-xl shadow-lg shadow-[#2563EB]/20 hover:bg-[#2563EB]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-4 h-4" />
Export {exportFormat === 'gif' ? 'GIF' : 'Video'}
{tSettings('export.exportVideo', undefined, { format: exportFormat === 'gif' ? 'GIF' : 'Video' })}
</Button>
<div className="flex gap-2 mt-3">
@@ -936,7 +939,7 @@ export function SettingsPanel({
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
<Bug className="w-3 h-3 text-[#2563EB]" />
Report Bug
{tSettings('export.reportBug')}
</button>
<button
type="button"
@@ -946,7 +949,7 @@ export function SettingsPanel({
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
<Star className="w-3 h-3 text-yellow-400" />
Star on GitHub
{tSettings('export.starOnGithub')}
</button>
</div>
</div>
@@ -17,10 +17,12 @@ import {
type ShortcutsConfig,
} from '@/lib/shortcuts';
import { useShortcuts } from '@/contexts/ShortcutsContext';
import { useScopedT } from '../../contexts/I18nContext';
const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']);
export function ShortcutsConfigDialog() {
const t = useScopedT('dialogs');
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
@@ -61,7 +63,7 @@ export function ShortcutsConfigDialog() {
setCaptureFor(null);
if (found?.type === 'fixed') {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
toast.error(t('shortcutsConfig.reserved', undefined, { label: found.label }));
return;
}
@@ -75,7 +77,7 @@ export function ShortcutsConfigDialog() {
window.addEventListener('keydown', handleCapture, { capture: true });
return () => window.removeEventListener('keydown', handleCapture, { capture: true });
}, [captureFor]);
}, [captureFor, t]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== 'configurable') return;
@@ -93,14 +95,14 @@ export function ShortcutsConfigDialog() {
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success('Keyboard shortcuts saved');
toast.success(t('shortcutsConfig.saved'));
closeConfig();
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
const handleReset = useCallback(() => {
setDraft({ ...DEFAULT_SHORTCUTS });
toast.info('Reset to default shortcuts — click Save to apply');
}, []);
toast.info(t('shortcutsConfig.resetNotice'));
}, [t]);
const handleClose = useCallback(() => {
setCaptureFor(null);
@@ -114,12 +116,12 @@ export function ShortcutsConfigDialog() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#2563EB]" />
Keyboard Shortcuts
{t('shortcutsConfig.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Configurable</p>
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">{t('shortcutsConfig.configurable')}</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
@@ -133,7 +135,7 @@ export function ShortcutsConfigDialog() {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
title={isCapturing ? t('shortcutsConfig.pressEscToCancel') : t('shortcutsConfig.clickToChange')}
className={[
'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
isCapturing
@@ -143,13 +145,13 @@ export function ShortcutsConfigDialog() {
: 'bg-white/5 border-white/10 text-slate-200 hover:border-[#2563EB]/50 hover:text-[#2563EB] cursor-pointer',
].join(' ')}
>
{isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
{isCapturing ? t('shortcutsConfig.pressAKey') : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === 'configurable' && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
⚠ Already used by <strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
{t('shortcutsConfig.alreadyUsedBy', undefined, { action: SHORTCUT_LABELS[conflict.conflictWith.action] })}
</span>
<div className="flex gap-1.5">
<button
@@ -157,14 +159,14 @@ export function ShortcutsConfigDialog() {
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
{t('shortcutsConfig.swap')}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
{t('shortcutsConfig.cancel')}
</button>
</div>
</div>
@@ -175,7 +177,7 @@ export function ShortcutsConfigDialog() {
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Fixed</p>
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">{t('shortcutsConfig.fixed')}</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
<div
key={label}
@@ -190,8 +192,7 @@ export function ShortcutsConfigDialog() {
</div>
<p className="text-[10px] text-slate-500 mt-1">
Click a shortcut then press the new key combination. Press{' '}
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
{t('shortcutsConfig.instructions')}
</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
@@ -202,18 +203,18 @@ export function ShortcutsConfigDialog() {
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
Reset to defaults
{t('shortcutsConfig.resetToDefaults')}
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose}>
Cancel
{t('shortcutsConfig.cancel')}
</Button>
<Button
size="sm"
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white"
onClick={handleSave}
>
Save
{t('shortcutsConfig.save')}
</Button>
</div>
</DialogFooter>
+129 -142
View File
@@ -1,146 +1,133 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ArrowRight, HelpCircle, Scissors } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HelpCircle, Scissors, ArrowRight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
export function TutorialHelp() {
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">How trimming works</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
</DialogTitle>
<DialogDescription className="text-slate-400">
Understanding how to cut out unwanted parts of your video.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
<p className="text-slate-300 leading-relaxed">
The Trim tool works by defining the segments you want to
<span className="text-[#ef4444] font-bold"> remove</span>. Any part
of the timeline that is
<span className="text-[#ef4444] font-bold"> covered</span> by a red
trim segment will be cut out when you export.
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Visual Example
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
{/* Solid line representing video */}
</div>
{/* Removed Segment 1 */}
<div
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Removed Segment 2 */}
<div
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
Kept
</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
Kept
</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
Kept
</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
</div>
{/* Result */}
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 1
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 2
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 3
</span>
</div>
<span className="absolute right-4 text-xs text-slate-400">
Final Video
</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">
1. Add Trim
</div>
<p className="text-xs text-slate-400">
Press
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
or click the scissors icon to mark a section for removal.
</p>
</div>
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">
2. Adjust
</div>
<p className="text-xs text-slate-400">
Drag the edges of the red region to cover exactly what you want
to cut out.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
const t = useScopedT("editor");
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">{t("tutorial.howTrimmingWorks")}</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")}
</DialogTitle>
<DialogDescription className="text-slate-400">
{t("tutorial.understanding")}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
<p className="text-slate-300 leading-relaxed">
{t("tutorial.descriptionP1")}
<span className="text-[#ef4444] font-bold"> {t("tutorial.descriptionRemove")}</span>.{" "}
{t("tutorial.descriptionP3")}
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
{t("tutorial.visualExample")}
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
{/* Solid line representing video */}
</div>
{/* Removed Segment 1 */}
<div
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
{t("tutorial.removed")}
</span>
</div>
{/* Removed Segment 2 */}
<div
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
{t("tutorial.removed")}
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
</div>
{/* Result */}
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
{t("tutorial.part", undefined, { number: "1" })}
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
{t("tutorial.part", undefined, { number: "2" })}
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
{t("tutorial.part", undefined, { number: "3" })}
</span>
</div>
<span className="absolute right-4 text-xs text-slate-400">
{t("tutorial.finalVideo")}
</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.addTrimStep")}</div>
<p className="text-xs text-slate-400">{t("tutorial.addTrimDesc")}</p>
</div>
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.adjustStep")}</div>
<p className="text-xs text-slate-400">{t("tutorial.adjustDesc")}</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+78 -65
View File
@@ -1,80 +1,93 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import type { Span } from "dnd-timeline";
import { FolderOpen, Languages } from "lucide-react";
import { useI18n } from "@/contexts/I18nContext";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { SUPPORTED_LOCALES } from "@/i18n/config";
import type { AppLocale } from "@/i18n/config";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import { SettingsPanel } from "./SettingsPanel";
import { ExportDialog } from "./ExportDialog";
import { DEFAULT_WALLPAPER_RELATIVE_PATH, WALLPAPER_PATHS } from "@/lib/wallpapers";
import {
createProjectData,
deriveNextId,
fromFileUrl,
normalizeProjectEditor,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import type { Span } from "dnd-timeline";
import {
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_MOTION_BLUR,
clampFocusToDepth,
DEFAULT_CROP_REGION,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
type CursorTelemetryPoint,
type TrimRegion,
type AnnotationRegion,
type CropRegion,
type FigureData,
type SpeedRegion,
type PlaybackSpeed,
} from "./types";
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { getAssetPath } from "@/lib/assetPath";
import {
calculateOutputDimensions,
type ExportFormat,
type ExportProgress,
type ExportQuality,
type ExportSettings,
GIF_SIZE_PRESETS,
GifExporter,
type GifFrameRate,
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
import { matchesShortcut } from "@/lib/shortcuts";
import { detectInteractionCandidates, normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils";
import { buildLoopedCursorTelemetry, getDisplayedTimelineWindowMs } from "./videoPlayback/cursorLoopTelemetry";
import { DEFAULT_WALLPAPER_RELATIVE_PATH, WALLPAPER_PATHS } from "@/lib/wallpapers";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
import PlaybackControls from "./PlaybackControls";
import {
createProjectData,
deriveNextId,
fromFileUrl,
normalizeProjectEditor,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
detectInteractionCandidates,
normalizeCursorTelemetry,
} from "./timeline/zoomSuggestionUtils";
import {
type AnnotationRegion,
type CropRegion,
type CursorTelemetryPoint,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_MOTION_BLUR,
type FigureData,
type PlaybackSpeed,
type SpeedRegion,
type TrimRegion,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import {
buildLoopedCursorTelemetry,
getDisplayedTimelineWindowMs,
} from "./videoPlayback/cursorLoopTelemetry";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
const LOOP_CURSOR_END_WINDOW_MS = 670;
type EditorHistorySnapshot = {
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
selectedZoomId: string | null;
selectedTrimId: string | null;
selectedSpeedId: string | null;
selectedAnnotationId: string | null;
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
selectedZoomId: string | null;
selectedTrimId: string | null;
selectedSpeedId: string | null;
selectedAnnotationId: string | null;
};
type PendingExportSave = {
fileName: string;
arrayBuffer: ArrayBuffer;
fileName: string;
arrayBuffer: ArrayBuffer;
};
function LanguageSwitcher() {
@@ -98,6 +111,7 @@ function LanguageSwitcher() {
export default function VideoEditor() {
const { t } = useI18n();
const tEditor = useScopedT("editor");
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
@@ -1835,4 +1849,3 @@ export default function VideoEditor() {
</div>
);
}
+147 -139
View File
@@ -1,162 +1,170 @@
import { useMemo } from "react";
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import { ZoomIn, Scissors, MessageSquare, Gauge } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
id: string;
span: Span;
rowId: string;
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: 'zoom' | 'trim' | 'annotation' | 'speed';
id: string;
span: Span;
rowId: string;
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: "zoom" | "trim" | "annotation" | "speed";
}
// Map zoom depth to multiplier labels
const ZOOM_LABELS: Record<number, string> = {
1: "1.25×",
2: "1.5×",
3: "1.8×",
4: "2.2×",
5: "3.5×",
6: "5×",
1: "1.25×",
2: "1.5×",
3: "1.8×",
4: "2.2×",
5: "3.5×",
6: "5×",
};
function formatMs(ms: number): string {
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}:${seconds.toFixed(1).padStart(4, '0')}`;
}
return `${seconds.toFixed(1)}s`;
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
}
return `${seconds.toFixed(1)}s`;
}
export default function Item({
id,
span,
rowId,
isSelected = false,
onSelect,
zoomDepth = 1,
speedValue,
variant = 'zoom',
children
id,
span,
rowId,
isSelected = false,
onSelect,
zoomDepth = 1,
speedValue,
variant = "zoom",
children,
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
data: { rowId },
});
const t = useScopedT("timeline");
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
data: { rowId },
});
const isZoom = variant === 'zoom';
const isTrim = variant === 'trim';
const isSpeed = variant === 'speed';
const isZoom = variant === "zoom";
const isTrim = variant === "trim";
const isSpeed = variant === "speed";
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: isSpeed
? glassStyles.glassAmber
: glassStyles.glassYellow;
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: isSpeed
? glassStyles.glassAmber
: glassStyles.glassYellow;
const endCapColor = isZoom
? '#2563EB'
: isTrim
? '#ef4444'
: isSpeed
? '#d97706'
: '#B4A046';
const endCapColor = isZoom ? "#2563EB" : isTrim ? "#ef4444" : isSpeed ? "#d97706" : "#B4A046";
const timeLabel = useMemo(
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
[span.start, span.end],
);
const timeLabel = useMemo(
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
[span.start, span.end],
);
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
return (
<div
ref={setNodeRef}
style={safeItemStyle}
{...listeners}
{...attributes}
onPointerDownCapture={() => onSelect?.()}
className="group"
>
<div style={{ ...itemContentStyle, minWidth: 24 }}>
<div
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected
)}
style={{ height: 40, color: '#fff', minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Trim
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : 'Speed'}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{children}
</span>
</>
)}
</div>
<span
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
isSelected ? 'opacity-60' : 'opacity-0 group-hover:opacity-40'
}`}
>
{timeLabel}
</span>
</div>
</div>
</div>
</div>
);
return (
<div
ref={setNodeRef}
style={safeItemStyle}
{...listeners}
{...attributes}
onPointerDownCapture={() => onSelect?.()}
className="group"
>
<div style={{ ...itemContentStyle, minWidth: 24 }}>
<div
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected,
)}
style={{ height: 40, color: "#fff", minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{
cursor: "col-resize",
pointerEvents: "auto",
width: 8,
opacity: 0.9,
background: endCapColor,
}}
title={t("resizeLeft")}
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{
cursor: "col-resize",
pointerEvents: "auto",
width: 8,
opacity: 0.9,
background: endCapColor,
}}
title={t("resizeRight")}
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{t("trim.label", undefined, { index: "" }).trim()}
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : t("speed.label")}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{children}
</span>
</>
)}
</div>
<span
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
isSelected ? "opacity-60" : "opacity-0 group-hover:opacity-40"
}`}
>
{timeLabel}
</span>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -96,8 +96,17 @@ function normalizeLocale(locale: string | null | undefined): AppLocale {
)
if (canonical) return canonical
// Handle extended subtags like "zh-Hans-CN" → try "zh-CN"
const parts = locale.split('-')
if (parts.length >= 3) {
const langRegion = `${parts[0]}-${parts[parts.length - 1]}`
if (isSupportedLocale(langRegion)) {
return langRegion
}
}
// Language-only fallback (e.g. "zh" matches "zh-CN")
const lang = locale.split('-')[0].toLowerCase()
const lang = parts[0].toLowerCase()
const byLang = SUPPORTED_LOCALES.find((l) => l.split('-')[0].toLowerCase() === lang)
if (byLang) return byLang
+17 -6
View File
@@ -1,7 +1,18 @@
{
"app": {
"name": "Recordly",
"editorTitle": "Recordly Editor",
"subtitle": "Screen recording and editing"
}
}
"app": {
"name": "Recordly",
"editorTitle": "Recordly Editor",
"subtitle": "Screen recording and editing"
},
"actions": {
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"done": "Done"
},
"errors": {
"invalidFileType": "Invalid file type",
"failedToUploadImage": "Failed to upload image",
"fileReadError": "There was an error reading the file."
}
}
+61 -1
View File
@@ -1 +1,61 @@
{}
{
"export": {
"pleaseTryAgain": "Please try again",
"compilingGifProgress": "Compiling GIF... {{progress}}%",
"compilingGifWait": "Compiling GIF... This may take a while",
"takeMoment": "This may take a moment...",
"exportFailed": "Export Failed",
"compilingGifTitle": "Compiling GIF",
"exportingFormat": "Exporting {{format}}",
"exportComplete": "Export Complete",
"formatReady": "Your {{format}} is ready",
"showInFolder": "Show in Folder",
"compiling": "Compiling",
"renderingFrames": "Rendering Frames",
"processing": "Processing...",
"status": "Status",
"format": "Format",
"compilingStatus": "Compiling...",
"frames": "Frames",
"cancelExport": "Cancel Export",
"savedSuccess": "{{format}} saved successfully!"
},
"addFont": {
"title": "Add Google Font",
"heading": "Add Google Font",
"description": "Add a custom font from Google Fonts to use in your annotations.",
"urlLabel": "Google Fonts Import URL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
"nameLabel": "Display Name",
"namePlaceholder": "My Custom Font",
"nameHelp": "This is how the font will appear in the font selector",
"adding": "Adding...",
"addFont": "Add Font",
"enterUrl": "Please enter a Google Fonts import URL",
"invalidUrl": "Please enter a valid Google Fonts URL",
"enterName": "Please enter a font name",
"extractFailed": "Could not extract font family from URL",
"addSuccess": "Font \"{{name}}\" added successfully",
"addFailed": "Failed to add font",
"loadTimeout": "Font took too long to load. Please check the URL and try again.",
"loadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct."
},
"shortcutsConfig": {
"title": "Keyboard Shortcuts",
"configurable": "Configurable",
"fixed": "Fixed",
"pressEscToCancel": "Press Esc to cancel",
"clickToChange": "Click to change",
"pressAKey": "Press a key…",
"alreadyUsedBy": "Already used by <strong>{{action}}</strong>",
"swap": "Swap",
"reserved": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
"saved": "Keyboard shortcuts saved",
"resetNotice": "Reset to default shortcuts — click Save to apply",
"instructions": "Click a shortcut then press the new key combination. Press Esc to cancel.",
"resetToDefaults": "Reset to defaults",
"cancel": "Cancel",
"save": "Save"
}
}
+91 -1
View File
@@ -1 +1,91 @@
{}
{
"playback": {
"play": "Play",
"pause": "Pause"
},
"annotations": {
"settings": "Annotation Settings",
"active": "Active",
"text": "Text",
"image": "Image",
"arrow": "Arrow",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
"selectStyle": "Select style",
"size": "Size",
"toggleBold": "Toggle bold",
"toggleItalic": "Toggle italic",
"toggleUnderline": "Toggle underline",
"alignLeft": "Align left",
"alignCenter": "Align center",
"alignRight": "Align right",
"textColor": "Text Color",
"background": "Background",
"none": "None",
"clearBackground": "Clear Background",
"uploadImage": "Upload Image",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
"arrowDirection": "Arrow Direction",
"strokeWidth": "Stroke Width: {{width}}px",
"arrowColor": "Arrow Color",
"deleteAnnotation": "Delete Annotation",
"shortcutsAndTips": "Shortcuts & Tips",
"tipSelectAnnotation": "Move playhead to overlapping annotation section and select an item.",
"tipCycleForward": "Use Tab to cycle through overlapping items.",
"tipCycleBackward": "Use Shift+Tab to cycle backwards.",
"imageUploadSuccess": "Image uploaded successfully!",
"imageUploadError": "Please upload a JPG, PNG, GIF, or WebP image file."
},
"fontStyles": {
"classic": "Classic",
"editor": "Editor",
"strong": "Strong",
"typewriter": "Typewriter",
"deco": "Deco",
"simple": "Simple",
"modern": "Modern",
"clean": "Clean"
},
"format": {
"mp4Video": "MP4 Video",
"mp4Description": "High quality video file",
"gifAnimation": "GIF Animation",
"gifDescription": "Animated image for sharing"
},
"gifOptions": {
"frameRate": "Frame Rate",
"outputSize": "Output Size",
"outputDimensions": "Output: {{width}} × {{height}}px",
"loopAnimation": "Loop Animation",
"loopDescription": "GIF will play continuously"
},
"tutorial": {
"howTrimmingWorks": "How trimming works",
"title": "How Trimming Works",
"understanding": "Understanding how to cut out unwanted parts of your video.",
"descriptionP1": "The Trim tool works by defining the segments you want to",
"descriptionRemove": "remove",
"descriptionP2": "from your video.",
"descriptionP3": "Any part of the timeline that is covered by a red trim segment will be cut out when you export.",
"visualExample": "Visual Example",
"removed": "REMOVED",
"kept": "Kept",
"finalVideo": "Final Video",
"part": "Part {{number}}",
"addTrimStep": "1. Add Trim",
"addTrimDesc": "Press T or click the scissors icon to mark a section for removal.",
"adjustStep": "2. Adjust",
"adjustDesc": "Drag the edges of the red region to cover exactly what you want to cut out."
},
"keyboardShortcuts": {
"title": "Keyboard Shortcuts",
"customizeTooltip": "Customize shortcuts",
"customize": "Customize",
"panTimeline": "Pan Timeline",
"zoomTimeline": "Zoom Timeline",
"cycleAnnotations": "Cycle Annotations",
"tab": "Tab"
},
"openRecordingsFolder": "Open recordings folder"
}
+36 -1
View File
@@ -1 +1,36 @@
{}
{
"recording": {
"disableSystemAudio": "Disable system audio",
"enableSystemAudio": "Enable system audio",
"disableMicrophone": "Disable microphone",
"enableMicrophone": "Enable microphone",
"record": "Record",
"recordingFolder": "Recording folder: {{path}}",
"chooseRecordingsFolder": "Choose recordings folder",
"folderPath": "Path: /{{name}}/",
"openVideoFile": "Open video file",
"openProject": "Open project",
"hideHud": "Hide HUD",
"closeApp": "Close App"
},
"sourceSelector": {
"loadingSources": "Loading sources...",
"screens": "Screens",
"windows": "Windows",
"windowsNote": "Only visible (non-minimized) windows can be recorded.",
"windowPlaceholder": "Window",
"cancel": "Cancel",
"share": "Share"
},
"permissions": {
"screenRecordingNeeded": "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly.",
"screenRecordingMissing": "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.",
"accessibilityNeeded": "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly.",
"accessibilityMissing": "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.",
"selectSource": "Please select a source to record",
"systemAudioUnavailable": "System audio is not available for this source. Recording will continue without system audio.",
"microphoneDenied": "Microphone access was denied. Recording will continue without microphone audio.",
"failedToStart": "Failed to start recording: {{error}}",
"failedToStartGeneric": "Failed to start recording"
}
}
+60 -1
View File
@@ -1 +1,60 @@
{}
{
"zoom": {
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom"
},
"trim": {
"deleteRegion": "Delete Trim Region"
},
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region"
},
"effects": {
"title": "Video Effects",
"showCursor": "Show Cursor",
"loopCursor": "Loop cursor",
"backgroundBlur": "Background Blur",
"zoomMotionBlur": "Zoom Motion Blur",
"connectZooms": "Connect Zooms",
"cursorSize": "Cursor Size",
"cursorSmoothing": "Cursor Smoothing",
"off": "Off",
"cursorMotionBlur": "Cursor Motion Blur",
"cursorClickBounce": "Cursor Click Bounce",
"shadow": "Shadow",
"roundness": "Roundness",
"padding": "Padding"
},
"crop": {
"title": "Crop Video",
"instruction": "Drag on each side to adjust the crop area"
},
"background": {
"title": "Background",
"image": "Image",
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
"uploadSuccess": "Custom image uploaded successfully!",
"uploadError": "Please upload a JPG or JPEG image file."
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "Low",
"medium": "Medium",
"high": "High"
},
"loop": "Loop",
"outputDimensions": "Output: {{dimensions}}px",
"loadProject": "Load Project",
"saveProject": "Save Project",
"exportVideo": "Export {{format}}",
"reportBug": "Report Bug",
"starOnGithub": "Star on GitHub"
}
}
+16 -1
View File
@@ -1 +1,16 @@
{}
{
"actions": {
"addZoom": "Add Zoom",
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
"playPause": "Play / Pause",
"cycleForward": "Cycle Annotations Forward",
"cycleBackward": "Cycle Annotations Backward",
"deleteSelectedAlt": "Delete Selected (alt)",
"panTimeline": "Pan Timeline",
"zoomTimeline": "Zoom Timeline"
}
}
+38 -1
View File
@@ -1 +1,38 @@
{}
{
"zoom": {
"cannotPlace": "Cannot place zoom here",
"existsOrNoSpace": "Zoom already exists at this location or not enough space available.",
"suggestHandlerUnavailable": "Zoom suggestion handler unavailable",
"noTelemetry": "No cursor telemetry available",
"recordFirst": "Record a screencast first to generate cursor-based suggestions.",
"noUsableTelemetry": "No usable cursor telemetry",
"notEnoughMovement": "The recording does not include enough cursor movement data.",
"noInteractionMoments": "No clear interaction moments found",
"tryRecording": "Try a recording with pauses or clicks around important actions.",
"noAutoZoomSlots": "No auto-zoom slots available",
"dwellPointsOverlap": "Detected dwell points overlap existing zoom regions.",
"addedSuggestions": "Added {{count}} interaction-based zoom suggestion(s)",
"label": "Zoom {{index}}",
"addZoom": "Add Zoom (Z)",
"suggestZooms": "Suggest Zooms from Cursor"
},
"trim": {
"cannotPlace": "Cannot place trim here",
"existsOrNoSpace": "Trim already exists at this location or not enough space available.",
"label": "Trim {{index}}",
"addTrim": "Add Trim (T)"
},
"speed": {
"cannotPlace": "Cannot place speed here",
"existsOrNoSpace": "Speed region already exists at this location or not enough space available.",
"label": "Speed"
},
"annotation": {
"label": "Annotation",
"image": "Image",
"addAnnotation": "Add Annotation (A)"
},
"addSpeed": "Add Speed (S)",
"resizeLeft": "Resize left",
"resizeRight": "Resize right"
}
+17 -6
View File
@@ -1,7 +1,18 @@
{
"app": {
"name": "Recordly",
"editorTitle": "Editor de Recordly",
"subtitle": "Grabacion de pantalla y edicion"
}
}
"app": {
"name": "Recordly",
"editorTitle": "Editor de Recordly",
"subtitle": "Grabación de pantalla y edición"
},
"actions": {
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"done": "Hecho"
},
"errors": {
"invalidFileType": "Tipo de archivo inválido",
"failedToUploadImage": "Error al subir la imagen",
"fileReadError": "Hubo un error al leer el archivo."
}
}
+61 -1
View File
@@ -1 +1,61 @@
{}
{
"export": {
"pleaseTryAgain": "Por favor inténtalo de nuevo",
"compilingGifProgress": "Compilando GIF... {{progress}}%",
"compilingGifWait": "Compilando GIF... Esto puede tardar un momento",
"takeMoment": "Esto puede tardar un momento...",
"exportFailed": "Error en la exportación",
"compilingGifTitle": "Compilando GIF",
"exportingFormat": "Exportando {{format}}",
"exportComplete": "Exportación completada",
"formatReady": "Tu {{format}} está listo",
"showInFolder": "Mostrar en carpeta",
"compiling": "Compilando",
"renderingFrames": "Renderizando cuadros",
"processing": "Procesando...",
"status": "Estado",
"format": "Formato",
"compilingStatus": "Compilando...",
"frames": "Cuadros",
"cancelExport": "Cancelar exportación",
"savedSuccess": "¡{{format}} guardado exitosamente!"
},
"addFont": {
"title": "Agregar fuente de Google",
"heading": "Agregar fuente de Google",
"description": "Agrega una fuente personalizada de Google Fonts para usar en tus anotaciones.",
"urlLabel": "URL de importación de Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Obtener fuente\" → Copia la URL de @import",
"nameLabel": "Nombre para mostrar",
"namePlaceholder": "Mi fuente personalizada",
"nameHelp": "Así aparecerá la fuente en el selector de fuentes",
"adding": "Agregando...",
"addFont": "Agregar fuente",
"enterUrl": "Por favor ingresa una URL de importación de Google Fonts",
"invalidUrl": "Por favor ingresa una URL válida de Google Fonts",
"enterName": "Por favor ingresa un nombre de fuente",
"extractFailed": "No se pudo extraer el nombre de la familia de fuentes de la URL",
"addSuccess": "Fuente \"{{name}}\" agregada exitosamente",
"addFailed": "Error al agregar la fuente",
"loadTimeout": "La fuente tardó demasiado en cargar. Por favor verifica la URL e inténtalo de nuevo.",
"loadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta."
},
"shortcutsConfig": {
"title": "Atajos de teclado",
"configurable": "Configurable",
"fixed": "Fijo",
"pressEscToCancel": "Presiona Esc para cancelar",
"clickToChange": "Haz clic para cambiar",
"pressAKey": "Presiona una tecla…",
"alreadyUsedBy": "Ya está en uso por <strong>{{action}}</strong>",
"swap": "Intercambiar",
"reserved": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
"saved": "Atajos de teclado guardados",
"resetNotice": "Restablecer a atajos predeterminados — haz clic en Guardar para aplicar",
"instructions": "Haz clic en un atajo y luego presiona la nueva combinación de teclas. Presiona Esc para cancelar.",
"resetToDefaults": "Restablecer valores predeterminados",
"cancel": "Cancelar",
"save": "Guardar"
}
}
+91 -1
View File
@@ -1 +1,91 @@
{}
{
"playback": {
"play": "Reproducir",
"pause": "Pausar"
},
"annotations": {
"settings": "Configuración de anotaciones",
"active": "Activo",
"text": "Texto",
"image": "Imagen",
"arrow": "Flecha",
"textContent": "Contenido de texto",
"textPlaceholder": "Ingresa tu texto...",
"fontStyle": "Estilo de fuente",
"selectStyle": "Seleccionar estilo",
"size": "Tamaño",
"toggleBold": "Alternar negrita",
"toggleItalic": "Alternar cursiva",
"toggleUnderline": "Alternar subrayado",
"alignLeft": "Alinear a la izquierda",
"alignCenter": "Alinear al centro",
"alignRight": "Alinear a la derecha",
"textColor": "Color de texto",
"background": "Fondo",
"none": "Ninguno",
"clearBackground": "Borrar fondo",
"uploadImage": "Subir imagen",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
"arrowDirection": "Dirección de flecha",
"strokeWidth": "Ancho de trazo: {{width}}px",
"arrowColor": "Color de flecha",
"deleteAnnotation": "Eliminar anotación",
"shortcutsAndTips": "Atajos y consejos",
"tipSelectAnnotation": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
"tipCycleForward": "Usa Tab para recorrer los elementos superpuestos.",
"tipCycleBackward": "Usa Shift+Tab para recorrer hacia atrás.",
"imageUploadSuccess": "¡Imagen subida exitosamente!",
"imageUploadError": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP."
},
"fontStyles": {
"classic": "Clásico",
"editor": "Editor",
"strong": "Fuerte",
"typewriter": "Máquina de escribir",
"deco": "Deco",
"simple": "Simple",
"modern": "Moderno",
"clean": "Limpio"
},
"format": {
"mp4Video": "Video MP4",
"mp4Description": "Archivo de video de alta calidad",
"gifAnimation": "Animación GIF",
"gifDescription": "Imagen animada para compartir"
},
"gifOptions": {
"frameRate": "Velocidad de cuadros",
"outputSize": "Tamaño de salida",
"outputDimensions": "Salida: {{width}} × {{height}}px",
"loopAnimation": "Animación en bucle",
"loopDescription": "El GIF se reproducirá continuamente"
},
"tutorial": {
"howTrimmingWorks": "Cómo funciona el recorte",
"title": "Cómo Funciona el Recorte",
"understanding": "Comprende cómo eliminar las partes no deseadas de tu video.",
"descriptionP1": "La herramienta de recorte funciona definiendo los segmentos que deseas",
"descriptionRemove": "eliminar",
"descriptionP2": "de tu video.",
"descriptionP3": "Cualquier parte de la línea de tiempo cubierta por un segmento de recorte rojo será eliminada al exportar.",
"visualExample": "Ejemplo visual",
"removed": "ELIMINADO",
"kept": "Conservado",
"finalVideo": "Video final",
"part": "Parte {{number}}",
"addTrimStep": "1. Agregar recorte",
"addTrimDesc": "Presiona T o haz clic en el ícono de tijeras para marcar una sección para eliminar.",
"adjustStep": "2. Ajustar",
"adjustDesc": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
},
"keyboardShortcuts": {
"title": "Atajos de teclado",
"customizeTooltip": "Personalizar atajos",
"customize": "Personalizar",
"panTimeline": "Desplazar línea de tiempo",
"zoomTimeline": "Zoom en línea de tiempo",
"cycleAnnotations": "Recorrer anotaciones",
"tab": "Tab"
},
"openRecordingsFolder": "Abrir carpeta de grabaciones"
}
+36 -1
View File
@@ -1 +1,36 @@
{}
{
"recording": {
"disableSystemAudio": "Desactivar audio del sistema",
"enableSystemAudio": "Activar audio del sistema",
"disableMicrophone": "Desactivar micrófono",
"enableMicrophone": "Activar micrófono",
"record": "Grabar",
"recordingFolder": "Carpeta de grabaciones: {{path}}",
"chooseRecordingsFolder": "Elegir carpeta de grabaciones",
"folderPath": "Ruta: /{{name}}/",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto",
"hideHud": "Ocultar HUD",
"closeApp": "Cerrar aplicación"
},
"sourceSelector": {
"loadingSources": "Cargando fuentes...",
"screens": "Pantallas",
"windows": "Ventanas",
"windowsNote": "Solo se pueden grabar las ventanas visibles (no minimizadas).",
"windowPlaceholder": "Ventana",
"cancel": "Cancelar",
"share": "Compartir"
},
"permissions": {
"screenRecordingNeeded": "Recordly necesita permiso de grabación de pantalla antes de comenzar. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir Recordly.",
"screenRecordingMissing": "El permiso de grabación de pantalla aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir Recordly antes de grabar.",
"accessibilityNeeded": "Recordly también necesita permiso de accesibilidad para el seguimiento del cursor. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir Recordly.",
"accessibilityMissing": "El permiso de accesibilidad aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir Recordly antes de grabar.",
"selectSource": "Por favor selecciona una fuente para grabar",
"systemAudioUnavailable": "El audio del sistema no está disponible para esta fuente. La grabación continuará sin audio del sistema.",
"microphoneDenied": "Se denegó el acceso al micrófono. La grabación continuará sin audio del micrófono.",
"failedToStart": "Error al iniciar la grabación: {{error}}",
"failedToStartGeneric": "Error al iniciar la grabación"
}
}
+60 -1
View File
@@ -1 +1,60 @@
{}
{
"zoom": {
"level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
"deleteZoom": "Eliminar zoom"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad"
},
"effects": {
"title": "Efectos de video",
"showCursor": "Mostrar cursor",
"loopCursor": "Cursor en bucle",
"backgroundBlur": "Desenfoque de fondo",
"zoomMotionBlur": "Desenfoque de movimiento del zoom",
"connectZooms": "Conectar zooms",
"cursorSize": "Tamaño del cursor",
"cursorSmoothing": "Suavizado del cursor",
"off": "Desactivado",
"cursorMotionBlur": "Desenfoque de movimiento del cursor",
"cursorClickBounce": "Rebote de clic del cursor",
"shadow": "Sombra",
"roundness": "Redondez",
"padding": "Relleno"
},
"crop": {
"title": "Recortar video",
"instruction": "Arrastra cada lado para ajustar el área de recorte"
},
"background": {
"title": "Fondo",
"image": "Imagen",
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
"uploadError": "Por favor sube un archivo de imagen JPG o JPEG."
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "Baja",
"medium": "Media",
"high": "Alta"
},
"loop": "Bucle",
"outputDimensions": "Salida: {{dimensions}}px",
"loadProject": "Cargar proyecto",
"saveProject": "Guardar proyecto",
"exportVideo": "Exportar {{format}}",
"reportBug": "Reportar error",
"starOnGithub": "Estrella en GitHub"
}
}
+16 -1
View File
@@ -1 +1,16 @@
{}
{
"actions": {
"addZoom": "Agregar Zoom",
"addTrim": "Agregar Recorte",
"addSpeed": "Agregar Velocidad",
"addAnnotation": "Agregar Anotación",
"addKeyframe": "Agregar Fotograma clave",
"deleteSelected": "Eliminar seleccionado",
"playPause": "Reproducir / Pausar",
"cycleForward": "Recorrer anotaciones hacia adelante",
"cycleBackward": "Recorrer anotaciones hacia atrás",
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
"panTimeline": "Desplazar línea de tiempo",
"zoomTimeline": "Zoom en línea de tiempo"
}
}
+38 -1
View File
@@ -1 +1,38 @@
{}
{
"zoom": {
"cannotPlace": "No se puede colocar zoom aquí",
"existsOrNoSpace": "Ya existe un zoom en esta ubicación o no hay suficiente espacio.",
"suggestHandlerUnavailable": "Manejador de sugerencias de zoom no disponible",
"noTelemetry": "No hay telemetría de cursor disponible",
"recordFirst": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
"notEnoughMovement": "La grabación no incluye suficientes datos de movimiento del cursor.",
"noInteractionMoments": "No se encontraron momentos de interacción claros",
"tryRecording": "Intenta una grabación con pausas o clics alrededor de acciones importantes.",
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
"dwellPointsOverlap": "Los puntos de permanencia detectados se superponen con regiones de zoom existentes.",
"addedSuggestions": "Se agregaron {{count}} sugerencia(s) de zoom basadas en interacción",
"label": "Zoom {{index}}",
"addZoom": "Agregar Zoom (Z)",
"suggestZooms": "Sugerir zooms desde cursor"
},
"trim": {
"cannotPlace": "No se puede colocar recorte aquí",
"existsOrNoSpace": "Ya existe un recorte en esta ubicación o no hay suficiente espacio.",
"label": "Recorte {{index}}",
"addTrim": "Agregar Recorte (T)"
},
"speed": {
"cannotPlace": "No se puede colocar velocidad aquí",
"existsOrNoSpace": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio.",
"label": "Velocidad"
},
"annotation": {
"label": "Anotación",
"image": "Imagen",
"addAnnotation": "Agregar Anotación (A)"
},
"addSpeed": "Agregar Velocidad (S)",
"resizeLeft": "Redimensionar izquierda",
"resizeRight": "Redimensionar derecha"
}
+18 -1
View File
@@ -1 +1,18 @@
{}
{
"app": {
"name": "Recordly",
"editorTitle": "Recordly 编辑器",
"subtitle": "屏幕录制与编辑"
},
"actions": {
"cancel": "取消",
"save": "保存",
"delete": "删除",
"done": "完成"
},
"errors": {
"invalidFileType": "无效的文件类型",
"failedToUploadImage": "上传图片失败",
"fileReadError": "读取文件时出错。"
}
}
+61 -1
View File
@@ -1 +1,61 @@
{}
{
"export": {
"pleaseTryAgain": "请重试",
"compilingGifProgress": "正在编译 GIF... {{progress}}%",
"compilingGifWait": "正在编译 GIF... 这可能需要一些时间",
"takeMoment": "这可能需要一点时间...",
"exportFailed": "导出失败",
"compilingGifTitle": "正在编译 GIF",
"exportingFormat": "正在导出 {{format}}",
"exportComplete": "导出完成",
"formatReady": "您的 {{format}} 已准备就绪",
"showInFolder": "在文件夹中显示",
"compiling": "正在编译",
"renderingFrames": "正在渲染帧",
"processing": "正在处理...",
"status": "状态",
"format": "格式",
"compilingStatus": "正在编译...",
"frames": "帧",
"cancelExport": "取消导出",
"savedSuccess": "{{format}} 保存成功!"
},
"addFont": {
"title": "添加 Google 字体",
"heading": "添加 Google 字体",
"description": "从 Google Fonts 添加自定义字体用于注释。",
"urlLabel": "Google Fonts 导入 URL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "从 Google Fonts 获取:选择字体 → 点击「获取字体」→ 复制 @import URL",
"nameLabel": "显示名称",
"namePlaceholder": "我的自定义字体",
"nameHelp": "此名称将显示在字体选择器中",
"adding": "正在添加...",
"addFont": "添加字体",
"enterUrl": "请输入 Google Fonts 导入 URL",
"invalidUrl": "请输入有效的 Google Fonts URL",
"enterName": "请输入字体名称",
"extractFailed": "无法从 URL 提取字体系列名称",
"addSuccess": "字体 \"{{name}}\" 添加成功",
"addFailed": "添加字体失败",
"loadTimeout": "字体加载超时。请检查 URL 后重试。",
"loadFailed": "无法加载字体。请确认 Google Fonts URL 是否正确。"
},
"shortcutsConfig": {
"title": "键盘快捷键",
"configurable": "可配置",
"fixed": "固定",
"pressEscToCancel": "按 Esc 取消",
"clickToChange": "点击以更改",
"pressAKey": "按下按键…",
"alreadyUsedBy": "已被 <strong>{{action}}</strong> 使用",
"swap": "交换",
"reserved": "此快捷键已保留给 \"{{label}}\",无法重新分配。",
"saved": "键盘快捷键已保存",
"resetNotice": "已重置为默认快捷键 - 点击保存以应用",
"instructions": "点击快捷键后按下新的组合键。按 Esc 取消。",
"resetToDefaults": "重置为默认",
"cancel": "取消",
"save": "保存"
}
}
+91 -1
View File
@@ -1 +1,91 @@
{}
{
"playback": {
"play": "播放",
"pause": "暂停"
},
"annotations": {
"settings": "注释设置",
"active": "活动",
"text": "文本",
"image": "图片",
"arrow": "箭头",
"textContent": "文本内容",
"textPlaceholder": "输入文本...",
"fontStyle": "字体样式",
"selectStyle": "选择样式",
"size": "大小",
"toggleBold": "切换粗体",
"toggleItalic": "切换斜体",
"toggleUnderline": "切换下划线",
"alignLeft": "左对齐",
"alignCenter": "居中对齐",
"alignRight": "右对齐",
"textColor": "文本颜色",
"background": "背景",
"none": "无",
"clearBackground": "清除背景",
"uploadImage": "上传图片",
"supportedFormats": "支持格式:JPG、PNG、GIF、WebP",
"arrowDirection": "箭头方向",
"strokeWidth": "描边宽度:{{width}}px",
"arrowColor": "箭头颜色",
"deleteAnnotation": "删除注释",
"shortcutsAndTips": "快捷键与提示",
"tipSelectAnnotation": "将播放头移动到注释重叠区域并选择项目。",
"tipCycleForward": "使用 Tab 循环切换重叠项目。",
"tipCycleBackward": "使用 Shift+Tab 反向循环切换。",
"imageUploadSuccess": "图片上传成功!",
"imageUploadError": "请上传 JPG、PNG、GIF 或 WebP 图片文件。"
},
"fontStyles": {
"classic": "经典",
"editor": "编辑器",
"strong": "加粗",
"typewriter": "打字机",
"deco": "装饰",
"simple": "简约",
"modern": "现代",
"clean": "清爽"
},
"format": {
"mp4Video": "MP4 视频",
"mp4Description": "高质量视频文件",
"gifAnimation": "GIF 动画",
"gifDescription": "可分享的动态图片"
},
"gifOptions": {
"frameRate": "帧率",
"outputSize": "输出尺寸",
"outputDimensions": "输出:{{width}} × {{height}}px",
"loopAnimation": "循环动画",
"loopDescription": "GIF 将持续播放"
},
"tutorial": {
"howTrimmingWorks": "修剪的工作方式",
"title": "修剪的工作方式",
"understanding": "了解如何裁剪视频中不需要的部分。",
"descriptionP1": "修剪工具通过定义要",
"descriptionRemove": "移除",
"descriptionP2": "的片段来工作。",
"descriptionP3": "时间线上被红色修剪区域覆盖的部分将在导出时被剪掉。",
"visualExample": "视觉示例",
"removed": "已移除",
"kept": "保留",
"finalVideo": "最终视频",
"part": "第 {{number}} 部分",
"addTrimStep": "1. 添加修剪",
"addTrimDesc": "按 T 或点击剪刀图标标记要移除的部分。",
"adjustStep": "2. 调整",
"adjustDesc": "拖动红色区域的边缘,精确覆盖要剪掉的部分。"
},
"keyboardShortcuts": {
"title": "键盘快捷键",
"customizeTooltip": "自定义快捷键",
"customize": "自定义",
"panTimeline": "平移时间线",
"zoomTimeline": "缩放时间线",
"cycleAnnotations": "循环切换注释",
"tab": "Tab"
},
"openRecordingsFolder": "打开录制文件夹"
}
+36 -1
View File
@@ -1 +1,36 @@
{}
{
"recording": {
"disableSystemAudio": "禁用系统音频",
"enableSystemAudio": "启用系统音频",
"disableMicrophone": "禁用麦克风",
"enableMicrophone": "启用麦克风",
"record": "录制",
"recordingFolder": "录制文件夹:{{path}}",
"chooseRecordingsFolder": "选择录制文件夹",
"folderPath": "路径:/{{name}}/",
"openVideoFile": "打开视频文件",
"openProject": "打开项目",
"hideHud": "隐藏 HUD",
"closeApp": "关闭应用"
},
"sourceSelector": {
"loadingSources": "正在加载源...",
"screens": "屏幕",
"windows": "窗口",
"windowsNote": "仅可录制可见(非最小化)窗口。",
"windowPlaceholder": "窗口",
"cancel": "取消",
"share": "共享"
},
"permissions": {
"screenRecordingNeeded": "Recordly 需要屏幕录制权限才能开始。系统设置已打开。启用后请退出并重新打开 Recordly。",
"screenRecordingMissing": "屏幕录制权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。",
"accessibilityNeeded": "Recordly 还需要辅助功能权限以跟踪光标。系统设置已打开。启用后请退出并重新打开 Recordly。",
"accessibilityMissing": "辅助功能权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。",
"selectSource": "请选择要录制的源",
"systemAudioUnavailable": "此源不支持系统音频。将继续录制但不包含系统音频。",
"microphoneDenied": "麦克风访问被拒绝。将继续录制但不包含麦克风音频。",
"failedToStart": "录制启动失败:{{error}}",
"failedToStartGeneric": "录制启动失败"
}
}
+60 -1
View File
@@ -1 +1,60 @@
{}
{
"zoom": {
"level": "缩放级别",
"selectRegion": "选择缩放区域以调整",
"deleteZoom": "删除缩放"
},
"trim": {
"deleteRegion": "删除修剪区域"
},
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择变速区域以调整",
"deleteRegion": "删除变速区域"
},
"effects": {
"title": "视频效果",
"showCursor": "显示光标",
"loopCursor": "循环光标",
"backgroundBlur": "背景模糊",
"zoomMotionBlur": "缩放运动模糊",
"connectZooms": "连接缩放",
"cursorSize": "光标大小",
"cursorSmoothing": "光标平滑",
"off": "关",
"cursorMotionBlur": "光标运动模糊",
"cursorClickBounce": "光标点击弹跳",
"shadow": "阴影",
"roundness": "圆角",
"padding": "内边距"
},
"crop": {
"title": "裁剪视频",
"instruction": "拖动各边以调整裁剪区域"
},
"background": {
"title": "背景",
"image": "图片",
"color": "颜色",
"gradient": "渐变",
"uploadCustom": "上传自定义",
"uploadSuccess": "自定义图片上传成功!",
"uploadError": "请上传 JPG 或 JPEG 图片文件。"
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "低",
"medium": "中",
"high": "高"
},
"loop": "循环",
"outputDimensions": "输出:{{dimensions}}px",
"loadProject": "加载项目",
"saveProject": "保存项目",
"exportVideo": "导出{{format}}",
"reportBug": "报告问题",
"starOnGithub": "在 GitHub 上加星"
}
}
+16 -1
View File
@@ -1 +1,16 @@
{}
{
"actions": {
"addZoom": "添加缩放",
"addTrim": "添加修剪",
"addSpeed": "添加变速",
"addAnnotation": "添加注释",
"addKeyframe": "添加关键帧",
"deleteSelected": "删除选中",
"playPause": "播放 / 暂停",
"cycleForward": "向前循环切换注释",
"cycleBackward": "向后循环切换注释",
"deleteSelectedAlt": "删除选中(替代)",
"panTimeline": "平移时间线",
"zoomTimeline": "缩放时间线"
}
}
+38 -1
View File
@@ -1 +1,38 @@
{}
{
"zoom": {
"cannotPlace": "无法在此处放置缩放",
"existsOrNoSpace": "此位置已存在缩放或空间不足。",
"suggestHandlerUnavailable": "缩放建议处理不可用",
"noTelemetry": "无光标遥测数据",
"recordFirst": "请先录制屏幕以生成基于光标的建议。",
"noUsableTelemetry": "无可用的光标遥测数据",
"notEnoughMovement": "录制中未包含足够的光标移动数据。",
"noInteractionMoments": "未找到明显的交互时刻",
"tryRecording": "尝试在关键操作附近有停顿或点击的录制。",
"noAutoZoomSlots": "没有可用的自动缩放位置",
"dwellPointsOverlap": "检测到的停留点与现有缩放区域重叠。",
"addedSuggestions": "已添加 {{count}} 个基于交互的缩放建议",
"label": "缩放 {{index}}",
"addZoom": "添加缩放 (Z)",
"suggestZooms": "从光标建议缩放"
},
"trim": {
"cannotPlace": "无法在此处放置修剪",
"existsOrNoSpace": "此位置已存在修剪或空间不足。",
"label": "修剪 {{index}}",
"addTrim": "添加修剪 (T)"
},
"speed": {
"cannotPlace": "无法在此处放置变速",
"existsOrNoSpace": "此位置已存在变速区域或空间不足。",
"label": "变速"
},
"annotation": {
"label": "注释",
"image": "图片",
"addAnnotation": "添加注释 (A)"
},
"addSpeed": "添加变速 (S)",
"resizeLeft": "向左调整",
"resizeRight": "向右调整"
}