From d8871d9228658a0187105e0d1df30e7de6f6da3f Mon Sep 17 00:00:00 2001 From: JH Date: Fri, 20 Mar 2026 16:25:19 +0900 Subject: [PATCH 1/4] fix: pan timeline when dragging playhead to edges --- .../video-editor/timeline/TimelineEditor.tsx | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index ab177a70..75e82ec8 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -169,6 +169,21 @@ function createInitialRange(totalMs: number): Range { return { start: 0, end: FALLBACK_RANGE_MS }; } +function clampVisibleRange(candidate: Range, totalMs: number): Range { + if (totalMs <= 0) { + return candidate; + } + + const span = Math.max(candidate.end - candidate.start, 1); + + if (span >= totalMs) { + return { start: 0, end: totalMs }; + } + + const start = Math.max(0, Math.min(candidate.start, totalMs - span)); + return { start, end: start + span }; +} + function formatTimeLabel(milliseconds: number, intervalMs: number) { const totalSeconds = milliseconds / 1000; const hours = Math.floor(totalSeconds / 3600); @@ -204,18 +219,21 @@ function PlaybackCursor({ currentTimeMs, videoDurationMs, onSeek, + onRangeChange, timelineRef, keyframes = [], }: { currentTimeMs: number; videoDurationMs: number; onSeek?: (time: number) => void; + onRangeChange?: (updater: (previous: Range) => Range) => void; timelineRef: React.RefObject; keyframes?: { id: string; time: number }[]; }) { const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; const [isDragging, setIsDragging] = useState(false); + const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState(null); useEffect(() => { if (!isDragging) return; @@ -225,6 +243,7 @@ function PlaybackCursor({ const rect = timelineRef.current.getBoundingClientRect(); const clickX = e.clientX - rect.left - sidebarWidth; + const contentWidth = Math.max(rect.width - sidebarWidth, 1); // Allow dragging outside to 0 or max, but clamp the value const relativeMs = pixelsToValue(clickX); @@ -243,11 +262,51 @@ function PlaybackCursor({ absoluteMs = nearbyKeyframe.time; } + setDragPreviewTimeMs(absoluteMs); + + const visibleMs = range.end - range.start; + if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) { + const msPerPixel = visibleMs / contentWidth; + const overflowLeftPx = Math.max(0, -clickX); + const overflowRightPx = Math.max(0, clickX - contentWidth); + + if (overflowLeftPx > 0 && range.start > 0) { + const shiftMs = overflowLeftPx * msPerPixel; + onRangeChange((previous) => { + const nextRange = clampVisibleRange( + { + start: previous.start - shiftMs, + end: previous.end - shiftMs, + }, + videoDurationMs, + ); + return nextRange.start === previous.start && nextRange.end === previous.end + ? previous + : nextRange; + }); + } else if (overflowRightPx > 0 && range.end < videoDurationMs) { + const shiftMs = overflowRightPx * msPerPixel; + onRangeChange((previous) => { + const nextRange = clampVisibleRange( + { + start: previous.start + shiftMs, + end: previous.end + shiftMs, + }, + videoDurationMs, + ); + return nextRange.start === previous.start && nextRange.end === previous.end + ? previous + : nextRange; + }); + } + } + onSeek(absoluteMs / 1000); }; const handleMouseUp = () => { setIsDragging(false); + setDragPreviewTimeMs(null); document.body.style.cursor = ""; }; @@ -263,6 +322,7 @@ function PlaybackCursor({ }, [ isDragging, onSeek, + onRangeChange, timelineRef, sidebarWidth, range.start, @@ -272,11 +332,14 @@ function PlaybackCursor({ keyframes, ]); - if (videoDurationMs <= 0 || currentTimeMs < 0) { + const displayTimeMs = + isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs; + + if (videoDurationMs <= 0 || displayTimeMs < 0) { return null; } - const clampedTime = Math.min(currentTimeMs, videoDurationMs); + const clampedTime = Math.min(displayTimeMs, videoDurationMs); if (clampedTime < range.start || clampedTime > range.end) { return null; @@ -299,6 +362,7 @@ function PlaybackCursor({ }} onMouseDown={(e) => { e.stopPropagation(); // Prevent timeline click + setDragPreviewTimeMs(currentTimeMs); setIsDragging(true); }} > @@ -444,6 +508,7 @@ function Timeline({ videoDurationMs, currentTimeMs, onSeek, + onRangeChange, onSelectZoom, onSelectTrim, onSelectAnnotation, @@ -458,6 +523,7 @@ function Timeline({ videoDurationMs: number; currentTimeMs: number; onSeek?: (time: number) => void; + onRangeChange?: (updater: (previous: Range) => Range) => void; onSelectZoom?: (id: string | null) => void; onSelectTrim?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; @@ -532,6 +598,7 @@ function Timeline({ currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} onSeek={onSeek} + onRangeChange={onRangeChange} timelineRef={localTimelineRef} keyframes={keyframes} /> @@ -1351,6 +1418,7 @@ export default function TimelineEditor({ videoDurationMs={totalMs} currentTimeMs={currentTimeMs} onSeek={onSeek} + onRangeChange={setRange} onSelectZoom={onSelectZoom} onSelectTrim={onSelectTrim} onSelectAnnotation={onSelectAnnotation} From 203282be43beaa15266fe863705feeb4b2730774 Mon Sep 17 00:00:00 2001 From: JH Date: Fri, 20 Mar 2026 16:52:16 +0900 Subject: [PATCH 2/4] fix: pan timeline on row scroll --- .../video-editor/timeline/TimelineEditor.tsx | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 75e82ec8..4e0181bf 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -184,6 +184,18 @@ function clampVisibleRange(candidate: Range, totalMs: number): Range { return { start, end: start + span }; } +function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number { + if (deltaMode === WheelEvent.DOM_DELTA_LINE) { + return delta * 16; + } + + if (deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return delta * pageSizePx; + } + + return delta; +} + function formatTimeLabel(milliseconds: number, intervalMs: number) { const totalSeconds = milliseconds / 1000; const hours = Math.floor(totalSeconds / 3600); @@ -580,6 +592,46 @@ function Timeline({ ], ); + const handleTimelineWheel = useCallback( + (event: React.WheelEvent) => { + if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) { + return; + } + + const visibleMs = range.end - range.start; + if (visibleMs <= 0 || videoDurationMs <= visibleMs) { + return; + } + + const dominantDelta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (dominantDelta === 0) { + return; + } + + event.preventDefault(); + + const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1); + const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx); + const shiftMs = pixelsToValue(normalizedDeltaPx); + + onRangeChange((previous) => { + const nextRange = clampVisibleRange( + { + start: previous.start + shiftMs, + end: previous.end + shiftMs, + }, + videoDurationMs, + ); + + return nextRange.start === previous.start && nextRange.end === previous.end + ? previous + : nextRange; + }); + }, + [onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue], + ); + const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID); const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID); const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID); @@ -591,6 +643,7 @@ function Timeline({ style={style} className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group" onClick={handleTimelineClick} + onWheel={handleTimelineWheel} >
@@ -724,17 +777,15 @@ export default function TimelineEditor({ const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); const [scrollLabels, setScrollLabels] = useState({ - pan: "Shift + Ctrl + Scroll", + pan: "Scroll", zoom: "Ctrl + Scroll", }); const timelineContainerRef = useRef(null); const { shortcuts: keyShortcuts, isMac } = useShortcuts(); useEffect(() => { - formatShortcut(["shift", "mod", "Scroll"]).then((pan) => { - formatShortcut(["mod", "Scroll"]).then((zoom) => { - setScrollLabels({ pan, zoom }); - }); + formatShortcut(["mod", "Scroll"]).then((zoom) => { + setScrollLabels({ pan: "Scroll", zoom }); }); }, []); From c322825969b7d834b3fb1023a400c9580c3754a7 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 21 Mar 2026 17:06:25 -0700 Subject: [PATCH 3/4] feat(export): allow re-saving exported video on dialog cancel --- src/components/video-editor/SettingsPanel.tsx | 15 +++++++++ src/components/video-editor/VideoEditor.tsx | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 3d546d0b..aa26039b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -130,6 +130,8 @@ interface SettingsPanelProps { onSaveProject?: () => void; onLoadProject?: () => void; onExport?: () => void; + unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null; + onSaveUnsavedExport?: () => void; selectedAnnotationId?: string | null; annotationRegions?: AnnotationRegion[]; onAnnotationContentChange?: (id: string, content: string) => void; @@ -198,6 +200,8 @@ export function SettingsPanel({ onSaveProject, onLoadProject, onExport, + unsavedExport, + onSaveUnsavedExport, selectedAnnotationId, annotationRegions = [], onAnnotationContentChange, @@ -1150,6 +1154,17 @@ export function SettingsPanel({
+ {unsavedExport && ( + + )} - diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 0d94efac..5768c3ac 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { MdCheck } from "react-icons/md"; +import { useScopedT } from "@/contexts/I18nContext"; import { Button } from "../ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import styles from "./SourceSelector.module.css"; @@ -13,6 +14,8 @@ interface DesktopSource { } export function SourceSelector() { + const t = useScopedT("launch"); + const tc = useScopedT("common"); const [sources, setSources] = useState([]); const [selectedSource, setSelectedSource] = useState(null); const [loading, setLoading] = useState(true); @@ -63,7 +66,7 @@ export function SourceSelector() { >
-

Loading sources...

+

{t("sourceSelector.loading")}

); @@ -113,13 +116,13 @@ export function SourceSelector() { value="screens" className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all" > - Screens ({screenSources.length}) + {t("sourceSelector.screens", { count: String(screenSources.length) })} - Windows ({windowSources.length}) + {t("sourceSelector.windows", { count: String(windowSources.length) })}
@@ -146,14 +149,14 @@ export function SourceSelector() { onClick={() => window.close()} className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full" > - Cancel + {tc("actions.cancel")}
diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx index b1a321fb..9ab9ce3d 100644 --- a/src/components/video-editor/AddCustomFontDialog.tsx +++ b/src/components/video-editor/AddCustomFontDialog.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useScopedT } from "@/contexts/I18nContext"; import { addCustomFont, type CustomFont, @@ -25,6 +26,8 @@ interface AddCustomFontDialogProps { } export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { + const t = useScopedT("settings"); + const tc = useScopedT("common"); const [open, setOpen] = useState(false); const [importUrl, setImportUrl] = useState(""); const [fontName, setFontName] = useState(""); @@ -45,17 +48,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("customFont.errorEmptyUrl")); return; } if (!isValidGoogleFontsUrl(importUrl)) { - toast.error("Please enter a valid Google Fonts URL"); + toast.error(t("customFont.errorInvalidUrl")); return; } if (!fontName.trim()) { - toast.error("Please enter a font name"); + toast.error(t("customFont.errorEmptyName")); return; } @@ -65,7 +68,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("customFont.errorExtractFailed")); setLoading(false); return; } @@ -86,7 +89,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { onFontAdded(newFont); } - toast.success(`Font "${fontName}" added successfully`); + toast.success(t("customFont.successMessage", { fontName })); // Reset and close setImportUrl(""); @@ -95,10 +98,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("customFont.failedToAdd"), { 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("customFont.errorTimeout") + : t("customFont.errorLoadFailed"), }); } finally { setLoading(false); @@ -114,12 +117,12 @@ 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" > - Add Google Font + {t("customFont.dialogTitle")} - Add Google Font + {t("customFont.dialogTitle")} Add a custom font from Google Fonts to use in your annotations. @@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
handleImportUrlChange(e.target.value)} className="bg-white/5 border-white/10 text-slate-200" /> -

- Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL -

+

{t("customFont.urlHelp")}

setFontName(e.target.value)} className="bg-white/5 border-white/10 text-slate-200" /> -

- This is how the font will appear in the font selector -

+

{t("customFont.nameHelp")}

@@ -164,14 +163,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { onClick={() => setOpen(false)} className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10" > - Cancel + {tc("actions.cancel")}
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 94f31edb..b289392e 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -27,6 +27,7 @@ import { import { Slider } from "@/components/ui/slider"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useScopedT } from "@/contexts/I18nContext"; import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; import { cn } from "@/lib/utils"; import { AddCustomFontDialog } from "./AddCustomFontDialog"; @@ -43,14 +44,14 @@ interface AnnotationSettingsPanelProps { } 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" }, + { value: "system-ui, -apple-system, sans-serif", labelKey: "classic" }, + { value: "Georgia, serif", labelKey: "editor" }, + { value: "Impact, Arial Black, sans-serif", labelKey: "strong" }, + { value: "Courier New, monospace", labelKey: "typewriter" }, + { value: "Brush Script MT, cursive", labelKey: "deco" }, + { value: "Arial, sans-serif", labelKey: "simple" }, + { value: "Verdana, sans-serif", labelKey: "modern" }, + { value: "Trebuchet MS, sans-serif", labelKey: "clean" }, ]; const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128]; @@ -63,9 +64,21 @@ export function AnnotationSettingsPanel({ onFigureDataChange, onDelete, }: AnnotationSettingsPanelProps) { + const t = useScopedT("settings"); const fileInputRef = useRef(null); const [customFonts, setCustomFonts] = useState([]); + const fontStyleLabels: Record = { + classic: t("fontStyles.classic"), + editor: t("fontStyles.editor"), + strong: t("fontStyles.strong"), + typewriter: t("fontStyles.typewriter"), + deco: t("fontStyles.deco"), + simple: t("fontStyles.simple"), + modern: t("fontStyles.modern"), + clean: t("fontStyles.clean"), + }; + // Load custom fonts on mount useEffect(() => { setCustomFonts(getCustomFonts()); @@ -99,8 +112,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("annotation.invalidImageType"), { + description: t("annotation.imageFormatsOnly"), }); event.target.value = ""; return; @@ -112,12 +125,12 @@ export function AnnotationSettingsPanel({ const dataUrl = e.target?.result as string; if (dataUrl) { onContentChange(dataUrl); - toast.success("Image uploaded successfully!"); + toast.success(t("annotation.imageUploadSuccess")); } }; reader.onerror = () => { - toast.error("Failed to upload image", { + toast.error(t("annotation.failedImageUpload"), { description: "There was an error reading the file.", }); }; @@ -130,9 +143,9 @@ export function AnnotationSettingsPanel({
- Annotation Settings + {t("annotation.title")} - Active + {t("annotation.active")}
@@ -148,14 +161,14 @@ export function AnnotationSettingsPanel({ className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2" > - Text + {t("annotation.typeText")} - Image + {t("annotation.typeImage")} - Arrow + {t("annotation.typeArrow")} {/* Text Content */}
- +