Files
hyperframes/packages/studio/src/components/editor/studioMotionOps.ts
T
Miguel ÁngelandMiguel Ángel 7bff49ecf0 refactor(studio): simplify hooks, split contexts, remove dead code (#1416)
* fix(studio): guard Zustand no-op setters and fix useConsoleErrorCapture memory leak

- Guard setIsPlaying to skip set() when value unchanged (eliminates 60
  notifications/sec during reverse playback)
- Guard caption store selectGroup to bail before set() when group missing
  (prevents empty Zustand notifications)
- Guard clearSelection to skip when already empty
- Fix useConsoleErrorCapture: restore original console.error, remove error
  event listener, and delete __hfErrorCapture flag on cleanup

* fix(studio): delete dead files and unused exports

Remove 7 dead files (audioBeatDetection, keyframeSnapping,
timelineInspector, DopesheetStrip, StaggerControls,
TimelineLayerPanel, TimelineEditorNotice) and their test companions.

Delete unused computeFitToChildrenSize export from propertyPanelHelpers.

Fix re-export indirection: useDomEditCommits and studioMotionOps.test
now import patch builders directly from manualEditsDomPatches instead
of the re-export passthrough in manualEditsDom.

* fix(studio): eliminate effect-chain state mirroring for lint findings, hover, and GSAP fetch

Move lint findingsByElement sync from App.tsx into useLintModal where
the value is produced, removing the mirroring useEffect. Consolidate
4 hover-clearing effects in useDomSelection into 2 (one unconditional
on context change, one conditional combining caption mode, selection
match, and disconnected element checks). Fold the GSAP retry effect
into the fetch effect in useGsapTweenCache, scheduling a single retry
via setTimeout when the initial fetch returns 0 animations.

Eliminates 3 unnecessary render cycles from effect chains.

* fix(studio): memoize renderQueue, toolbar, and canvas rect to prevent re-render cascade

- Wrap renderQueue object in useMemo so StudioContext consumers don't
  re-render on every App render
- Memoize timelineToolbar JSX so NLELayout memo isn't defeated
- Move canvasRect getBoundingClientRect() from render-time IIFE to a
  useLayoutEffect-backed ref, eliminating layout thrashing
- Track and clear setTimeout handles in refreshPreviewDocumentVersion
  to prevent stale timer accumulation on rapid calls and unmount

* refactor(studio): consolidate GSAP shared primitives — defaults, iframe access, keyframe parsing

Extract duplicated PROPERTY_DEFAULTS, IframeGsap interface, iframe
accessors (getIframeGsap, queryIframeElement), percentage keyframe
parsing, and toAbsoluteTime into a single gsapShared.ts module.

Removes ~120 lines of copy-pasted logic across 8 hook files, reducing
drift risk between the duplicate implementations.

* fix(studio): remove dead store fields, dead file, duplicate helper, and unsafe assertions

* refactor(studio): deduplicate selector helpers, rounding utils, percentage computation, and iframe access

* fix(studio): split StudioContext into Shell + Playback to prevent cascade re-renders

* refactor(studio): decompose useGsapScriptCommits into focused mutation hooks

* refactor(studio): decompose useFileManager into focused file operation hooks

Extract useFileTree (tree loading, refresh, derived assets/compositions)
and useEditorSave (debounced save with history tracking) from the 508-LOC
useFileManager. The parent hook composes both and retains file I/O,
click-to-source, upload/import, and CRUD — preserving the same public
interface so no consumers change.

* refactor(studio): decompose useDomEditCommits into focused commit hooks

Extract geometry (path offset, box size, rotation) and element lifecycle
(delete, z-index reorder) into useDomGeometryCommits and
useElementLifecycleOps. Parent keeps persistDomEditOperations as core
and composes all sub-hooks — public interface unchanged.

* refactor(studio): simplify useAppHotkeys with declarative command table

* refactor(studio): simplify useAppHotkeys with declarative command table

Replace 15 individual useRef callback refs with a single cbRef object.
Extract keydown dispatch into pure dispatchModifierKey/dispatchPlainKey
functions. Merge duplicate undo/redo logic into shared applyHistory.
Extract cross-origin listener boilerplate into safeAddListener/safeRemoveListener.

Hook body: 204 LOC (down from 445). Public API unchanged.

* fix(studio): remove unused getDomEditTargetKey import

* refactor(studio): decompose useDomEditSession into focused editing hooks

Extract GSAP-aware geometry intercepts (move/resize/rotation) and
animated property commit into useGsapAwareEditing, and selection
wiring, GSAP cache management, preview sync, and selection handlers
into useDomEditWiring. The parent remains a pure composition shell.

* style(studio): fix formatting in 5 files

* fix(studio): trim App.tsx to 598 lines (under 600 limit)

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-13 18:25:11 -04:00

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Manifest parse, serialize, and CRUD operations ──
import type { DomEditSelection } from "./domEditing";
import {
DEFAULT_CUSTOM_EASE_POINTS,
GSAP_EASE_CONTROL_POINTS,
CUSTOM_EASE_DATA_PATTERN,
STUDIO_MOTION_ATTR,
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
type StudioCustomEaseControlPoints,
type StudioGsapCustomEase,
type StudioGsapMotion,
type StudioGsapMotionPreset,
type StudioGsapPresetMotionOptions,
type StudioGsapMotionValues,
type StudioMotionManifest,
type StudioMotionTarget,
} from "./studioMotionTypes";
import { roundTo3 } from "../../utils/rounding";
// ── Private helpers ──
function clampPositiveNumber(value: number, fallback: number): number {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function clampNonNegativeNumber(value: number, fallback: number): number {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function sanitizeEase(value: string): string {
return value.trim() || "none";
}
function roundEaseNumber(value: number): number {
return roundTo3(value);
}
function clampRange(value: number, min: number, max: number, fallback: number): number {
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
function formatEaseNumber(value: number): string {
const rounded = roundEaseNumber(value);
if (Object.is(rounded, -0)) return "0";
return `${rounded}`;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
// ── Custom ease points ──
export function clampStudioCustomEasePoints(
points: Partial<StudioCustomEaseControlPoints>,
): StudioCustomEaseControlPoints {
return {
x1: roundEaseNumber(clampRange(points.x1 ?? DEFAULT_CUSTOM_EASE_POINTS.x1, 0, 1, 0.215)),
y1: roundEaseNumber(clampRange(points.y1 ?? DEFAULT_CUSTOM_EASE_POINTS.y1, -0.6, 1.6, 0.61)),
x2: roundEaseNumber(clampRange(points.x2 ?? DEFAULT_CUSTOM_EASE_POINTS.x2, 0, 1, 0.355)),
y2: roundEaseNumber(clampRange(points.y2 ?? DEFAULT_CUSTOM_EASE_POINTS.y2, -0.6, 1.6, 1)),
};
}
export function parseStudioCustomEaseData(
data: string | undefined,
): StudioCustomEaseControlPoints | null {
if (!data) return null;
const match = data.trim().match(CUSTOM_EASE_DATA_PATTERN);
if (!match) return null;
const points = {
x1: Number.parseFloat(match[1] ?? ""),
y1: Number.parseFloat(match[2] ?? ""),
x2: Number.parseFloat(match[3] ?? ""),
y2: Number.parseFloat(match[4] ?? ""),
};
if (!Object.values(points).every(Number.isFinite)) return null;
return clampStudioCustomEasePoints(points);
}
export function serializeStudioCustomEaseData(points: StudioCustomEaseControlPoints): string {
const clamped = clampStudioCustomEasePoints(points);
return `M0,0 C${formatEaseNumber(clamped.x1)},${formatEaseNumber(clamped.y1)} ${formatEaseNumber(clamped.x2)},${formatEaseNumber(clamped.y2)} 1,1`;
}
export function controlPointsForGsapEase(ease: string): StudioCustomEaseControlPoints {
return GSAP_EASE_CONTROL_POINTS[ease] ?? DEFAULT_CUSTOM_EASE_POINTS;
}
// ── Preset motion builder ──
export function buildStudioGsapPresetMotion(
preset: StudioGsapMotionPreset,
options: StudioGsapPresetMotionOptions,
): Omit<StudioGsapMotion, "kind" | "target" | "updatedAt"> {
const start = clampNonNegativeNumber(options.start, 0);
const duration = clampPositiveNumber(options.duration, 0.6);
const distance = clampPositiveNumber(options.distance, 32);
const ease = sanitizeEase(options.ease);
const direction = options.direction ?? "up";
const base = { start, duration, ease, customEase: options.customEase };
if (preset === "pop") {
return {
...base,
from: { scale: 0.88, autoAlpha: 0 },
to: { scale: 1, autoAlpha: 1 },
};
}
if (preset === "slide") {
const x = direction === "right" ? -distance : direction === "left" ? distance : 0;
const y = direction === "down" ? -distance : direction === "up" ? distance : 0;
return {
...base,
from: { x, y, autoAlpha: 0 },
to: { x: 0, y: 0, autoAlpha: 1 },
};
}
return {
...base,
from: { y: direction === "down" ? -distance : distance, autoAlpha: 0 },
to: { y: 0, autoAlpha: 1 },
};
}
// ── Manifest parse/serialize ──
function parseMotionValues(value: unknown): StudioGsapMotionValues | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const parsed: StudioGsapMotionValues = {};
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"] as const) {
const next = finiteNumber(record[key]);
if (next != null) parsed[key] = next;
}
return Object.keys(parsed).length > 0 ? parsed : null;
}
function parseTarget(value: unknown): StudioMotionTarget | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const sourceFile = typeof record.sourceFile === "string" ? record.sourceFile : "";
if (!sourceFile) return null;
const selector = typeof record.selector === "string" ? record.selector : undefined;
const id = typeof record.id === "string" ? record.id : undefined;
if (!selector && !id) return null;
return {
sourceFile,
selector,
selectorIndex: finiteNumber(record.selectorIndex) ?? undefined,
id,
};
}
function parseCustomEase(value: unknown): StudioGsapCustomEase | undefined {
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
const data = typeof record.data === "string" ? record.data.trim() : "";
if (!id || !data) return undefined;
return { id, data };
}
function parseGsapMotion(value: unknown): StudioGsapMotion | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (record.kind !== "gsap-motion") return null;
const target = parseTarget(record.target);
if (!target) return null;
const start = finiteNumber(record.start);
const duration = finiteNumber(record.duration);
if (start == null || duration == null || start < 0 || duration <= 0) return null;
const ease = typeof record.ease === "string" && record.ease.trim() ? record.ease.trim() : "none";
const from = parseMotionValues(record.from);
const to = parseMotionValues(record.to);
if (!from || !to) return null;
return {
kind: "gsap-motion",
target,
start,
duration,
ease,
customEase: parseCustomEase(record.customEase),
from,
to,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}
export function emptyStudioMotionManifest(): StudioMotionManifest {
return { version: 1, motions: [] };
}
export function parseStudioMotionManifest(content: string): StudioMotionManifest {
if (!content.trim()) return emptyStudioMotionManifest();
try {
const parsed = JSON.parse(content) as unknown;
if (!parsed || typeof parsed !== "object") return emptyStudioMotionManifest();
const motions = (parsed as { motions?: unknown }).motions;
if (!Array.isArray(motions)) return emptyStudioMotionManifest();
return {
version: 1,
motions: motions
.map(parseGsapMotion)
.filter((motion): motion is StudioGsapMotion => motion !== null),
};
} catch {
return emptyStudioMotionManifest();
}
}
export function serializeStudioMotionManifest(manifest: StudioMotionManifest): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
// ── Path helpers ──
function normalizeStudioFileChangePath(path: string): string {
return path
.trim()
.replace(/\\/g, "/")
.replace(/^\.?\//, "");
}
export function isStudioMotionManifestPath(path: string | null): boolean {
if (!path) return false;
const normalized = normalizeStudioFileChangePath(path);
return (
normalized === "." + "/" + ".hyperframes/studio-motion.json".slice(1) ||
normalized === ".hyperframes/studio-motion.json" ||
normalized.endsWith("/.hyperframes/studio-motion.json")
);
}
// ── CRUD helpers ──
function selectionTarget(selection: DomEditSelection): StudioMotionTarget {
return {
sourceFile: selection.sourceFile || "index.html",
selector: selection.selector,
selectorIndex: selection.selectorIndex,
id: selection.id ?? undefined,
};
}
function targetKey(target: StudioMotionTarget): string {
return [
target.sourceFile,
target.id ? `id:${target.id}` : "",
target.selector ? `selector:${target.selector}` : "",
target.selectorIndex != null ? `index:${target.selectorIndex}` : "",
].join("|");
}
function sameSelectionTarget(motion: StudioGsapMotion, selection: DomEditSelection): boolean {
const target = selectionTarget(selection);
if (motion.target.sourceFile !== target.sourceFile) return false;
if (motion.target.id && target.id && motion.target.id === target.id) return true;
return targetKey(motion.target) === targetKey(target);
}
export function upsertStudioGsapMotion(
manifest: StudioMotionManifest,
selection: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
): StudioMotionManifest {
const target = selectionTarget(selection);
const nextMotion: StudioGsapMotion = {
kind: "gsap-motion",
target,
...motion,
updatedAt: new Date().toISOString(),
};
return {
version: 1,
motions: [
...manifest.motions.filter((existing) => targetKey(existing.target) !== targetKey(target)),
nextMotion,
],
};
}
export function removeStudioMotionForSelection(
manifest: StudioMotionManifest,
selection: DomEditSelection,
): StudioMotionManifest {
return {
version: 1,
motions: manifest.motions.filter((motion) => !sameSelectionTarget(motion, selection)),
};
}
export function getStudioMotionForSelection(
manifest: StudioMotionManifest,
selection: DomEditSelection,
): StudioGsapMotion | null {
return manifest.motions.find((motion) => sameSelectionTarget(motion, selection)) ?? null;
}
// ── HTML-attributebacked motion storage ──
/** The JSON stored in the attribute omits kind/target/updatedAt — those are derived from context. */
interface StudioMotionAttrPayload {
start: number;
duration: number;
ease: string;
customEase?: StudioGsapCustomEase;
from: StudioGsapMotionValues;
to: StudioGsapMotionValues;
}
export function readStudioMotionFromElement(
element: HTMLElement,
): Omit<StudioGsapMotion, "kind" | "target" | "updatedAt"> | null {
const json = element.getAttribute(STUDIO_MOTION_ATTR);
if (!json || json === "true") return null;
try {
const parsed = JSON.parse(json) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const record = parsed as Record<string, unknown>;
const start = finiteNumber(record.start);
const duration = finiteNumber(record.duration);
if (start == null || duration == null || start < 0 || duration <= 0) return null;
const ease =
typeof record.ease === "string" && record.ease.trim() ? record.ease.trim() : "none";
const from = parseMotionValues(record.from);
const to = parseMotionValues(record.to);
if (!from || !to) return null;
return { start, duration, ease, customEase: parseCustomEase(record.customEase), from, to };
} catch {
return null;
}
}
export function writeStudioMotionToElement(
element: HTMLElement,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
): void {
// Capture original styles before first write (only if not already captured)
if (!element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)) {
element.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, element.style.transform);
element.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, element.style.opacity);
element.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, element.style.visibility);
}
const payload: StudioMotionAttrPayload = {
start: motion.start,
duration: motion.duration,
ease: motion.ease,
from: motion.from,
to: motion.to,
};
if (motion.customEase) payload.customEase = motion.customEase;
element.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(payload));
}
export function clearStudioMotionFromElement(
element: HTMLElement,
gsap?: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void },
): void {
if (!element.hasAttribute(STUDIO_MOTION_ATTR)) return;
gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
element.style.transform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR) ?? "";
element.style.opacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR) ?? "";
element.style.visibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR) ?? "";
element.removeAttribute(STUDIO_MOTION_ATTR);
element.removeAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
element.removeAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
element.removeAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
}