diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ab0df9cd..aa6c58cd 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,6 +31,7 @@ import { getAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; +import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import PlaybackControls from "./PlaybackControls"; import { createProjectData, @@ -128,6 +129,7 @@ function LanguageSwitcher() { export default function VideoEditor() { const { t } = useI18n(); + const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState( @@ -182,7 +184,9 @@ export default function VideoEditor() { ); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); - const [aspectRatio, setAspectRatio] = useState("16:9"); + const [aspectRatio, setAspectRatio] = useState( + initialEditorPreferences.aspectRatio, + ); const [exportQuality, setExportQuality] = useState("good"); const [exportFormat, setExportFormat] = useState("mp4"); const [gifFrameRate, setGifFrameRate] = useState(15); @@ -536,6 +540,10 @@ export default function VideoEditor() { loadInitialData(); }, [applyLoadedProject]); + useEffect(() => { + saveEditorPreferences({ aspectRatio }); + }, [aspectRatio]); + const saveProject = useCallback( async (forceSaveAs: boolean) => { if (!videoPath) { @@ -1705,7 +1713,6 @@ export default function VideoEditor() { [ videoPath, wallpaper, - zoomRegions, trimRegions, speedRegions, shadowIntensity, @@ -1726,6 +1733,7 @@ export default function VideoEditor() { isPlaying, aspectRatio, exportQuality, + effectiveZoomRegions, showExportSuccessToast, ], ); diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts new file mode 100644 index 00000000..192c9249 --- /dev/null +++ b/src/components/video-editor/editorPreferences.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_EDITOR_PREFERENCES, + EDITOR_PREFERENCES_STORAGE_KEY, + loadEditorPreferences, + normalizeEditorPreferences, + saveEditorPreferences, +} from "./editorPreferences"; + +function createStorageMock(initialValues: Record = {}): Storage { + const store = new Map(Object.entries(initialValues)); + + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key) { + return store.get(key) ?? null; + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key) { + store.delete(key); + }, + setItem(key, value) { + store.set(key, value); + }, + }; +} + +describe("editorPreferences", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("normalizes invalid values back to safe defaults", () => { + expect( + normalizeEditorPreferences({ + aspectRatio: "bad-value", + customAspectWidth: "0", + customAspectHeight: "", + }), + ).toEqual(DEFAULT_EDITOR_PREFERENCES); + }); + + it("loads stored aspect ratio preferences", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + aspectRatio: "native", + customAspectWidth: "21", + customAspectHeight: "9", + }), + }), + ); + + expect(loadEditorPreferences()).toEqual({ + aspectRatio: "native", + customAspectWidth: "21", + customAspectHeight: "9", + }); + }); + + it("preserves the last valid custom aspect inputs while typing", () => { + const localStorage = createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + aspectRatio: "16:9", + customAspectWidth: "21", + customAspectHeight: "9", + }), + }); + vi.stubGlobal("localStorage", localStorage); + + saveEditorPreferences({ customAspectWidth: "", customAspectHeight: "abc" }); + + expect(loadEditorPreferences()).toEqual({ + aspectRatio: "16:9", + customAspectWidth: "21", + customAspectHeight: "9", + }); + }); +}); diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts new file mode 100644 index 00000000..2a3bf126 --- /dev/null +++ b/src/components/video-editor/editorPreferences.ts @@ -0,0 +1,86 @@ +import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; + +export interface EditorPreferences { + aspectRatio: AspectRatio; + customAspectWidth: string; + customAspectHeight: string; +} + +export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences"; + +export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { + aspectRatio: "16:9", + customAspectWidth: "16", + customAspectHeight: "9", +}; + +function isStoredAspectRatio(value: unknown): value is AspectRatio { + return ( + typeof value === "string" && + ((ASPECT_RATIOS as readonly string[]).includes(value) || isCustomAspectRatio(value)) + ); +} + +function normalizePositiveIntegerString(value: unknown, fallback: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return String(parsed); +} + +export function normalizeEditorPreferences( + candidate: unknown, + fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES, +): EditorPreferences { + const raw = + candidate && typeof candidate === "object" ? (candidate as Partial) : {}; + + return { + aspectRatio: isStoredAspectRatio(raw.aspectRatio) ? raw.aspectRatio : fallback.aspectRatio, + customAspectWidth: normalizePositiveIntegerString( + raw.customAspectWidth, + fallback.customAspectWidth, + ), + customAspectHeight: normalizePositiveIntegerString( + raw.customAspectHeight, + fallback.customAspectHeight, + ), + }; +} + +export function loadEditorPreferences(): EditorPreferences { + if (typeof globalThis.localStorage === "undefined") { + return DEFAULT_EDITOR_PREFERENCES; + } + + try { + const stored = globalThis.localStorage.getItem(EDITOR_PREFERENCES_STORAGE_KEY); + if (!stored) { + return DEFAULT_EDITOR_PREFERENCES; + } + + return normalizeEditorPreferences(JSON.parse(stored)); + } catch { + return DEFAULT_EDITOR_PREFERENCES; + } +} + +export function saveEditorPreferences(preferences: Partial): void { + if (typeof globalThis.localStorage === "undefined") { + return; + } + + try { + const current = loadEditorPreferences(); + const merged = normalizeEditorPreferences({ ...current, ...preferences }, current); + globalThis.localStorage.setItem(EDITOR_PREFERENCES_STORAGE_KEY, JSON.stringify(merged)); + } catch { + // Ignore storage failures so editor controls still work. + } +} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 13ce0719..9ac424f4 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -25,6 +25,7 @@ import { matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; +import { loadEditorPreferences, saveEditorPreferences } from "../editorPreferences"; import { TutorialHelp } from "../TutorialHelp"; import type { AnnotationRegion, @@ -645,6 +646,7 @@ export default function TimelineEditor({ aspectRatio, onAspectRatioChange, }: TimelineEditorProps) { + const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]); const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]); @@ -656,8 +658,8 @@ export default function TimelineEditor({ const [range, setRange] = useState(() => createInitialRange(totalMs)); const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); - const [customAspectWidth, setCustomAspectWidth] = useState('16'); - const [customAspectHeight, setCustomAspectHeight] = useState('9'); + const [customAspectWidth, setCustomAspectWidth] = useState(initialEditorPreferences.customAspectWidth); + const [customAspectHeight, setCustomAspectHeight] = useState(initialEditorPreferences.customAspectHeight); const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' @@ -676,6 +678,13 @@ export default function TimelineEditor({ } }, [aspectRatio]); + useEffect(() => { + saveEditorPreferences({ + customAspectWidth, + customAspectHeight, + }); + }, [customAspectHeight, customAspectWidth]); + const applyCustomAspectRatio = useCallback(() => { const width = Number.parseInt(customAspectWidth, 10); const height = Number.parseInt(customAspectHeight, 10);