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 {
|
import {
|
||||||
BUILTIN_PRESETS,
|
BUILTIN_PRESETS,
|
||||||
PRESET_MATCH_TOLERANCE,
|
PRESET_MATCH_TOLERANCE,
|
||||||
removePreset,
|
|
||||||
savePreset,
|
|
||||||
type EasingPreset,
|
type EasingPreset,
|
||||||
useCustomPresets,
|
} from "./easing-presets";
|
||||||
} from "./presets";
|
import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store";
|
||||||
import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
|
import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
|
||||||
|
|
||||||
const COLLAPSED_MAX = 6;
|
const COLLAPSED_MAX = 6;
|
||||||
|
|
@ -160,12 +158,12 @@ export function GraphEditorPopover({
|
||||||
isActive={activePresetId === preset.id}
|
isActive={activePresetId === preset.id}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
onSelect={() => onCommitValue?.(preset.value)}
|
onSelect={() => onCommitValue?.(preset.value)}
|
||||||
onDelete={() => removePreset(preset.id)}
|
onDelete={() => removePreset({ id: preset.id })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => value && savePreset(value)}
|
onClick={() => value && savePreset({ value })}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
|
"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 {
|
import {
|
||||||
getCurveHandlesForNormalizedCubicBezier,
|
getCurveHandlesForNormalizedCubicBezier,
|
||||||
getEditableScalarChannels,
|
getEditableScalarChannels,
|
||||||
|
|
@ -22,7 +20,7 @@ const FLAT_VALUE_EPSILON = 1e-6;
|
||||||
const LINEAR_CURVE_EPSILON = 1e-6;
|
const LINEAR_CURVE_EPSILON = 1e-6;
|
||||||
|
|
||||||
export type GraphEditorUnavailableReason =
|
export type GraphEditorUnavailableReason =
|
||||||
| "no-keyframe-selected"
|
| "no-keyframe-selected"
|
||||||
| "multiple-keyframes-selected"
|
| "multiple-keyframes-selected"
|
||||||
| "selected-element-missing"
|
| "selected-element-missing"
|
||||||
| "selected-element-has-no-animations"
|
| "selected-element-has-no-animations"
|
||||||
|
|
@ -201,11 +199,11 @@ export function resolveGraphEditorSelectionState({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedKeyframes.length === 2) {
|
if (selectedKeyframes.length === 2) {
|
||||||
const [kf1, kf2] = selectedKeyframes;
|
const [firstKeyframe, secondKeyframe] = selectedKeyframes;
|
||||||
if (
|
if (
|
||||||
kf1.trackId !== kf2.trackId ||
|
firstKeyframe.trackId !== secondKeyframe.trackId ||
|
||||||
kf1.elementId !== kf2.elementId ||
|
firstKeyframe.elementId !== secondKeyframe.elementId ||
|
||||||
kf1.propertyPath !== kf2.propertyPath
|
firstKeyframe.propertyPath !== secondKeyframe.propertyPath
|
||||||
) {
|
) {
|
||||||
return createUnavailableState({
|
return createUnavailableState({
|
||||||
reason: "multiple-keyframes-selected",
|
reason: "multiple-keyframes-selected",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue