summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-06 12:40:47 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-06 12:40:47 +0200
commit964ac82d5e1ab2774e917338f7cbcbd11c323e94 (patch)
tree4d39e3edfc86ddba746d3e64ce544868d013bbea /apps/web/src/components/editor/panels
parent05243616eaf597c8e81b9401ba09b425928ebf95 (diff)
downloadopencut-964ac82d5e1ab2774e917338f7cbcbd11c323e94.tar.gz
refactor: split presets into data definitions and storage store
Diffstat (limited to 'apps/web/src/components/editor/panels')
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts101
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts19
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx10
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts90
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts12
5 files changed, 129 insertions, 103 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
new file mode 100644
index 00000000..54722ec2
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
@@ -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();
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
new file mode 100644
index 00000000..6ccc9071
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
@@ -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] },
+];
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
index 86665392..00348a60 100644
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
@@ -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",
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts
deleted file mode 100644
index cbcdf566..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts
+++ /dev/null
@@ -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();
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
index 148fa7b4..9c81215d 100644
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
@@ -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",