refactor: split presets into data definitions and storage store

This commit is contained in:
Maze Winther 2026-04-06 12:40:47 +02:00
parent 05243616ea
commit 964ac82d5e
5 changed files with 129 additions and 103 deletions

View File

@ -0,0 +1,101 @@
"use client";
import { useSyncExternalStore } from "react";
import { generateUUID } from "@/utils/id";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
import type { EasingPreset } from "./easing-presets";
const STORAGE_KEY = "opencut:graph-editor-presets";
let cachedPresets: EasingPreset[] | null = null;
const listeners = new Set<() => void>();
function isValidPresetArray(value: unknown): value is EasingPreset[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
typeof item === "object" &&
item !== null &&
typeof item.id === "string" &&
typeof item.label === "string" &&
Array.isArray(item.value) &&
item.value.length === 4 &&
item.value.every((number: unknown) => typeof number === "number"),
)
);
}
function readFromStorage(): EasingPreset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
return isValidPresetArray(parsed) ? parsed : [];
} catch {
// Silently recover — corrupted localStorage shouldn't crash the editor
return [];
}
}
function writeToStorage({ presets }: { presets: EasingPreset[] }): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
}
function getSnapshot(): EasingPreset[] {
cachedPresets ??= readFromStorage();
return cachedPresets;
}
function getServerSnapshot(): EasingPreset[] {
return [];
}
function notify(): void {
cachedPresets = null;
for (const listener of listeners) {
listener();
}
}
function onStorageChange(event: StorageEvent): void {
if (event.key === STORAGE_KEY) notify();
}
function subscribe(listener: () => void): () => void {
if (listeners.size === 0 && typeof window !== "undefined") {
window.addEventListener("storage", onStorageChange);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && typeof window !== "undefined") {
window.removeEventListener("storage", onStorageChange);
}
};
}
export function useCustomPresets(): EasingPreset[] {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function savePreset({ value }: { value: NormalizedCubicBezier }): void {
const current = getSnapshot();
writeToStorage({
presets: [
...current,
{
id: generateUUID(),
label: `Custom ${current.length + 1}`,
value,
isCustom: true,
},
],
});
notify();
}
export function removePreset({ id }: { id: string }): void {
writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) });
notify();
}

View File

@ -0,0 +1,19 @@
import type { NormalizedCubicBezier } from "@/lib/animation/types";
export const PRESET_MATCH_TOLERANCE = 0.02;
export interface EasingPreset {
id: string;
label: string;
value: NormalizedCubicBezier;
isCustom?: boolean;
}
export const BUILTIN_PRESETS: EasingPreset[] = [
{ id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
{ id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
{ id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
{ id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
{ id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
{ id: "linear", label: "Linear", value: [0, 0, 1, 1] },
];

View File

@ -17,11 +17,9 @@ import type { GraphEditorComponentOption } from "./session";
import {
BUILTIN_PRESETS,
PRESET_MATCH_TOLERANCE,
removePreset,
savePreset,
type EasingPreset,
useCustomPresets,
} from "./presets";
} from "./easing-presets";
import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store";
import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
const COLLAPSED_MAX = 6;
@ -160,12 +158,12 @@ export function GraphEditorPopover({
isActive={activePresetId === preset.id}
disabled={!canEdit}
onSelect={() => onCommitValue?.(preset.value)}
onDelete={() => removePreset(preset.id)}
onDelete={() => removePreset({ id: preset.id })}
/>
))}
<button
type="button"
onClick={() => value && savePreset(value)}
onClick={() => value && savePreset({ value })}
disabled={!canEdit}
className={cn(
"text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",

View File

@ -1,90 +0,0 @@
"use client";
import { useSyncExternalStore } from "react";
import type { NormalizedCubicBezier } from "@/lib/animation/types";
const STORAGE_KEY = "opencut:graph-editor-presets";
export const PRESET_MATCH_TOLERANCE = 0.02;
export interface EasingPreset {
id: string;
label: string;
value: NormalizedCubicBezier;
isCustom?: boolean;
}
export const BUILTIN_PRESETS: EasingPreset[] = [
{ id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
{ id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
{ id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
{ id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
{ id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
{ id: "linear", label: "Linear", value: [0, 0, 1, 1] },
];
let cachedPresets: EasingPreset[] | null = null;
const listeners = new Set<() => void>();
function readFromStorage(): EasingPreset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
// JSON.parse can throw if the stored value is corrupted
return raw ? (JSON.parse(raw) as EasingPreset[]) : [];
} catch {
return [];
}
}
function writeToStorage(presets: EasingPreset[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
}
function getSnapshot(): EasingPreset[] {
cachedPresets ??= readFromStorage();
return cachedPresets;
}
function getServerSnapshot(): EasingPreset[] {
return [];
}
function notify(): void {
cachedPresets = null;
for (const listener of listeners) {
listener();
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY) notify();
});
}
export function useCustomPresets(): EasingPreset[] {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function savePreset(value: NormalizedCubicBezier): void {
const current = getSnapshot();
writeToStorage([
...current,
{
id: `custom-${Date.now()}`,
label: `Custom ${current.length + 1}`,
value,
isCustom: true,
},
]);
notify();
}
export function removePreset(id: string): void {
writeToStorage(getSnapshot().filter((preset) => preset.id !== id));
notify();
}

View File

@ -1,5 +1,3 @@
"use client";
import {
getCurveHandlesForNormalizedCubicBezier,
getEditableScalarChannels,
@ -22,7 +20,7 @@ const FLAT_VALUE_EPSILON = 1e-6;
const LINEAR_CURVE_EPSILON = 1e-6;
export type GraphEditorUnavailableReason =
| "no-keyframe-selected"
| "no-keyframe-selected"
| "multiple-keyframes-selected"
| "selected-element-missing"
| "selected-element-has-no-animations"
@ -201,11 +199,11 @@ export function resolveGraphEditorSelectionState({
}
if (selectedKeyframes.length === 2) {
const [kf1, kf2] = selectedKeyframes;
const [firstKeyframe, secondKeyframe] = selectedKeyframes;
if (
kf1.trackId !== kf2.trackId ||
kf1.elementId !== kf2.elementId ||
kf1.propertyPath !== kf2.propertyPath
firstKeyframe.trackId !== secondKeyframe.trackId ||
firstKeyframe.elementId !== secondKeyframe.elementId ||
firstKeyframe.propertyPath !== secondKeyframe.propertyPath
) {
return createUnavailableState({
reason: "multiple-keyframes-selected",