feat(editor): persist full editor preferences

Keep the editor's background, effects, crop, cursor, and export controls between sessions so the workspace reopens with the user's last setup.
This commit is contained in:
KBCats
2026-03-15 18:51:27 -07:00
parent 85e2b4394f
commit 43a9ec5701
4 changed files with 594 additions and 384 deletions
+64 -8
View File
@@ -14,7 +14,7 @@ import {
Upload,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
@@ -34,12 +34,14 @@ import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { useI18n, useScopedT } from "../../contexts/I18nContext";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CropControl } from "./CropControl";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { SliderControl } from "./SliderControl";
import type {
AnnotationRegion,
AnnotationType,
CropRegion,
FigureData,
PlaybackSpeed,
ZoomDepth,
} from "./types";
@@ -81,6 +83,24 @@ const GRADIENTS = [
"linear-gradient(to right, #0acffe 0%, #495aff 100%)",
];
type BackgroundTab = "image" | "color" | "gradient";
function isHexWallpaper(value: string): boolean {
return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value);
}
function getBackgroundTabForWallpaper(value: string): BackgroundTab {
if (GRADIENTS.includes(value)) {
return "gradient";
}
if (isHexWallpaper(value)) {
return "color";
}
return "image";
}
interface SettingsPanelProps {
selected: string;
onWallpaperChange: (path: string) => void;
@@ -140,7 +160,7 @@ interface SettingsPanelProps {
onAnnotationContentChange?: (id: string, content: string) => void;
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
@@ -226,8 +246,11 @@ export function SettingsPanel({
}: SettingsPanelProps) {
const tSettings = useScopedT("settings");
const { t } = useI18n();
const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>(
initialEditorPreferences.customWallpapers,
);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -240,7 +263,7 @@ export function SettingsPanel({
),
);
if (mounted) setWallpaperPreviewPaths(resolved);
} catch (err) {
} catch {
if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS);
}
})();
@@ -267,11 +290,38 @@ export function SettingsPanel({
"#795548",
];
const [selectedColor, setSelectedColor] = useState("#ADADAD");
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [selectedColor, setSelectedColor] = useState(
isHexWallpaper(selected) ? selected : "#ADADAD",
);
const [gradient, setGradient] = useState<string>(
GRADIENTS.includes(selected) ? selected : GRADIENTS[0],
);
const [backgroundTab, setBackgroundTab] = useState<BackgroundTab>(() =>
getBackgroundTabForWallpaper(selected),
);
const [showCropModal, setShowCropModal] = useState(false);
const cropSnapshotRef = useRef<CropRegion | null>(null);
useEffect(() => {
setBackgroundTab(getBackgroundTabForWallpaper(selected));
if (isHexWallpaper(selected)) {
setSelectedColor(selected);
}
if (GRADIENTS.includes(selected)) {
setGradient(selected);
}
if (selected.startsWith("data:image") && !customImages.includes(selected)) {
setCustomImages((prev) => [selected, ...prev]);
}
}, [customImages, selected]);
useEffect(() => {
saveEditorPreferences({ customWallpapers: customImages });
}, [customImages]);
const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
@@ -718,7 +768,11 @@ export function SettingsPanel({
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<Tabs defaultValue="image" className="w-full">
<Tabs
value={backgroundTab}
onValueChange={(value) => setBackgroundTab(value as BackgroundTab)}
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"
@@ -804,7 +858,9 @@ export function SettingsPanel({
if (clean(wallpaperValue).endsWith(clean(selected))) return true;
if (clean(selected).endsWith(clean(previewPath))) return true;
if (clean(previewPath).endsWith(clean(selected))) return true;
} catch {}
} catch {
return false;
}
return false;
})();
return (
+260 -363
View File
@@ -5,9 +5,9 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useI18n } from "@/contexts/I18nContext";
import { SUPPORTED_LOCALES } from "@/i18n/config";
import type { AppLocale } from "@/i18n/config";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import type { AppLocale } from "@/i18n/config";
import { SUPPORTED_LOCALES } from "@/i18n/config";
import { getAssetPath } from "@/lib/assetPath";
import {
calculateOutputDimensions,
@@ -22,14 +22,8 @@ import {
VideoExporter,
} from "@/lib/exporter";
import { matchesShortcut } from "@/lib/shortcuts";
import {
DEFAULT_WALLPAPER_RELATIVE_PATH,
WALLPAPER_PATHS,
} from "@/lib/wallpapers";
import {
type AspectRatio,
getAspectRatioValue,
} from "@/utils/aspectRatioUtils";
import { DEFAULT_WALLPAPER_RELATIVE_PATH } from "@/lib/wallpapers";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import PlaybackControls from "./PlaybackControls";
@@ -55,16 +49,9 @@ import {
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_CURSOR_SWAY,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_MOTION_BLUR,
type FigureData,
type PlaybackSpeed,
type SpeedRegion,
@@ -100,12 +87,8 @@ type PendingExportSave = {
function LanguageSwitcher() {
const { locale, setLocale, t } = useI18n();
const idx = SUPPORTED_LOCALES.indexOf(
locale as (typeof SUPPORTED_LOCALES)[number],
);
const next = SUPPORTED_LOCALES[
(idx + 1) % SUPPORTED_LOCALES.length
] as AppLocale;
const idx = SUPPORTED_LOCALES.indexOf(locale as (typeof SUPPORTED_LOCALES)[number]);
const next = SUPPORTED_LOCALES[(idx + 1) % SUPPORTED_LOCALES.length] as AppLocale;
const labels: Record<string, string> = {
en: "EN",
es: "ES",
@@ -120,9 +103,7 @@ function LanguageSwitcher() {
aria-label={t("common.app.language", "Language")}
>
<Languages className="h-4 w-4" />
<span className="text-xs font-normal">
{labels[locale] ?? locale.toUpperCase()}
</span>
<span className="text-xs font-normal">{labels[locale] ?? locale.toUpperCase()}</span>
</button>
);
}
@@ -132,73 +113,61 @@ export default function VideoEditor() {
const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(
null,
);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
const [shadowIntensity, setShadowIntensity] = useState(0.67);
const [backgroundBlur, setBackgroundBlur] = useState(0);
const [zoomMotionBlur, setZoomMotionBlur] = useState(
DEFAULT_ZOOM_MOTION_BLUR,
);
const [connectZooms, setConnectZooms] = useState(true);
const [showCursor, setShowCursor] = useState(true);
const [loopCursor, setLoopCursor] = useState(false);
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
const [cursorSmoothing, setCursorSmoothing] = useState(
DEFAULT_CURSOR_SMOOTHING,
);
const [wallpaper, setWallpaper] = useState<string>(initialEditorPreferences.wallpaper);
const [shadowIntensity, setShadowIntensity] = useState(initialEditorPreferences.shadowIntensity);
const [backgroundBlur, setBackgroundBlur] = useState(initialEditorPreferences.backgroundBlur);
const [zoomMotionBlur, setZoomMotionBlur] = useState(initialEditorPreferences.zoomMotionBlur);
const [connectZooms, setConnectZooms] = useState(initialEditorPreferences.connectZooms);
const [showCursor, setShowCursor] = useState(initialEditorPreferences.showCursor);
const [loopCursor, setLoopCursor] = useState(initialEditorPreferences.loopCursor);
const [cursorSize, setCursorSize] = useState(initialEditorPreferences.cursorSize);
const [cursorSmoothing, setCursorSmoothing] = useState(initialEditorPreferences.cursorSmoothing);
const [cursorMotionBlur, setCursorMotionBlur] = useState(
DEFAULT_CURSOR_MOTION_BLUR,
initialEditorPreferences.cursorMotionBlur,
);
const [cursorClickBounce, setCursorClickBounce] = useState(
DEFAULT_CURSOR_CLICK_BOUNCE,
initialEditorPreferences.cursorClickBounce,
);
const [cursorSway, setCursorSway] = useState(DEFAULT_CURSOR_SWAY);
const [borderRadius, setBorderRadius] = useState(12.5);
const [padding, setPadding] = useState(50);
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [cursorSway, setCursorSway] = useState(initialEditorPreferences.cursorSway);
const [borderRadius, setBorderRadius] = useState(initialEditorPreferences.borderRadius);
const [padding, setPadding] = useState(initialEditorPreferences.padding);
const [cropRegion, setCropRegion] = useState<CropRegion>(initialEditorPreferences.cropRegion);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [cursorTelemetry, setCursorTelemetry] = useState<
CursorTelemetryPoint[]
>([]);
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [speedRegions, setSpeedRegions] = useState<SpeedRegion[]>([]);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
const [annotationRegions, setAnnotationRegions] = useState<
AnnotationRegion[]
>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<
string | null
>(null);
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(
null,
);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(
initialEditorPreferences.aspectRatio,
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(initialEditorPreferences.aspectRatio);
const [exportQuality, setExportQuality] = useState<ExportQuality>(
initialEditorPreferences.exportQuality,
);
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(
undefined,
const [exportFormat, setExportFormat] = useState<ExportFormat>(
initialEditorPreferences.exportFormat,
);
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(
initialEditorPreferences.gifFrameRate,
);
const [gifLoop, setGifLoop] = useState(initialEditorPreferences.gifLoop);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(
initialEditorPreferences.gifSizePreset,
);
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(undefined);
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(
null,
);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -216,23 +185,18 @@ export default function VideoEditor() {
const applyingHistoryRef = useRef(false);
const pendingExportSaveRef = useRef<PendingExportSave | null>(null);
const cloneSnapshot = useCallback(
(snapshot: EditorHistorySnapshot): EditorHistorySnapshot => {
return {
zoomRegions: JSON.parse(JSON.stringify(snapshot.zoomRegions)),
trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)),
speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)),
annotationRegions: JSON.parse(
JSON.stringify(snapshot.annotationRegions),
),
selectedZoomId: snapshot.selectedZoomId,
selectedTrimId: snapshot.selectedTrimId,
selectedSpeedId: snapshot.selectedSpeedId,
selectedAnnotationId: snapshot.selectedAnnotationId,
};
},
[],
);
const cloneSnapshot = useCallback((snapshot: EditorHistorySnapshot): EditorHistorySnapshot => {
return {
zoomRegions: JSON.parse(JSON.stringify(snapshot.zoomRegions)),
trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)),
speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)),
annotationRegions: JSON.parse(JSON.stringify(snapshot.annotationRegions)),
selectedZoomId: snapshot.selectedZoomId,
selectedTrimId: snapshot.selectedTrimId,
selectedSpeedId: snapshot.selectedSpeedId,
selectedAnnotationId: snapshot.selectedAnnotationId,
};
}, []);
const buildHistorySnapshot = useCallback((): EditorHistorySnapshot => {
return {
@@ -286,10 +250,7 @@ export default function VideoEditor() {
cloned.annotationRegions.map((region) => region.id),
);
nextAnnotationZIndexRef.current =
cloned.annotationRegions.reduce(
(max, region) => Math.max(max, region.zIndex),
0,
) + 1;
cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1;
},
[cloneSnapshot],
);
@@ -297,8 +258,7 @@ export default function VideoEditor() {
const handleUndo = useCallback(() => {
if (historyPastRef.current.length === 0) return;
const current =
historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot());
const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot());
const previous = historyPastRef.current.pop();
if (!previous) return;
@@ -310,8 +270,7 @@ export default function VideoEditor() {
const handleRedo = useCallback(() => {
if (historyFutureRef.current.length === 0) return;
const current =
historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot());
const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot());
const next = historyFutureRef.current.pop();
if (!next) return;
@@ -320,94 +279,86 @@ export default function VideoEditor() {
applyHistorySnapshot(next);
}, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot]);
const applyLoadedProject = useCallback(
async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
return false;
}
const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
return false;
}
const project = candidate;
const sourcePath = fromFileUrl(project.videoPath);
const normalizedEditor = normalizeProjectEditor(project.editor);
const project = candidate;
const sourcePath = fromFileUrl(project.videoPath);
const normalizedEditor = normalizeProjectEditor(project.editor);
try {
videoPlaybackRef.current?.pause();
} catch {
// no-op
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
try {
videoPlaybackRef.current?.pause();
} catch {
// no-op
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(path ?? null);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(path ?? null);
setWallpaper(normalizedEditor.wallpaper);
setShadowIntensity(normalizedEditor.shadowIntensity);
setBackgroundBlur(normalizedEditor.backgroundBlur);
setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
setConnectZooms(normalizedEditor.connectZooms);
setShowCursor(normalizedEditor.showCursor);
setLoopCursor(normalizedEditor.loopCursor);
setCursorSize(normalizedEditor.cursorSize);
setCursorSmoothing(normalizedEditor.cursorSmoothing);
setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
setCursorClickBounce(normalizedEditor.cursorClickBounce);
setCursorSway(normalizedEditor.cursorSway);
setBorderRadius(normalizedEditor.borderRadius);
setPadding(normalizedEditor.padding);
setCropRegion(normalizedEditor.cropRegion);
setZoomRegions(normalizedEditor.zoomRegions);
setTrimRegions(normalizedEditor.trimRegions);
setSpeedRegions(normalizedEditor.speedRegions);
setAnnotationRegions(normalizedEditor.annotationRegions);
setAspectRatio(normalizedEditor.aspectRatio);
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setWallpaper(normalizedEditor.wallpaper);
setShadowIntensity(normalizedEditor.shadowIntensity);
setBackgroundBlur(normalizedEditor.backgroundBlur);
setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
setConnectZooms(normalizedEditor.connectZooms);
setShowCursor(normalizedEditor.showCursor);
setLoopCursor(normalizedEditor.loopCursor);
setCursorSize(normalizedEditor.cursorSize);
setCursorSmoothing(normalizedEditor.cursorSmoothing);
setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
setCursorClickBounce(normalizedEditor.cursorClickBounce);
setCursorSway(normalizedEditor.cursorSway);
setBorderRadius(normalizedEditor.borderRadius);
setPadding(normalizedEditor.padding);
setCropRegion(normalizedEditor.cropRegion);
setZoomRegions(normalizedEditor.zoomRegions);
setTrimRegions(normalizedEditor.trimRegions);
setSpeedRegions(normalizedEditor.speedRegions);
setAnnotationRegions(normalizedEditor.annotationRegions);
setAspectRatio(normalizedEditor.aspectRatio);
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
nextZoomIdRef.current = deriveNextId(
"zoom",
normalizedEditor.zoomRegions.map((region) => region.id),
);
nextTrimIdRef.current = deriveNextId(
"trim",
normalizedEditor.trimRegions.map((region) => region.id),
);
nextSpeedIdRef.current = deriveNextId(
"speed",
normalizedEditor.speedRegions.map((region) => region.id),
);
nextAnnotationIdRef.current = deriveNextId(
"annotation",
normalizedEditor.annotationRegions.map((region) => region.id),
);
nextAnnotationZIndexRef.current =
normalizedEditor.annotationRegions.reduce(
(max, region) => Math.max(max, region.zIndex),
0,
) + 1;
nextZoomIdRef.current = deriveNextId(
"zoom",
normalizedEditor.zoomRegions.map((region) => region.id),
);
nextTrimIdRef.current = deriveNextId(
"trim",
normalizedEditor.trimRegions.map((region) => region.id),
);
nextSpeedIdRef.current = deriveNextId(
"speed",
normalizedEditor.speedRegions.map((region) => region.id),
);
nextAnnotationIdRef.current = deriveNextId(
"annotation",
normalizedEditor.annotationRegions.map((region) => region.id),
);
nextAnnotationZIndexRef.current =
normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) +
1;
setLastSavedSnapshot(
JSON.stringify(createProjectData(sourcePath, normalizedEditor)),
);
return true;
},
[],
);
setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor)));
return true;
}, []);
const currentProjectSnapshot = useMemo(() => {
const sourcePath =
videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!sourcePath) {
return null;
}
@@ -500,16 +451,15 @@ export default function VideoEditor() {
const hasUnsavedChanges = Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
currentProjectSnapshot !== lastSavedSnapshot,
currentProjectSnapshot &&
lastSavedSnapshot &&
currentProjectSnapshot !== lastSavedSnapshot,
);
useEffect(() => {
async function loadInitialData() {
try {
const currentProjectResult =
await window.electronAPI.loadCurrentProjectFile();
const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (currentProjectResult.success && currentProjectResult.project) {
const restored = await applyLoadedProject(
currentProjectResult.project,
@@ -541,8 +491,52 @@ export default function VideoEditor() {
}, [applyLoadedProject]);
useEffect(() => {
saveEditorPreferences({ aspectRatio });
}, [aspectRatio]);
saveEditorPreferences({
wallpaper,
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
connectZooms,
showCursor,
loopCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorSway,
borderRadius,
padding,
cropRegion,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
});
}, [
wallpaper,
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
connectZooms,
showCursor,
loopCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorSway,
borderRadius,
padding,
cropRegion,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
@@ -692,10 +686,7 @@ export default function VideoEditor() {
return;
}
const restored = await applyLoadedProject(
result.project,
result.path ?? null,
);
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error("Invalid project file format");
return;
@@ -705,12 +696,9 @@ export default function VideoEditor() {
}, [applyLoadedProject]);
useEffect(() => {
const removeLoadListener =
window.electronAPI.onMenuLoadProject(handleLoadProject);
const removeSaveListener =
window.electronAPI.onMenuSaveProject(handleSaveProject);
const removeSaveAsListener =
window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs);
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject);
const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs);
return () => {
removeLoadListener?.();
@@ -731,9 +719,7 @@ export default function VideoEditor() {
}
try {
const result = await window.electronAPI.getCursorTelemetry(
fromFileUrl(videoPath),
);
const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath));
if (mounted) {
setCursorTelemetry(result.success ? result.samples : []);
}
@@ -797,11 +783,9 @@ export default function VideoEditor() {
return zoomRegions;
}
const dominantAtStart = findDominantRegion(
zoomRegions,
displayedTimelineWindow.startMs,
{ connectZooms },
).region;
const dominantAtStart = findDominantRegion(zoomRegions, displayedTimelineWindow.startMs, {
connectZooms,
}).region;
if (!dominantAtStart) {
return zoomRegions;
}
@@ -821,10 +805,7 @@ export default function VideoEditor() {
},
};
return [
...zoomRegions.filter((region) => region.id !== loopEndRegion.id),
loopEndRegion,
];
return [...zoomRegions.filter((region) => region.id !== loopEndRegion.id), loopEndRegion];
}, [loopCursor, zoomRegions, displayedTimelineWindow, connectZooms]);
useEffect(() => {
@@ -854,9 +835,7 @@ export default function VideoEditor() {
const DEFAULT_DURATION_MS = 1100;
const MIN_SPACING_MS = 1800;
const sortedCandidates = [...candidates].sort(
(a, b) => b.strength - a.strength,
);
const sortedCandidates = [...candidates].sort((a, b) => b.strength - a.strength);
const acceptedCenters: number[] = [];
setZoomRegions((prev) => {
@@ -870,25 +849,17 @@ export default function VideoEditor() {
sortedCandidates.forEach((candidate) => {
const tooCloseToAccepted = acceptedCenters.some(
(center) =>
Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS,
(center) => Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS,
);
if (tooCloseToAccepted) {
return;
}
const centeredStart = Math.round(
candidate.centerTimeMs - DEFAULT_DURATION_MS / 2,
);
const startMs = Math.max(
0,
Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS),
);
const centeredStart = Math.round(candidate.centerTimeMs - DEFAULT_DURATION_MS / 2);
const startMs = Math.max(0, Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS));
const endMs = Math.min(totalMs, startMs + DEFAULT_DURATION_MS);
const hasOverlap = reservedSpans.some(
(span) => endMs > span.start && startMs < span.end,
);
const hasOverlap = reservedSpans.some((span) => endMs > span.start && startMs < span.end);
if (hasOverlap) {
return;
}
@@ -920,9 +891,7 @@ export default function VideoEditor() {
let mounted = true;
(async () => {
try {
const resolvedPath = await getAssetPath(
DEFAULT_WALLPAPER_RELATIVE_PATH,
);
const resolvedPath = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH);
if (mounted) {
setWallpaper(resolvedPath);
}
@@ -1149,9 +1118,7 @@ export default function VideoEditor() {
(speed: PlaybackSpeed) => {
if (!selectedSpeedId) return;
setSpeedRegions((prev) =>
prev.map((region) =>
region.id === selectedSpeedId ? { ...region, speed } : region,
),
prev.map((region) => (region.id === selectedSpeedId ? { ...region, speed } : region)),
);
},
[selectedSpeedId],
@@ -1201,85 +1168,70 @@ export default function VideoEditor() {
[selectedAnnotationId],
);
const handleAnnotationContentChange = useCallback(
(id: string, content: string) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
// Store content in type-specific fields
if (region.type === "text") {
return { ...region, content, textContent: content };
} else if (region.type === "image") {
return { ...region, content, imageContent: content };
} else {
return { ...region, content };
}
});
return updated;
// Store content in type-specific fields
if (region.type === "text") {
return { ...region, content, textContent: content };
} else if (region.type === "image") {
return { ...region, content, imageContent: content };
} else {
return { ...region, content };
}
});
},
[],
);
return updated;
});
}, []);
const handleAnnotationTypeChange = useCallback(
(id: string, type: AnnotationRegion["type"]) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion["type"]) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
const updatedRegion = { ...region, type };
const updatedRegion = { ...region, type };
// Restore content from type-specific storage
if (type === "text") {
updatedRegion.content = region.textContent || "Enter text...";
} else if (type === "image") {
updatedRegion.content = region.imageContent || "";
} else if (type === "figure") {
updatedRegion.content = "";
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
// Restore content from type-specific storage
if (type === "text") {
updatedRegion.content = region.textContent || "Enter text...";
} else if (type === "image") {
updatedRegion.content = region.imageContent || "";
} else if (type === "figure") {
updatedRegion.content = "";
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
}
return updatedRegion;
});
return updated;
return updatedRegion;
});
},
[],
);
return updated;
});
}, []);
const handleAnnotationStyleChange = useCallback(
(id: string, style: Partial<AnnotationRegion["style"]>) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, style: { ...region.style, ...style } }
: region,
region.id === id ? { ...region, style: { ...region.style, ...style } } : region,
),
);
},
[],
);
const handleAnnotationFigureDataChange = useCallback(
(id: string, figureData: FigureData) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id ? { ...region, figureData } : region,
),
);
},
[],
);
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
setAnnotationRegions((prev) =>
prev.map((region) => (region.id === id ? { ...region, figureData } : region)),
);
}, []);
const handleAnnotationPositionChange = useCallback(
(id: string, position: { x: number; y: number }) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id ? { ...region, position } : region,
),
prev.map((region) => (region.id === id ? { ...region, position } : region)),
);
},
[],
@@ -1353,24 +1305,17 @@ export default function VideoEditor() {
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () =>
window.removeEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [shortcuts, isMac, handleUndo, handleRedo]);
useEffect(() => {
if (
selectedZoomId &&
!zoomRegions.some((region) => region.id === selectedZoomId)
) {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
setSelectedZoomId(null);
}
}, [selectedZoomId, zoomRegions]);
useEffect(() => {
if (
selectedTrimId &&
!trimRegions.some((region) => region.id === selectedTrimId)
) {
if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) {
setSelectedTrimId(null);
}
}, [selectedTrimId, trimRegions]);
@@ -1385,10 +1330,7 @@ export default function VideoEditor() {
}, [selectedAnnotationId, annotationRegions]);
useEffect(() => {
if (
selectedSpeedId &&
!speedRegions.some((region) => region.id === selectedSpeedId)
) {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
setSelectedSpeedId(null);
}
}, [selectedSpeedId, speedRegions]);
@@ -1402,9 +1344,7 @@ export default function VideoEditor() {
const result = await window.electronAPI.revealInFolder(filePath);
if (!result.success) {
const errorMessage =
result.error ||
result.message ||
"Failed to reveal item in folder.";
result.error || result.message || "Failed to reveal item in folder.";
toast.error(errorMessage);
}
} catch (err) {
@@ -1445,12 +1385,8 @@ export default function VideoEditor() {
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const sourceAspectRatio =
sourceHeight > 0 ? sourceWidth / sourceHeight : 16 / 9;
const aspectRatioValue = getAspectRatioValue(
aspectRatio,
sourceAspectRatio,
);
const sourceAspectRatio = sourceHeight > 0 ? sourceWidth / sourceHeight : 16 / 9;
const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio);
// Get preview CONTAINER dimensions for scaling
const playbackRef = videoPlaybackRef.current;
@@ -1503,10 +1439,7 @@ export default function VideoEditor() {
const timestamp = Date.now();
const fileName = `export-${timestamp}.gif`;
const saveResult = await window.electronAPI.saveExportedVideo(
arrayBuffer,
fileName,
);
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
pendingExportSaveRef.current = { arrayBuffer, fileName };
@@ -1514,9 +1447,7 @@ export default function VideoEditor() {
setExportError(
"Save dialog canceled. Click Save Again to save without re-rendering.",
);
toast.info(
"Save canceled. You can save again without re-exporting.",
);
toast.info("Save canceled. You can save again without re-exporting.");
keepExportDialogOpen = true;
} else if (saveResult.success && saveResult.path) {
showExportSuccessToast(saveResult.path);
@@ -1546,8 +1477,7 @@ export default function VideoEditor() {
exportHeight = Math.floor(sourceHeight / 2) * 2;
} else if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension =
Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
@@ -1556,10 +1486,7 @@ export default function VideoEditor() {
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (
h % 2 === 0 &&
Math.abs(w / h - aspectRatioValue) < 0.0001
) {
if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
@@ -1575,10 +1502,7 @@ export default function VideoEditor() {
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (
w % 2 === 0 &&
Math.abs(w / h - aspectRatioValue) < 0.0001
) {
if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
@@ -1586,8 +1510,7 @@ export default function VideoEditor() {
}
if (!found) {
exportHeight = baseHeight;
exportWidth =
Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
@@ -1660,10 +1583,7 @@ export default function VideoEditor() {
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
const saveResult = await window.electronAPI.saveExportedVideo(
arrayBuffer,
fileName,
);
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
pendingExportSaveRef.current = { arrayBuffer, fileName };
@@ -1671,9 +1591,7 @@ export default function VideoEditor() {
setExportError(
"Save dialog canceled. Click Save Again to save without re-rendering.",
);
toast.info(
"Save canceled. You can save again without re-exporting.",
);
toast.info("Save canceled. You can save again without re-exporting.");
keepExportDialogOpen = true;
} else if (saveResult.success && saveResult.path) {
showExportSuccessToast(saveResult.path);
@@ -1696,8 +1614,7 @@ export default function VideoEditor() {
}
} catch (error) {
console.error("Export error:", error);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(`Export failed: ${errorMessage}`);
} finally {
@@ -1746,9 +1663,7 @@ export default function VideoEditor() {
if (hasPendingExportSave) {
setShowExportDialog(true);
setExportError(
"Save dialog canceled. Click Save Again to save without re-rendering.",
);
setExportError("Save dialog canceled. Click Save Again to save without re-rendering.");
return;
}
@@ -1828,9 +1743,7 @@ export default function VideoEditor() {
);
if (saveResult.canceled) {
setExportError(
"Save dialog canceled. Click Save Again to save without re-rendering.",
);
setExportError("Save dialog canceled. Click Save Again to save without re-rendering.");
toast.info("Save canceled. You can try again.");
return;
}
@@ -1854,9 +1767,7 @@ export default function VideoEditor() {
try {
const result = await window.electronAPI.openRecordingsFolder();
if (!result.success) {
toast.error(
result.message || result.error || "Failed to open recordings folder.",
);
toast.error(result.message || result.error || "Failed to open recordings folder.");
}
} catch (error) {
toast.error(`Failed to open recordings folder: ${String(error)}`);
@@ -1893,9 +1804,7 @@ export default function VideoEditor() {
className="relative h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-center px-6 z-50"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<span className="text-sm font-semibold tracking-tight text-white/90">
Recordly
</span>
<span className="text-sm font-semibold tracking-tight text-white/90">Recordly</span>
<div
className="absolute right-4 flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
@@ -1906,10 +1815,7 @@ export default function VideoEditor() {
onClick={() => void openRecordingsFolder()}
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-white/90 transition hover:bg-white/8 hover:text-white cursor-pointer"
title={t("common.app.manageRecordings", "Open recordings folder")}
aria-label={t(
"common.app.manageRecordings",
"Open recordings folder",
)}
aria-label={t("common.app.manageRecordings", "Open recordings folder")}
>
<FolderOpen className="h-4 w-4" />
<span className="text-xs font-normal">
@@ -1941,9 +1847,7 @@ export default function VideoEditor() {
(() => {
const previewVideo = videoPlaybackRef.current?.video;
if (previewVideo && previewVideo.videoHeight > 0) {
return (
previewVideo.videoWidth / previewVideo.videoHeight
);
return previewVideo.videoWidth / previewVideo.videoHeight;
}
return 16 / 9;
})(),
@@ -1982,9 +1886,7 @@ export default function VideoEditor() {
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={
handleAnnotationPositionChange
}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
cursorTelemetry={effectiveCursorTelemetry}
showCursor={showCursor}
@@ -2069,13 +1971,9 @@ export default function VideoEditor() {
selected={wallpaper}
onWallpaperChange={setWallpaper}
selectedZoomDepth={
selectedZoomId
? zoomRegions.find((z) => z.id === selectedZoomId)?.depth
: null
}
onZoomDepthChange={(depth) =>
selectedZoomId && handleZoomDepthChange(depth)
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
@@ -2139,8 +2037,7 @@ export default function VideoEditor() {
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ??
null)
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
: null
}
onSpeedChange={handleSpeedChange}
@@ -41,29 +41,62 @@ describe("editorPreferences", () => {
it("normalizes invalid values back to safe defaults", () => {
expect(
normalizeEditorPreferences({
wallpaper: 123,
showCursor: "yes",
cropRegion: { x: 2, width: -1 },
aspectRatio: "bad-value",
customAspectWidth: "0",
customAspectHeight: "",
customWallpapers: "not-an-array",
}),
).toEqual(DEFAULT_EDITOR_PREFERENCES);
});
it("loads stored aspect ratio preferences", () => {
it("loads stored editor control preferences", () => {
vi.stubGlobal(
"localStorage",
createStorageMock({
[EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({
wallpaper: "#123456",
backgroundBlur: 3.5,
showCursor: false,
cropRegion: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 },
aspectRatio: "native",
exportFormat: "gif",
gifFrameRate: 30,
gifLoop: false,
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
}),
}),
);
expect(loadEditorPreferences()).toEqual({
wallpaper: "#123456",
shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity,
backgroundBlur: 3.5,
zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur,
connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms,
showCursor: false,
loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor,
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
padding: DEFAULT_EDITOR_PREFERENCES.padding,
cropRegion: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 },
aspectRatio: "native",
exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality,
exportFormat: "gif",
gifFrameRate: 30,
gifLoop: false,
gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset,
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
});
});
@@ -81,8 +114,88 @@ describe("editorPreferences", () => {
expect(loadEditorPreferences()).toEqual({
aspectRatio: "16:9",
wallpaper: DEFAULT_EDITOR_PREFERENCES.wallpaper,
shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity,
backgroundBlur: DEFAULT_EDITOR_PREFERENCES.backgroundBlur,
zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur,
connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms,
showCursor: DEFAULT_EDITOR_PREFERENCES.showCursor,
loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor,
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
padding: DEFAULT_EDITOR_PREFERENCES.padding,
cropRegion: DEFAULT_EDITOR_PREFERENCES.cropRegion,
exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality,
exportFormat: DEFAULT_EDITOR_PREFERENCES.exportFormat,
gifFrameRate: DEFAULT_EDITOR_PREFERENCES.gifFrameRate,
gifLoop: DEFAULT_EDITOR_PREFERENCES.gifLoop,
gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset,
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers,
});
});
it("saves all editor controls with normalization", () => {
const localStorage = createStorageMock();
vi.stubGlobal("localStorage", localStorage);
saveEditorPreferences({
wallpaper: "linear-gradient(to right, #000000, #ffffff)",
shadowIntensity: 0.4,
backgroundBlur: 1.5,
zoomMotionBlur: 0.75,
connectZooms: false,
showCursor: false,
loopCursor: true,
cursorSize: 3,
cursorSmoothing: 1.25,
cursorMotionBlur: 0.5,
cursorClickBounce: 2.25,
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
cropRegion: { x: 0.12, y: 0.08, width: 0.7, height: 0.65 },
aspectRatio: "4:5",
exportQuality: "source",
exportFormat: "gif",
gifFrameRate: 20,
gifLoop: false,
gifSizePreset: "large",
customAspectWidth: "4",
customAspectHeight: "5",
customWallpapers: ["data:image/jpeg;base64,abc", "data:image/jpeg;base64,abc"],
});
expect(loadEditorPreferences()).toEqual({
wallpaper: "linear-gradient(to right, #000000, #ffffff)",
shadowIntensity: 0.4,
backgroundBlur: 1.5,
zoomMotionBlur: 0.75,
connectZooms: false,
showCursor: false,
loopCursor: true,
cursorSize: 3,
cursorSmoothing: 1.25,
cursorMotionBlur: 0.5,
cursorClickBounce: 2.25,
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
cropRegion: { x: 0.12, y: 0.08, width: 0.7, height: 0.65 },
aspectRatio: "4:5",
exportQuality: "source",
exportFormat: "gif",
gifFrameRate: 20,
gifLoop: false,
gifSizePreset: "large",
customAspectWidth: "4",
customAspectHeight: "5",
customWallpapers: ["data:image/jpeg;base64,abc"],
});
});
});
+156 -12
View File
@@ -1,26 +1,69 @@
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
import { normalizeProjectEditor, type ProjectEditorState } from "./projectPersistence";
export interface EditorPreferences {
aspectRatio: AspectRatio;
type PersistedEditorControls = Pick<
ProjectEditorState,
| "wallpaper"
| "shadowIntensity"
| "backgroundBlur"
| "zoomMotionBlur"
| "connectZooms"
| "showCursor"
| "loopCursor"
| "cursorSize"
| "cursorSmoothing"
| "cursorMotionBlur"
| "cursorClickBounce"
| "cursorSway"
| "borderRadius"
| "padding"
| "cropRegion"
| "aspectRatio"
| "exportQuality"
| "exportFormat"
| "gifFrameRate"
| "gifLoop"
| "gifSizePreset"
>;
type PartialEditorControls = Partial<PersistedEditorControls>;
export interface EditorPreferences extends PersistedEditorControls {
customAspectWidth: string;
customAspectHeight: string;
customWallpapers: string[];
}
export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences";
const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({});
export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
aspectRatio: "16:9",
wallpaper: DEFAULT_EDITOR_CONTROLS.wallpaper,
shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity,
backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur,
zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur,
connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms,
showCursor: DEFAULT_EDITOR_CONTROLS.showCursor,
loopCursor: DEFAULT_EDITOR_CONTROLS.loopCursor,
cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing,
cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce,
cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway,
borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius,
padding: DEFAULT_EDITOR_CONTROLS.padding,
cropRegion: DEFAULT_EDITOR_CONTROLS.cropRegion,
aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio,
exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality,
exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat,
gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate,
gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop,
gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset,
customAspectWidth: "16",
customAspectHeight: "9",
customWallpapers: [],
};
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;
@@ -34,6 +77,106 @@ function normalizePositiveIntegerString(value: unknown, fallback: string): strin
return String(parsed);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeCropRegion(
value: unknown,
fallback: EditorPreferences["cropRegion"],
): EditorPreferences["cropRegion"] {
if (!value || typeof value !== "object") {
return fallback;
}
const raw = value as Partial<EditorPreferences["cropRegion"]>;
const x = isFiniteNumber(raw.x) && raw.x >= 0 && raw.x < 1 ? raw.x : fallback.x;
const y = isFiniteNumber(raw.y) && raw.y >= 0 && raw.y < 1 ? raw.y : fallback.y;
const maxWidth = 1 - x;
const maxHeight = 1 - y;
const fallbackWidth = clamp(fallback.width, 0.01, maxWidth);
const fallbackHeight = clamp(fallback.height, 0.01, maxHeight);
const width =
isFiniteNumber(raw.width) && raw.width >= 0.01 && raw.width <= maxWidth
? raw.width
: fallbackWidth;
const height =
isFiniteNumber(raw.height) && raw.height >= 0.01 && raw.height <= maxHeight
? raw.height
: fallbackHeight;
return { x, y, width, height };
}
function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) {
return fallback;
}
return Array.from(
new Set(value.filter((item): item is string => typeof item === "string" && item.length > 0)),
);
}
function normalizeEditorControls(
raw: Partial<EditorPreferences>,
fallback: EditorPreferences,
): PersistedEditorControls {
const candidate: PartialEditorControls = {
wallpaper: raw.wallpaper ?? fallback.wallpaper,
shadowIntensity: raw.shadowIntensity ?? fallback.shadowIntensity,
backgroundBlur: raw.backgroundBlur ?? fallback.backgroundBlur,
zoomMotionBlur: raw.zoomMotionBlur ?? fallback.zoomMotionBlur,
connectZooms: raw.connectZooms ?? fallback.connectZooms,
showCursor: raw.showCursor ?? fallback.showCursor,
loopCursor: raw.loopCursor ?? fallback.loopCursor,
cursorSize: raw.cursorSize ?? fallback.cursorSize,
cursorSmoothing: raw.cursorSmoothing ?? fallback.cursorSmoothing,
cursorMotionBlur: raw.cursorMotionBlur ?? fallback.cursorMotionBlur,
cursorClickBounce: raw.cursorClickBounce ?? fallback.cursorClickBounce,
cursorSway: raw.cursorSway ?? fallback.cursorSway,
borderRadius: raw.borderRadius ?? fallback.borderRadius,
padding: raw.padding ?? fallback.padding,
cropRegion: normalizeCropRegion(raw.cropRegion, fallback.cropRegion),
aspectRatio: raw.aspectRatio ?? fallback.aspectRatio,
exportQuality: raw.exportQuality ?? fallback.exportQuality,
exportFormat: raw.exportFormat ?? fallback.exportFormat,
gifFrameRate: raw.gifFrameRate ?? fallback.gifFrameRate,
gifLoop: raw.gifLoop ?? fallback.gifLoop,
gifSizePreset: raw.gifSizePreset ?? fallback.gifSizePreset,
};
const normalized = normalizeProjectEditor(candidate);
return {
wallpaper: normalized.wallpaper,
shadowIntensity: normalized.shadowIntensity,
backgroundBlur: normalized.backgroundBlur,
zoomMotionBlur: normalized.zoomMotionBlur,
connectZooms: normalized.connectZooms,
showCursor: normalized.showCursor,
loopCursor: normalized.loopCursor,
cursorSize: normalized.cursorSize,
cursorSmoothing: normalized.cursorSmoothing,
cursorMotionBlur: normalized.cursorMotionBlur,
cursorClickBounce: normalized.cursorClickBounce,
cursorSway: normalized.cursorSway,
borderRadius: normalized.borderRadius,
padding: normalized.padding,
cropRegion: normalized.cropRegion,
aspectRatio: normalized.aspectRatio,
exportQuality: normalized.exportQuality,
exportFormat: normalized.exportFormat,
gifFrameRate: normalized.gifFrameRate,
gifLoop: normalized.gifLoop,
gifSizePreset: normalized.gifSizePreset,
};
}
export function normalizeEditorPreferences(
candidate: unknown,
fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES,
@@ -42,7 +185,7 @@ export function normalizeEditorPreferences(
candidate && typeof candidate === "object" ? (candidate as Partial<EditorPreferences>) : {};
return {
aspectRatio: isStoredAspectRatio(raw.aspectRatio) ? raw.aspectRatio : fallback.aspectRatio,
...normalizeEditorControls(raw, fallback),
customAspectWidth: normalizePositiveIntegerString(
raw.customAspectWidth,
fallback.customAspectWidth,
@@ -51,6 +194,7 @@ export function normalizeEditorPreferences(
raw.customAspectHeight,
fallback.customAspectHeight,
),
customWallpapers: normalizeCustomWallpapers(raw.customWallpapers, fallback.customWallpapers),
};
}