mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
fix: persist presets and theme preferences
This commit is contained in:
@@ -37,9 +37,27 @@ function createStorageMock(initialValues: Record<string, string> = {}): Storage
|
||||
};
|
||||
}
|
||||
|
||||
function stubElectronSettings(initialValues: Record<string, unknown> = {}) {
|
||||
const store = new Map(Object.entries(initialValues));
|
||||
|
||||
Object.defineProperty(globalThis, "electronAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getAppSetting: (key: string) => (store.has(key) ? store.get(key) : null),
|
||||
setAppSetting: (key: string, value: unknown) => {
|
||||
store.set(key, value);
|
||||
return true;
|
||||
},
|
||||
} as Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">,
|
||||
});
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("editorPreferences", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Reflect.deleteProperty(globalThis, "electronAPI");
|
||||
});
|
||||
|
||||
it("normalizes invalid values back to safe defaults", () => {
|
||||
@@ -69,10 +87,10 @@ describe("editorPreferences", () => {
|
||||
expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source");
|
||||
});
|
||||
|
||||
it("defaults cursor preferences to macOS at 2.5x with lighter sway", () => {
|
||||
it("defaults cursor preferences to macOS at 2.5x with gentler sway", () => {
|
||||
expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("macos");
|
||||
expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5);
|
||||
expect(DEFAULT_EDITOR_PREFERENCES.cursorSway).toBe(0.25);
|
||||
expect(DEFAULT_EDITOR_PREFERENCES.cursorSway).toBe(0.4);
|
||||
});
|
||||
|
||||
it("defaults MP4 exports to the Lightning pipeline", () => {
|
||||
@@ -309,6 +327,24 @@ describe("editorPreferences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads editor preferences from Electron app settings when available", () => {
|
||||
stubElectronSettings({
|
||||
[EDITOR_PREFERENCES_STORAGE_KEY]: {
|
||||
wallpaper: "#0f172a",
|
||||
showCursor: false,
|
||||
customAspectWidth: "3",
|
||||
customAspectHeight: "2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadEditorPreferences()).toMatchObject({
|
||||
wallpaper: "#0f172a",
|
||||
showCursor: false,
|
||||
customAspectWidth: "3",
|
||||
customAspectHeight: "2",
|
||||
});
|
||||
});
|
||||
|
||||
it("saves editor presets and reports success", () => {
|
||||
const localStorage = createStorageMock();
|
||||
vi.stubGlobal("localStorage", localStorage);
|
||||
@@ -337,6 +373,32 @@ describe("editorPreferences", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("saves editor presets to Electron app settings when available", () => {
|
||||
const settingsStore = stubElectronSettings();
|
||||
|
||||
expect(
|
||||
saveEditorPresets([
|
||||
{
|
||||
id: "preset-1",
|
||||
name: "Demo Preset",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-02T00:00:00.000Z",
|
||||
snapshot: {
|
||||
...DEFAULT_EDITOR_PREFERENCES,
|
||||
autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS,
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
|
||||
expect(settingsStore.get(EDITOR_PRESETS_STORAGE_KEY)).toMatchObject([
|
||||
{
|
||||
id: "preset-1",
|
||||
name: "Demo Preset",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns false when preset persistence fails", () => {
|
||||
const localStorage = createStorageMock();
|
||||
localStorage.setItem = () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
stripPersistedDevMotionBlurSettings,
|
||||
type ProjectEditorState,
|
||||
} from "./projectPersistence";
|
||||
import { loadAppSetting, saveAppSetting } from "../../lib/appSettings";
|
||||
|
||||
type PersistedEditorControls = Pick<
|
||||
ProjectEditorState,
|
||||
@@ -422,12 +423,13 @@ export function normalizeEditorPreferences(
|
||||
}
|
||||
|
||||
export function loadEditorPreferences(): EditorPreferences {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return DEFAULT_EDITOR_PREFERENCES;
|
||||
const persisted = loadAppSetting<unknown>(EDITOR_PREFERENCES_STORAGE_KEY);
|
||||
if (persisted !== null) {
|
||||
return normalizeEditorPreferences(persisted);
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = globalThis.localStorage.getItem(EDITOR_PREFERENCES_STORAGE_KEY);
|
||||
const stored = globalThis.localStorage?.getItem(EDITOR_PREFERENCES_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return DEFAULT_EDITOR_PREFERENCES;
|
||||
}
|
||||
@@ -439,16 +441,14 @@ export function loadEditorPreferences(): EditorPreferences {
|
||||
}
|
||||
|
||||
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(
|
||||
const persisted = stripPersistedDevMotionBlurSettings(merged);
|
||||
saveAppSetting(EDITOR_PREFERENCES_STORAGE_KEY, persisted);
|
||||
globalThis.localStorage?.setItem(
|
||||
EDITOR_PREFERENCES_STORAGE_KEY,
|
||||
JSON.stringify(stripPersistedDevMotionBlurSettings(merged)),
|
||||
JSON.stringify(persisted),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures so editor controls still work.
|
||||
@@ -456,12 +456,13 @@ export function saveEditorPreferences(preferences: Partial<EditorPreferences>):
|
||||
}
|
||||
|
||||
export function loadEditorPresets(): EditorPreset[] {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return [];
|
||||
const persisted = loadAppSetting<unknown>(EDITOR_PRESETS_STORAGE_KEY);
|
||||
if (persisted !== null) {
|
||||
return normalizeEditorPresets(persisted);
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = globalThis.localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY);
|
||||
const stored = globalThis.localStorage?.getItem(EDITOR_PRESETS_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
@@ -473,14 +474,11 @@ export function loadEditorPresets(): EditorPreset[] {
|
||||
}
|
||||
|
||||
export function saveEditorPresets(presets: EditorPreset[]): boolean {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = normalizeEditorPresets(presets);
|
||||
globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized));
|
||||
return true;
|
||||
const persisted = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized);
|
||||
globalThis.localStorage?.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized));
|
||||
return persisted || typeof globalThis.localStorage !== "undefined";
|
||||
} catch {
|
||||
// Ignore storage failures so editor controls still work.
|
||||
return false;
|
||||
|
||||
@@ -101,7 +101,7 @@ export const DEFAULT_CURSOR_SMOOTHING = 0.67;
|
||||
export const DEFAULT_CURSOR_MOTION_BLUR = 0.4;
|
||||
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
|
||||
export const DEFAULT_CURSOR_CLICK_BOUNCE_DURATION = 350;
|
||||
export const DEFAULT_CURSOR_SWAY = 0.25;
|
||||
export const DEFAULT_CURSOR_SWAY = 0.4;
|
||||
export const DEFAULT_ZOOM_SMOOTHNESS = 0.5;
|
||||
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
|
||||
export interface ZoomMotionBlurTuning {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadThemePreference, persistThemePreference } from "./ThemeContext";
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubElectronSettings(initialValues: Record<string, unknown> = {}) {
|
||||
const store = new Map(Object.entries(initialValues));
|
||||
|
||||
Object.defineProperty(globalThis, "electronAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getAppSetting: (key: string) => (store.has(key) ? store.get(key) : null),
|
||||
setAppSetting: (key: string, value: unknown) => {
|
||||
store.set(key, value);
|
||||
return true;
|
||||
},
|
||||
} as Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">,
|
||||
});
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("ThemeContext persistence", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Reflect.deleteProperty(globalThis, "electronAPI");
|
||||
});
|
||||
|
||||
it("loads the persisted theme preference from Electron app settings", () => {
|
||||
stubElectronSettings({ "recordly.theme": "dark" });
|
||||
|
||||
expect(loadThemePreference()).toBe("dark");
|
||||
});
|
||||
|
||||
it("saves the theme preference to Electron app settings", () => {
|
||||
const settingsStore = stubElectronSettings();
|
||||
|
||||
persistThemePreference("dark");
|
||||
|
||||
expect(settingsStore.get("recordly.theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("falls back to localStorage when Electron settings are unavailable", () => {
|
||||
vi.stubGlobal(
|
||||
"localStorage",
|
||||
createStorageMock({ "recordly.theme": "light" }),
|
||||
);
|
||||
|
||||
expect(loadThemePreference()).toBe("light");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { loadAppSetting, saveAppSetting } from "../lib/appSettings";
|
||||
|
||||
export type ThemePreference = "light" | "dark" | "system";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
@@ -16,7 +17,12 @@ const THEME_STORAGE_KEY = "recordly.theme";
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function getStoredPreference(): ThemePreference {
|
||||
export function loadThemePreference(): ThemePreference {
|
||||
const persisted = loadAppSetting<unknown>(THEME_STORAGE_KEY);
|
||||
if (persisted === "light" || persisted === "dark" || persisted === "system") {
|
||||
return persisted;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = globalThis.localStorage?.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
@@ -28,6 +34,16 @@ function getStoredPreference(): ThemePreference {
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function persistThemePreference(pref: ThemePreference): void {
|
||||
saveAppSetting(THEME_STORAGE_KEY, pref);
|
||||
|
||||
try {
|
||||
globalThis.localStorage?.setItem(THEME_STORAGE_KEY, pref);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTheme(pref: ThemePreference): ResolvedTheme {
|
||||
if (pref === "system") {
|
||||
return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
@@ -49,7 +65,7 @@ function applyThemeToDOM(theme: ResolvedTheme) {
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(() => {
|
||||
const stored = getStoredPreference();
|
||||
const stored = loadThemePreference();
|
||||
applyThemeToDOM(resolveTheme(stored));
|
||||
return stored;
|
||||
});
|
||||
@@ -63,11 +79,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const r = resolveTheme(pref);
|
||||
setResolved(r);
|
||||
applyThemeToDOM(r);
|
||||
try {
|
||||
globalThis.localStorage?.setItem(THEME_STORAGE_KEY, pref);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
persistThemePreference(pref);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
type ElectronSettingsApi = Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">;
|
||||
|
||||
function getElectronSettingsApi(): ElectronSettingsApi | null {
|
||||
const api = (globalThis as typeof globalThis & { electronAPI?: ElectronSettingsApi })
|
||||
.electronAPI;
|
||||
if (
|
||||
!api ||
|
||||
typeof api.getAppSetting !== "function" ||
|
||||
typeof api.setAppSetting !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
export function loadAppSetting<T>(key: string): T | null {
|
||||
const api = getElectronSettingsApi();
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = api.getAppSetting(key);
|
||||
return value === undefined ? null : (value as T | null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAppSetting(key: string, value: unknown): boolean {
|
||||
const api = getElectronSettingsApi();
|
||||
if (!api) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return api.setAppSetting(key, value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user