feat(editor): remember aspect ratio preferences

Keep the last selected aspect ratio and custom X:Y values between editing sessions so the timeline opens with the user's preferred crop setup.
This commit is contained in:
KBCats
2026-03-15 18:27:49 -07:00
parent 7d22c49df5
commit 85e2b4394f
4 changed files with 195 additions and 4 deletions
+10 -2
View File
@@ -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<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(
@@ -182,7 +184,9 @@ export default function VideoEditor() {
);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>("16:9");
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(
initialEditorPreferences.aspectRatio,
);
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(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,
],
);
@@ -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<string, string> = {}): 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",
});
});
});
@@ -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<EditorPreferences>) : {};
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<EditorPreferences>): 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.
}
}
@@ -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<Range>(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(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);