Files
hyperframes/packages/studio/src/components/editor/studioMotionOps.ts
T
James 7e0a447325 refactor: drop unused exports detected by fallow auto-fix
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols
fallow's reachability analysis identifies as unused. Keeps only the cases
where the symbol is still referenced internally in its own file (so
removing `export` doesn't surface a new oxlint `no-unused-vars` error).

Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused
exports), with no behavior change — each symbol is still defined and used
exactly the same way within its file.

Reverted ~20 files where fallow's auto-fix would have created cascading
"declared but never used" lint errors — those are cases where the symbol
isn't used at all, and properly cleaning them up means deleting the
declaration, not just dropping `export`. Better to land that as a
separate, narrower PR rather than mixing it into a mechanical de-export.

Also reverted four false positives where fallow missed real consumers:
- `captureCost.ts` (renderOrchestrator has two separate import blocks
  from the same module; fallow only saw the first)
- `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses
  fallow's reachability missed)
- `render.ts` (functions imported via `await import()` dynamic import,
  which fallow's static analysis doesn't follow)

Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean,
cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
2026-05-19 00:51:56 +00:00

374 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";
// ── 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 Math.round(value * 1000) / 1000;
}
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);
}