mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user