From 91c56de8127271e135861419eea22c40faa89561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 13 Jun 2026 03:04:42 +0900 Subject: [PATCH] fix(studio): remove motion tab from right panel The motion panel was behind a feature flag defaulting to false and never shipped. Remove the tab button, MotionPanel component, feature flag, and all associated wiring (EaseCurveEditor, SpringEaseEditor, MotionPanelFields, MotionPathOverlay). The underlying motion data infrastructure (studioMotion, studioMotionOps) remains intact for the GSAP panel. --- packages/studio/src/App.tsx | 5 - .../src/components/StudioRightPanel.tsx | 39 +-- .../src/components/editor/EaseCurveEditor.tsx | 221 -------------- .../src/components/editor/MotionPanel.tsx | 277 ------------------ .../components/editor/MotionPanelFields.tsx | 185 ------------ .../components/editor/MotionPathOverlay.tsx | 146 --------- .../components/editor/SpringEaseEditor.tsx | 256 ---------------- .../editor/manualEditingAvailability.test.ts | 3 +- .../editor/manualEditingAvailability.ts | 7 - .../studio/src/contexts/DomEditContext.tsx | 7 +- .../studio/src/hooks/useDomEditCommits.ts | 68 ----- .../studio/src/hooks/useDomEditSession.ts | 4 - .../studio/src/hooks/useStudioContextValue.ts | 22 +- packages/studio/src/utils/studioHelpers.ts | 2 +- .../studio/src/utils/studioUrlState.test.ts | 1 - packages/studio/src/utils/studioUrlState.ts | 10 +- 16 files changed, 9 insertions(+), 1244 deletions(-) delete mode 100644 packages/studio/src/components/editor/EaseCurveEditor.tsx delete mode 100644 packages/studio/src/components/editor/MotionPanel.tsx delete mode 100644 packages/studio/src/components/editor/MotionPanelFields.tsx delete mode 100644 packages/studio/src/components/editor/MotionPathOverlay.tsx delete mode 100644 packages/studio/src/components/editor/SpringEaseEditor.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 2f944b228e..f100423b29 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -389,9 +389,7 @@ export function StudioApp() { ); const { - selectedStudioMotion, designPanelActive, - motionPanelActive, inspectorPanelActive, inspectorButtonActive, shouldShowSelectedDomBounds, @@ -399,7 +397,6 @@ export function StudioApp() { panelLayout.rightPanelTab, panelLayout.rightCollapsed, isPlaying, - domEditSession.domEditSelection, gestureState === "recording", ); @@ -530,9 +527,7 @@ export function StudioApp() { {!panelLayout.rightCollapsed && ( { setActiveBlockParams(null); diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 9d0bd49160..1cb60fc78b 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -1,20 +1,12 @@ import { Tooltip } from "./ui"; import { PropertyPanel } from "./editor/PropertyPanel"; -import { MotionPanel } from "./editor/MotionPanel"; import { LayersPanel } from "./editor/LayersPanel"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; import { BlockParamsPanel } from "./editor/BlockParamsPanel"; import { RenderQueue } from "./renders/RenderQueue"; import type { RenderJob } from "./renders/useRenderQueue"; -import type { StudioGsapMotion } from "./editor/studioMotion"; import type { BlockParam } from "@hyperframes/core/registry"; -import { - STUDIO_INSPECTOR_PANELS_ENABLED, - STUDIO_MOTION_PANEL_ENABLED, -} from "./editor/manualEditingAvailability"; - -/** Motion data without targeting metadata. */ -type StudioMotionData = Omit; +import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability"; import { useStudioContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; @@ -23,9 +15,7 @@ import { useDomEditContext } from "../contexts/DomEditContext"; import { usePlayerStore } from "../player"; export interface StudioRightPanelProps { - selectedStudioMotion: StudioMotionData | null; designPanelActive: boolean; - motionPanelActive: boolean; activeBlockParams?: { blockName: string; blockTitle: string; @@ -40,9 +30,7 @@ export interface StudioRightPanelProps { // fallow-ignore-next-line complexity export function StudioRightPanel({ - selectedStudioMotion, designPanelActive, - motionPanelActive, activeBlockParams, onCloseBlockParams, recordingState, @@ -84,8 +72,6 @@ export function StudioRightPanel({ handleDomAddTextField, handleDomRemoveTextField, handleAskAgent, - handleDomMotionCommit, - handleDomMotionClear, selectedGsapAnimations, gsapMultipleTimelines, gsapUnsupportedTimelinePattern, @@ -159,21 +145,6 @@ export function StudioRightPanel({ Layers - {STUDIO_MOTION_PANEL_ENABLED && ( - - - - )} )} @@ -248,14 +219,6 @@ export function StudioRightPanel({ recordingDuration={recordingDuration} onToggleRecording={onToggleRecording} /> - ) : motionPanelActive ? ( - 1 ? null : domEditSelection} - motion={selectedStudioMotion} - onClearSelection={clearDomSelection} - onSetMotion={handleDomMotionCommit} - onClearMotion={handleDomMotionClear} - /> ) : ( { x: number; y: number }, -): string { - const commands: string[] = []; - for (let index = 0; index <= 48; index += 1) { - const point = map(cubicBezierPoint(index / 48, points)); - commands.push(`${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`); - } - return commands.join(" "); -} - -export function EaseCurveEditor({ - points, - onCommit, -}: { - points: StudioCustomEaseControlPoints; - onCommit: (points: StudioCustomEaseControlPoints) => void; -}) { - const svgRef = useRef(null); - const [draft, setDraft] = useState(points); - const draggingRef = useRef<"p1" | "p2" | null>(null); - - useEffect(() => { - setDraft(points); - }, [points]); - - const width = 324; - const height = 214; - const plot = { left: 46, top: 24, width: 242, height: 146 }; - const yMin = -0.4; - const yMax = 1.4; - - const mapPoint = (point: { x: number; y: number }) => ({ - x: plot.left + point.x * plot.width, - y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height, - }); - - const unmapPointer = (event: PointerEvent) => { - const rect = svgRef.current?.getBoundingClientRect(); - if (!rect) return null; - const x = ((event.clientX - rect.left) / rect.width) * width; - const y = ((event.clientY - rect.top) / rect.height) * height; - return clampStudioCustomEasePoints({ - x1: draggingRef.current === "p1" ? (x - plot.left) / plot.width : draft.x1, - y1: - draggingRef.current === "p1" - ? yMax - ((y - plot.top) / plot.height) * (yMax - yMin) - : draft.y1, - x2: draggingRef.current === "p2" ? (x - plot.left) / plot.width : draft.x2, - y2: - draggingRef.current === "p2" - ? yMax - ((y - plot.top) / plot.height) * (yMax - yMin) - : draft.y2, - }); - }; - - const start = mapPoint({ x: 0, y: 0 }); - const end = mapPoint({ x: 1, y: 1 }); - const p1 = mapPoint({ x: draft.x1, y: draft.y1 }); - const p2 = mapPoint({ x: draft.x2, y: draft.y2 }); - const curvePath = buildCurvePath(draft, mapPoint); - - const handlePointerMove = (event: PointerEvent) => { - if (!draggingRef.current) return; - event.preventDefault(); - const next = unmapPointer(event); - if (next) setDraft(next); - }; - - const endDrag = () => { - if (!draggingRef.current) return; - draggingRef.current = null; - onCommit(draft); - }; - - const startDrag = (handle: "p1" | "p2", event: PointerEvent) => { - event.preventDefault(); - event.stopPropagation(); - draggingRef.current = handle; - event.currentTarget.setPointerCapture(event.pointerId); - }; - - return ( -
-
-
-
CustomEase
-
- {serializeStudioCustomEaseData(draft)} -
-
- -
- - - {[0, 0.5, 1].map((value) => { - const mapped = mapPoint({ x: 0, y: value }); - return ( - - - - {value} - - - ); - })} - - - - - - - - startDrag("p1", event)} - /> - startDrag("p2", event)} - /> - - P1 - - - P2 - - -
-
- P1 {formatNumericValue(draft.x1)}, {formatNumericValue(draft.y1)} -
-
- P2 {formatNumericValue(draft.x2)}, {formatNumericValue(draft.y2)} -
-
-
- ); -} diff --git a/packages/studio/src/components/editor/MotionPanel.tsx b/packages/studio/src/components/editor/MotionPanel.tsx deleted file mode 100644 index 2887c0a760..0000000000 --- a/packages/studio/src/components/editor/MotionPanel.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { memo, useMemo } from "react"; -import { X, Zap } from "../../icons/SystemIcons"; -import type { DomEditSelection } from "./domEditing"; -import { - STUDIO_GSAP_EASE_OPTIONS, - buildStudioGsapPresetMotion, - controlPointsForGsapEase, - parseStudioCustomEaseData, - serializeStudioCustomEaseData, - type StudioCustomEaseControlPoints, - type StudioGsapMotion, - type StudioGsapMotionDirection, - type StudioGsapMotionPreset, -} from "./studioMotion"; -import { - formatNumericValue, - clampMotionNumber, - parsePlainNumber, - DetailField, - SegmentedControl, - SelectField, - MotionSection, - RESPONSIVE_GRID, -} from "./MotionPanelFields"; -import { EaseCurveEditor } from "./EaseCurveEditor"; - -/** Motion data without targeting metadata (kind/target/updatedAt are derived from context). */ -type StudioMotionData = Omit; - -interface MotionPanelProps { - element: DomEditSelection | null; - motion: StudioMotionData | null; - onClearSelection: () => void; - onSetMotion: (element: DomEditSelection, motion: StudioMotionData) => void; - onClearMotion: (element: DomEditSelection) => void; -} - -const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPreset }> = [ - { label: "Fade Up", value: "fade-up" }, - { label: "Slide", value: "slide" }, - { label: "Pop", value: "pop" }, -]; - -const MOTION_DIRECTION_OPTIONS: StudioGsapMotionDirection[] = ["up", "down", "left", "right"]; - -function motionValueDistance(motion: StudioMotionData | null): number { - if (!motion) return 32; - return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1); -} - -function inferMotionPreset(motion: StudioMotionData | null): StudioGsapMotionPreset { - if (!motion) return "fade-up"; - if (motion.from.scale != null || motion.to.scale != null) return "pop"; - if (motion.from.x != null || motion.to.x != null) return "slide"; - return "fade-up"; -} - -function inferMotionDirection(motion: StudioMotionData | null): StudioGsapMotionDirection { - if (!motion) return "up"; - const x = motion.from.x ?? 0; - const y = motion.from.y ?? 0; - if (Math.abs(x) > Math.abs(y)) return x < 0 ? "right" : "left"; - return y < 0 ? "down" : "up"; -} - -function buildStudioCustomEaseId(element: DomEditSelection): string { - const source = element.id || element.selector || element.label || "layer"; - const normalized = source - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - return `studio-${normalized || "layer"}-ease`; -} - -export const MotionPanel = memo(function MotionPanel({ - element, - motion, - onClearSelection, - onSetMotion, - onClearMotion, -}: MotionPanelProps) { - const activeMotionPreset = inferMotionPreset(motion); - const activeMotionDirection = inferMotionDirection(motion); - const activeMotionStart = motion?.start ?? 0; - const activeMotionDuration = motion?.duration ?? 0.6; - const activeMotionDistance = motionValueDistance(motion); - const activeMotionEase = motion?.ease ?? "power3.out"; - const customEaseData = motion?.customEase?.data ?? ""; - const customEaseActive = customEaseData.trim().length > 0; - const activeCustomEasePoints = useMemo( - () => - parseStudioCustomEaseData(customEaseData) ?? - controlPointsForGsapEase( - STUDIO_GSAP_EASE_OPTIONS.includes( - activeMotionEase as (typeof STUDIO_GSAP_EASE_OPTIONS)[number], - ) - ? activeMotionEase - : "power3.out", - ), - [activeMotionEase, customEaseData], - ); - - if (!element) { - return ( -
- -

Select an element for motion.

-

- Timeline layers and inspector selections can receive Studio-authored GSAP motion. -

-
- ); - } - - const sourceLabel = element.id ? `#${element.id}` : element.selector; - const easeSelectValue = customEaseActive - ? "CustomEase" - : STUDIO_GSAP_EASE_OPTIONS.includes( - activeMotionEase as (typeof STUDIO_GSAP_EASE_OPTIONS)[number], - ) - ? activeMotionEase - : "power3.out"; - const easeSelectOptions = customEaseActive - ? ["CustomEase", ...STUDIO_GSAP_EASE_OPTIONS] - : STUDIO_GSAP_EASE_OPTIONS; - - const commitMotion = ( - overrides: Partial<{ - preset: StudioGsapMotionPreset; - direction: StudioGsapMotionDirection; - start: number; - duration: number; - distance: number; - ease: string; - customEaseData: string; - }>, - ) => { - const customEaseText = overrides.customEaseData ?? customEaseData; - const customEase = customEaseText.trim() - ? { - id: motion?.customEase?.id ?? buildStudioCustomEaseId(element), - data: customEaseText.trim(), - } - : undefined; - const nextEase = customEase - ? customEase.id - : (overrides.ease ?? activeMotionEase).trim() || "none"; - onSetMotion( - element, - buildStudioGsapPresetMotion(overrides.preset ?? activeMotionPreset, { - start: clampMotionNumber(overrides.start ?? activeMotionStart, 0, 3600, 0), - duration: clampMotionNumber(overrides.duration ?? activeMotionDuration, 0.01, 3600, 0.6), - distance: clampMotionNumber(overrides.distance ?? activeMotionDistance, 1, 2000, 32), - direction: overrides.direction ?? activeMotionDirection, - ease: nextEase, - customEase, - }), - ); - }; - - const commitCustomEase = (points: StudioCustomEaseControlPoints) => { - commitMotion({ - ease: buildStudioCustomEaseId(element), - customEaseData: serializeStudioCustomEaseData(points), - }); - }; - - return ( -
-
-
-
-
- Motion Target -
-
- {element.label} -
-
{sourceLabel}
-
- -
-
- -
- - GSAP -
- } - > -
- commitMotion({ preset: next as StudioGsapMotionPreset })} - options={MOTION_PRESET_OPTIONS} - /> -
- commitMotion({ direction: next as StudioGsapMotionDirection })} - options={MOTION_DIRECTION_OPTIONS} - /> - { - if (next === "CustomEase") return; - commitMotion({ ease: next, customEaseData: "" }); - }} - options={easeSelectOptions} - /> -
-
- commitMotion({ start: parsePlainNumber(next) ?? 0 })} - /> - commitMotion({ duration: parsePlainNumber(next) ?? 0.6 })} - /> - commitMotion({ distance: parsePlainNumber(next) ?? 32 })} - /> -
-
- - - - CustomEase -
- } - > -
- - { - const parsed = parseStudioCustomEaseData(next); - if (parsed) commitCustomEase(parsed); - }} - /> -
- -
-
- - - - ); -}); diff --git a/packages/studio/src/components/editor/MotionPanelFields.tsx b/packages/studio/src/components/editor/MotionPanelFields.tsx deleted file mode 100644 index 15b64d2902..0000000000 --- a/packages/studio/src/components/editor/MotionPanelFields.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useState, useRef, useEffect, type ReactNode } from "react"; -import { Zap } from "../../icons/SystemIcons"; - -const FIELD = - "min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600"; -export const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500"; -export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3"; - -export function formatNumericValue(value: number): string { - const rounded = Math.round(value * 100) / 100; - return Number.isInteger(rounded) - ? `${rounded}` - : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); -} - -export function clampMotionNumber( - value: number | null, - min: number, - max: number, - fallback: number, -): number { - if (value == null || !Number.isFinite(value)) return fallback; - return Math.min(max, Math.max(min, value)); -} - -export function parsePlainNumber(value: string): number | null { - const parsed = Number.parseFloat(value.trim()); - return Number.isFinite(parsed) ? parsed : null; -} - -// ── CommitField ── - -function CommitField({ - value, - disabled, - onCommit, -}: { - value: string; - disabled?: boolean; - onCommit: (nextValue: string) => void; -}) { - const [draft, setDraft] = useState(value); - const focusedRef = useRef(false); - - useEffect(() => { - if (!focusedRef.current) setDraft(value); - }, [value]); - - const commitDraft = () => { - focusedRef.current = false; - const next = draft.trim(); - if (next !== value) onCommit(next); - }; - - return ( - { - focusedRef.current = true; - }} - onChange={(event) => setDraft(event.target.value)} - onBlur={commitDraft} - onKeyDown={(event) => { - if (event.key === "Enter") (event.target as HTMLInputElement).blur(); - }} - className="w-full min-w-0 bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600" - /> - ); -} - -// ── DetailField ── - -export function DetailField({ - label, - value, - disabled, - onCommit, -}: { - label: string; - value: string; - disabled?: boolean; - onCommit: (nextValue: string) => void; -}) { - return ( - - ); -} - -// ── SegmentedControl ── - -export function SegmentedControl({ - value, - options, - onChange, -}: { - value: string; - options: Array<{ label: string; value: string }>; - onChange: (value: string) => void; -}) { - return ( -
- {options.map((option) => ( - - ))} -
- ); -} - -// ── SelectField ── - -export function SelectField({ - label, - value, - options, - onChange, -}: { - label: string; - value: string; - options: readonly string[]; - onChange: (value: string) => void; -}) { - return ( - - ); -} - -// ── MotionSection ── - -export function MotionSection({ - title, - children, - accessory, -}: { - title: string; - children: ReactNode; - accessory?: ReactNode; -}) { - return ( -
-
-
- -

- {title} -

-
- {accessory} -
- {children} -
- ); -} diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx deleted file mode 100644 index 49ea39dd00..0000000000 --- a/packages/studio/src/components/editor/MotionPathOverlay.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { memo, useMemo, type RefObject } from "react"; -import type { ArcPathConfig } from "@hyperframes/core/gsap-parser"; - -interface MotionPathOverlayProps { - iframeRef: RefObject; - arcPath: ArcPathConfig | null; - waypoints: Array<{ x: number; y: number }> | null; - elementBaseRect: { left: number; top: number; scaleX: number; scaleY: number } | null; -} - -function buildSvgPath( - waypoints: Array<{ x: number; y: number }>, - segments: ArcPathConfig["segments"], - base: { left: number; top: number; scaleX: number; scaleY: number }, -): string { - if (waypoints.length < 2) return ""; - - const toPixel = (wp: { x: number; y: number }) => ({ - x: base.left + wp.x * base.scaleX, - y: base.top + wp.y * base.scaleY, - }); - - const first = toPixel(waypoints[0]!); - const parts = [`M ${first.x} ${first.y}`]; - - for (let i = 0; i < segments.length && i < waypoints.length - 1; i++) { - const seg = segments[i]!; - const end = toPixel(waypoints[i + 1]!); - - if (seg.cp1 && seg.cp2) { - const c1 = toPixel(seg.cp1); - const c2 = toPixel(seg.cp2); - parts.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`); - } else { - const start = toPixel(waypoints[i]!); - const dx = end.x - start.x; - const dy = end.y - start.y; - const c = seg.curviness ?? 1; - const offset = c * Math.abs(dx) * 0.25; - const c1x = start.x + dx * 0.33; - const c1y = start.y + dy * 0.33 - offset; - const c2x = start.x + dx * 0.66; - const c2y = start.y + dy * 0.66 - offset; - parts.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${end.x} ${end.y}`); - } - } - - return parts.join(" "); -} - -export const MotionPathOverlay = memo(function MotionPathOverlay({ - arcPath, - waypoints, - elementBaseRect, -}: MotionPathOverlayProps) { - const pathD = useMemo(() => { - if (!arcPath?.enabled || !waypoints || waypoints.length < 2 || !elementBaseRect) return ""; - return buildSvgPath(waypoints, arcPath.segments, elementBaseRect); - }, [arcPath, waypoints, elementBaseRect]); - - const anchorPoints = useMemo(() => { - if (!waypoints || !elementBaseRect) return []; - return waypoints.map((wp) => ({ - x: elementBaseRect.left + wp.x * elementBaseRect.scaleX, - y: elementBaseRect.top + wp.y * elementBaseRect.scaleY, - })); - }, [waypoints, elementBaseRect]); - - const controlPoints = useMemo(() => { - if (!arcPath?.enabled || !elementBaseRect) return []; - const points: Array<{ - segIndex: number; - type: "cp1" | "cp2"; - x: number; - y: number; - anchorX: number; - anchorY: number; - }> = []; - for (let i = 0; i < arcPath.segments.length; i++) { - const seg = arcPath.segments[i]!; - if (seg.cp1 && seg.cp2 && waypoints) { - const anchor1 = waypoints[i]!; - const anchor2 = waypoints[i + 1]!; - points.push({ - segIndex: i, - type: "cp1", - x: elementBaseRect.left + seg.cp1.x * elementBaseRect.scaleX, - y: elementBaseRect.top + seg.cp1.y * elementBaseRect.scaleY, - anchorX: elementBaseRect.left + anchor1.x * elementBaseRect.scaleX, - anchorY: elementBaseRect.top + anchor1.y * elementBaseRect.scaleY, - }); - points.push({ - segIndex: i, - type: "cp2", - x: elementBaseRect.left + seg.cp2.x * elementBaseRect.scaleX, - y: elementBaseRect.top + seg.cp2.y * elementBaseRect.scaleY, - anchorX: elementBaseRect.left + anchor2.x * elementBaseRect.scaleX, - anchorY: elementBaseRect.top + anchor2.y * elementBaseRect.scaleY, - }); - } - } - return points; - }, [arcPath, waypoints, elementBaseRect]); - - if (!pathD) return null; - - return ( - - - - {controlPoints.map((cp) => ( - - - - - ))} - - {anchorPoints.map((pt, i) => ( - - ))} - - ); -}); diff --git a/packages/studio/src/components/editor/SpringEaseEditor.tsx b/packages/studio/src/components/editor/SpringEaseEditor.tsx deleted file mode 100644 index 852f2a32da..0000000000 --- a/packages/studio/src/components/editor/SpringEaseEditor.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from "react"; -import { generateSpringEaseData, SPRING_PRESETS } from "@hyperframes/core/spring-ease"; -import { LABEL } from "./MotionPanelFields"; -import { RotateCcw } from "../../icons/SystemIcons"; - -interface SpringParams { - mass: number; - stiffness: number; - damping: number; -} - -const DEFAULT_SPRING: SpringParams = { mass: 1, stiffness: 180, damping: 12 }; - -const SLIDERS: { - key: keyof SpringParams; - label: string; - min: number; - max: number; - step: number; -}[] = [ - { key: "mass", label: "Mass", min: 0.1, max: 5, step: 0.1 }, - { key: "stiffness", label: "Stiffness", min: 10, max: 500, step: 10 }, - { key: "damping", label: "Damping", min: 1, max: 50, step: 1 }, -]; - -function springValue(mass: number, stiffness: number, damping: number, t: number): number { - const w0 = Math.sqrt(stiffness / mass); - const zeta = damping / (2 * Math.sqrt(stiffness * mass)); - if (zeta < 1) { - const wd = w0 * Math.sqrt(1 - zeta * zeta); - return ( - 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t)) - ); - } - if (zeta === 1) { - return 1 - (1 + w0 * t) * Math.exp(-w0 * t); - } - const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1)); - const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1)); - return 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1); -} - -function springSimDuration(mass: number, stiffness: number, damping: number): number { - const w0 = Math.sqrt(stiffness / mass); - const zeta = damping / (2 * Math.sqrt(stiffness * mass)); - if (zeta < 1) return Math.min(5 / (zeta * w0), 10); - const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1); - return Math.min(4 / Math.max(decayRate, 0.01), 10); -} - -function buildSpringPath( - params: SpringParams, - mapFn: (point: { x: number; y: number }) => { x: number; y: number }, -): string { - const steps = 64; - const simDur = springSimDuration(params.mass, params.stiffness, params.damping); - const commands: string[] = []; - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const simT = t * simDur; - const y = springValue(params.mass, params.stiffness, params.damping, simT); - const mapped = mapFn({ x: t, y }); - commands.push(`${i === 0 ? "M" : "L"}${mapped.x.toFixed(2)},${mapped.y.toFixed(2)}`); - } - return commands.join(" "); -} - -export function SpringEaseEditor({ - onCommit, -}: { - onCommit: (easeId: string, easeData: string) => void; -}) { - const [params, setParams] = useState(DEFAULT_SPRING); - const commitTimeoutRef = useRef | null>(null); - - const scheduleCommit = useCallback( - (next: SpringParams) => { - if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current); - commitTimeoutRef.current = setTimeout(() => { - const data = generateSpringEaseData(next.mass, next.stiffness, next.damping); - const id = `spring-m${next.mass}-k${next.stiffness}-d${next.damping}`; - onCommit(id, data); - }, 120); - }, - [onCommit], - ); - - useEffect(() => { - return () => { - if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current); - }; - }, []); - - const updateParam = (key: keyof SpringParams, value: number) => { - const next = { ...params, [key]: value }; - setParams(next); - scheduleCommit(next); - }; - - const applyPreset = (preset: (typeof SPRING_PRESETS)[number]) => { - const next: SpringParams = { - mass: preset.mass, - stiffness: preset.stiffness, - damping: preset.damping, - }; - setParams(next); - const data = generateSpringEaseData(next.mass, next.stiffness, next.damping); - onCommit(preset.name, data); - }; - - const reset = () => { - setParams(DEFAULT_SPRING); - const data = generateSpringEaseData( - DEFAULT_SPRING.mass, - DEFAULT_SPRING.stiffness, - DEFAULT_SPRING.damping, - ); - onCommit("spring-bouncy", data); - }; - - // SVG layout matching EaseCurveEditor proportions - const width = 324; - const height = 214; - const plot = { left: 46, top: 24, width: 242, height: 146 }; - const yMin = -0.2; - const yMax = 1.3; - - const mapPoint = (point: { x: number; y: number }) => ({ - x: plot.left + point.x * plot.width, - y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height, - }); - - const curvePath = buildSpringPath(params, mapPoint); - const start = mapPoint({ x: 0, y: 0 }); - const end = mapPoint({ x: 1, y: 1 }); - - const activePreset = SPRING_PRESETS.find( - (p) => - p.mass === params.mass && p.stiffness === params.stiffness && p.damping === params.damping, - ); - - return ( -
-
-
-
Spring Ease
-
- {activePreset?.label ?? `m${params.mass} k${params.stiffness} d${params.damping}`} -
-
- -
- - {/* Curve preview */} - - - {[0, 0.5, 1].map((value) => { - const mapped = mapPoint({ x: 0, y: value }); - return ( - - - - {value} - - - ); - })} - - - - - - - - {/* Presets */} -
- {SPRING_PRESETS.map((preset) => { - const isActive = - preset.mass === params.mass && - preset.stiffness === params.stiffness && - preset.damping === params.damping; - return ( - - ); - })} -
- - {/* Sliders */} -
- {SLIDERS.map((slider) => ( -
-
- - {slider.label} - - - {params[slider.key]} - -
- updateParam(slider.key, Number(e.target.value))} - className="h-1 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-yellow-400 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-yellow-400" - /> -
- ))} -
-
- ); -} diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index 0abab2d5f5..58aea35401 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -16,13 +16,12 @@ describe("manual editing availability", () => { vi.resetModules(); }); - it("enables inspector selection and manual dragging by default while motion stays opt-in", async () => { + it("enables inspector selection and manual dragging by default", async () => { const availability = await loadAvailabilityWithEnv({}); expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(true); expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true); expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true); - expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false); }); it("enables GSAP drag intercept by default", async () => { diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index f7c8de0053..7c702ac5f5 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -2,7 +2,6 @@ export type StudioFeatureFlagEnv = Record; const STUDIO_PREVIEW_MANUAL_DRAGGING_ENV = "VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING"; const STUDIO_INSPECTOR_PANELS_ENV = "VITE_STUDIO_ENABLE_INSPECTOR_PANELS"; -const STUDIO_MOTION_PANEL_ENV = "VITE_STUDIO_ENABLE_MOTION_PANEL"; const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "enabled"]); const FALSY_ENV_VALUES = new Set(["0", "false", "no", "off", "disabled"]); @@ -53,12 +52,6 @@ export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag( true, ); -export const STUDIO_MOTION_PANEL_ENABLED = resolveStudioBooleanEnvFlag( - env, - [STUDIO_MOTION_PANEL_ENV, "VITE_STUDIO_MOTION_PANEL_ENABLED"], - false, -); - export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_BLOCKS_PANEL", "VITE_STUDIO_BLOCKS_PANEL_ENABLED"], diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index e75e925de1..a0ee1403cf 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -37,8 +37,7 @@ export function DomEditProvider({ handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, - handleDomMotionCommit, - handleDomMotionClear, + handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, @@ -111,8 +110,6 @@ export function DomEditProvider({ handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, - handleDomMotionCommit, - handleDomMotionClear, handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, @@ -179,8 +176,6 @@ export function DomEditProvider({ handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, - handleDomMotionCommit, - handleDomMotionClear, handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 8c59b69e5b..e70e9f038f 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -27,15 +27,7 @@ import { buildClearPathOffsetPatches, buildClearBoxSizePatches, buildClearRotationPatches, - buildMotionPatches, - buildClearMotionPatches, } from "../components/editor/manualEditsDom"; -import { - writeStudioMotionToElement, - clearStudioMotionFromElement, - applyStudioMotionFromDom, - type StudioGsapMotion, -} from "../components/editor/studioMotion"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -400,64 +392,6 @@ export function useDomEditCommits({ [commitPositionPatchToHtml], ); - // ── Motion commits (HTML-attribute–backed) ── - - // fallow-ignore-next-line complexity - const handleDomMotionCommit = useCallback( - ( - selection: DomEditSelection, - motion: Omit, - ) => { - // 1. Write motion data as JSON attribute on the element - writeStudioMotionToElement(selection.element, motion); - // 2. Apply the GSAP timeline from DOM attributes - let doc: Document | null = null; - try { - doc = previewIframeRef.current?.contentDocument ?? null; - } catch { - // cross-origin guard - } - if (doc) applyStudioMotionFromDom(doc); - // 3. Build patches and persist to HTML - const patches = buildMotionPatches(selection.element); - commitPositionPatchToHtml(selection, patches, { - label: "Set GSAP motion", - coalesceKey: `motion:${getDomEditTargetKey(selection)}`, - }); - refreshDomEditSelectionFromPreview(selection); - }, - [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview], - ); - - // fallow-ignore-next-line complexity - const handleDomMotionClear = useCallback( - (selection: DomEditSelection) => { - const clearPatches = buildClearMotionPatches(selection.element); - // Get gsap from the preview window for proper cleanup - let gsap: { set?: (target: HTMLElement, vars: Record) => void } | undefined; - try { - gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap; - } catch { - // cross-origin guard - } - clearStudioMotionFromElement(selection.element, gsap); - let doc: Document | null = null; - try { - doc = previewIframeRef.current?.contentDocument ?? null; - } catch { - // cross-origin guard - } - if (doc) applyStudioMotionFromDom(doc); - commitPositionPatchToHtml(selection, clearPatches, { - label: "Clear GSAP motion", - coalesceKey: `motion:${getDomEditTargetKey(selection)}`, - skipRefresh: false, - }); - refreshDomEditSelectionFromPreview(selection); - }, - [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview], - ); - // fallow-ignore-next-line complexity const handleDomEditElementDelete = useCallback( // fallow-ignore-next-line complexity @@ -592,8 +526,6 @@ export function useDomEditCommits({ handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, - handleDomMotionCommit, - handleDomMotionClear, handleDomEditElementDelete, handleDomZIndexReorderCommit, }; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 4bb7a2d08e..6636efcafd 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -303,8 +303,6 @@ export function useDomEditSession({ handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, - handleDomMotionCommit, - handleDomMotionClear, handleDomEditElementDelete, handleDomZIndexReorderCommit, } = useDomEditCommits({ @@ -536,8 +534,6 @@ export function useDomEditSession({ handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit, handleDomRotationCommit: handleGsapAwareRotationCommit, handleDomManualEditsReset, - handleDomMotionCommit, - handleDomMotionClear, handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index ab1d1c8a85..9f56084bea 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -1,11 +1,6 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react"; -import { - STUDIO_INSPECTOR_PANELS_ENABLED, - STUDIO_MOTION_PANEL_ENABLED, -} from "../components/editor/manualEditingAvailability"; -import { readStudioMotionFromElement } from "../components/editor/studioMotion"; +import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; import type { StudioContextValue } from "../contexts/StudioContext"; -import type { DomEditSelection } from "../components/editor/domEditing"; interface StudioContextInput { projectId: string; @@ -66,10 +61,8 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex } export interface InspectorState { - selectedStudioMotion: ReturnType | null; layersPanelActive: boolean; designPanelActive: boolean; - motionPanelActive: boolean; inspectorPanelActive: boolean; inspectorButtonActive: boolean; shouldShowSelectedDomBounds: boolean; @@ -79,32 +72,23 @@ export function useInspectorState( rightPanelTab: string, rightCollapsed: boolean, isPlaying: boolean, - domEditSelection: DomEditSelection | null, isGestureRecording?: boolean, ): InspectorState { // fallow-ignore-next-line complexity return useMemo(() => { - const selectedStudioMotion = - STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection - ? readStudioMotionFromElement(domEditSelection.element) - : null; const layersPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "layers"; const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design"; - const motionPanelActive = - STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion"; - const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive; + const inspectorPanelActive = layersPanelActive || designPanelActive; return { - selectedStudioMotion, layersPanelActive, designPanelActive, - motionPanelActive, inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording, }; - }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]); + }, [rightPanelTab, rightCollapsed, isPlaying, isGestureRecording]); } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index b193364c4b..91da8232da 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -12,7 +12,7 @@ export interface AppToast { tone: "error" | "info"; } -export type RightPanelTab = "layers" | "design" | "motion" | "renders" | "block-params"; +export type RightPanelTab = "layers" | "design" | "renders" | "block-params"; export interface AgentModalAnchorPoint { x: number; diff --git a/packages/studio/src/utils/studioUrlState.test.ts b/packages/studio/src/utils/studioUrlState.test.ts index 6b9620a8a5..be0870c8f9 100644 --- a/packages/studio/src/utils/studioUrlState.test.ts +++ b/packages/studio/src/utils/studioUrlState.test.ts @@ -159,7 +159,6 @@ describe("studio url state", () => { it("normalizes url tabs against feature flags", () => { expect(normalizeStudioUrlPanelTab("renders")).toBe("renders"); expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders"); - expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design"); }); it("hydrates seek first, preserves the initial url state, then restores selection", async () => { diff --git a/packages/studio/src/utils/studioUrlState.ts b/packages/studio/src/utils/studioUrlState.ts index 57af39b520..816178e0e5 100644 --- a/packages/studio/src/utils/studioUrlState.ts +++ b/packages/studio/src/utils/studioUrlState.ts @@ -1,9 +1,6 @@ import type { RightPanelTab } from "./studioHelpers"; import { buildProjectHash, parseProjectHashRoute } from "./projectRouting"; -import { - STUDIO_INSPECTOR_PANELS_ENABLED, - STUDIO_MOTION_PANEL_ENABLED, -} from "../components/editor/manualEditingAvailability"; +import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; export interface StudioUrlSelectionState { sourceFile?: string; @@ -21,22 +18,19 @@ export interface StudioUrlState { selection: StudioUrlSelectionState | null; } -const VALID_TABS: RightPanelTab[] = ["layers", "design", "motion", "renders"]; +const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders"]; export function normalizeStudioUrlPanelTab( tab: RightPanelTab | null, options: { inspectorPanelsEnabled?: boolean; - motionPanelEnabled?: boolean; } = {}, ): RightPanelTab | null { if (!tab) return null; if (!VALID_TABS.includes(tab)) return null; const inspectorPanelsEnabled = options.inspectorPanelsEnabled ?? STUDIO_INSPECTOR_PANELS_ENABLED; - const motionPanelEnabled = options.motionPanelEnabled ?? STUDIO_MOTION_PANEL_ENABLED; if (!inspectorPanelsEnabled && tab !== "renders") return "renders"; - if (tab === "motion" && !motionPanelEnabled) return "design"; return tab; }