refactor: split presets into data definitions and storage store
This commit is contained in:
parent
05243616ea
commit
964ac82d5e
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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] },
|
||||
];
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue