fix(studio): remove motion tab from right panel (#1391)

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.

Co-authored-by: Miguel Ángel <miguelangelsisi098@gmail.com>
This commit is contained in:
Miguel Ángel
2026-06-12 16:37:05 -04:00
committed by GitHub
co-authored by Miguel Ángel
parent ad5229af83
commit d0a7f7d839
16 changed files with 9 additions and 1244 deletions
-5
View File
@@ -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 && (
<StudioRightPanel
selectedStudioMotion={selectedStudioMotion}
designPanelActive={designPanelActive}
motionPanelActive={motionPanelActive}
activeBlockParams={activeBlockParams}
onCloseBlockParams={() => {
setActiveBlockParams(null);
@@ -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<StudioGsapMotion, "kind" | "target" | "updatedAt">;
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
</button>
</Tooltip>
{STUDIO_MOTION_PANEL_ENABLED && (
<Tooltip label="Animation and motion" side="bottom">
<button
type="button"
onClick={() => setRightPanelTab("motion")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
rightPanelTab === "motion"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
Motion
</button>
</Tooltip>
)}
</>
)}
<Tooltip label="Render queue and exports" side="bottom">
@@ -248,14 +219,6 @@ export function StudioRightPanel({
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
) : motionPanelActive ? (
<MotionPanel
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
motion={selectedStudioMotion}
onClearSelection={clearDomSelection}
onSetMotion={handleDomMotionCommit}
onClearMotion={handleDomMotionClear}
/>
) : (
<RenderQueue
jobs={renderJobs}
@@ -1,221 +0,0 @@
import { useState, useRef, useEffect, type PointerEvent } from "react";
import { RotateCcw } from "../../icons/SystemIcons";
import {
clampStudioCustomEasePoints,
controlPointsForGsapEase,
serializeStudioCustomEaseData,
type StudioCustomEaseControlPoints,
} from "./studioMotion";
import { LABEL } from "./MotionPanelFields";
function formatNumericValue(value: number): string {
const rounded = Math.round(value * 100) / 100;
return Number.isInteger(rounded)
? `${rounded}`
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
}
function cubicBezierPoint(t: number, p1: StudioCustomEaseControlPoints): { x: number; y: number } {
const inv = 1 - t;
const inv2 = inv * inv;
const t2 = t * t;
return {
x: 3 * inv2 * t * p1.x1 + 3 * inv * t2 * p1.x2 + t2 * t,
y: 3 * inv2 * t * p1.y1 + 3 * inv * t2 * p1.y2 + t2 * t,
};
}
function buildCurvePath(
points: StudioCustomEaseControlPoints,
map: (point: { x: number; y: number }) => { 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<SVGSVGElement | null>(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<SVGSVGElement>) => {
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<SVGSVGElement>) => {
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<SVGCircleElement>) => {
event.preventDefault();
event.stopPropagation();
draggingRef.current = handle;
event.currentTarget.setPointerCapture(event.pointerId);
};
return (
<div className="overflow-hidden rounded-2xl border border-neutral-800 bg-black/40">
<div className="flex items-center justify-between gap-3 border-b border-neutral-800 px-3 py-2">
<div>
<div className={LABEL}>CustomEase</div>
<div className="mt-1 font-mono text-[10px] text-neutral-500">
{serializeStudioCustomEaseData(draft)}
</div>
</div>
<button
type="button"
onClick={() => {
const reset = controlPointsForGsapEase("power3.out");
setDraft(reset);
onCommit(reset);
}}
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-100"
>
<RotateCcw size={13} />
Reset
</button>
</div>
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
className="block w-full select-none touch-none"
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<rect x="0" y="0" width={width} height={height} fill="transparent" />
{[0, 0.5, 1].map((value) => {
const mapped = mapPoint({ x: 0, y: value });
return (
<g key={value}>
<line
x1={plot.left}
x2={plot.left + plot.width}
y1={mapped.y}
y2={mapped.y}
stroke="rgba(255,255,255,0.12)"
strokeDasharray="5 8"
/>
<text
x={plot.left - 12}
y={mapped.y + 4}
textAnchor="end"
className="fill-neutral-500 text-[10px] font-semibold"
>
{value}
</text>
</g>
);
})}
<line
x1={plot.left}
x2={plot.left + plot.width}
y1={plot.top + plot.height}
y2={plot.top + plot.height}
stroke="rgba(255,255,255,0.18)"
/>
<line
x1={plot.left}
x2={plot.left}
y1={plot.top}
y2={plot.top + plot.height}
stroke="rgba(255,255,255,0.18)"
/>
<line x1={start.x} y1={start.y} x2={p1.x} y2={p1.y} stroke="rgba(255,221,87,0.34)" />
<line x1={end.x} y1={end.y} x2={p2.x} y2={p2.y} stroke="rgba(255,221,87,0.34)" />
<path d={curvePath} fill="none" stroke="#ffdd57" strokeWidth="4" strokeLinecap="round" />
<circle cx={start.x} cy={start.y} r="5" fill="#ffdd57" />
<circle cx={end.x} cy={end.y} r="5" fill="#ffdd57" />
<circle
cx={p1.x}
cy={p1.y}
r="9"
fill="#141414"
stroke="#ffdd57"
strokeWidth="4"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(event) => startDrag("p1", event)}
/>
<circle
cx={p2.x}
cy={p2.y}
r="9"
fill="#141414"
stroke="#ffdd57"
strokeWidth="4"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(event) => startDrag("p2", event)}
/>
<text x={p1.x + 12} y={p1.y - 10} className="fill-neutral-400 text-[10px] font-semibold">
P1
</text>
<text x={p2.x + 12} y={p2.y - 10} className="fill-neutral-400 text-[10px] font-semibold">
P2
</text>
</svg>
<div className="grid grid-cols-2 gap-2 border-t border-neutral-800 p-3">
<div className="rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-[10px] text-neutral-400">
P1 {formatNumericValue(draft.x1)}, {formatNumericValue(draft.y1)}
</div>
<div className="rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-[10px] text-neutral-400">
P2 {formatNumericValue(draft.x2)}, {formatNumericValue(draft.y2)}
</div>
</div>
</div>
);
}
@@ -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<StudioGsapMotion, "kind" | "target" | "updatedAt">;
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 (
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
<Zap size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">Select an element for motion.</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
Timeline layers and inspector selections can receive Studio-authored GSAP motion.
</p>
</div>
);
}
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 (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
<div className="border-b border-neutral-800 px-4 py-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500">
Motion Target
</div>
<div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
{element.label}
</div>
<div className="mt-1 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<button
type="button"
aria-label="Clear selection"
onClick={onClearSelection}
className="flex h-9 w-9 items-center justify-center rounded-full border border-neutral-700 bg-neutral-950 text-neutral-500 shadow-[0_1px_2px_rgba(0,0,0,0.2)] transition-colors hover:border-neutral-600 hover:text-neutral-200"
>
<X size={13} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<MotionSection
title="GSAP Motion"
accessory={
<div className="rounded-full border border-studio-accent/40 bg-studio-accent/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-studio-accent">
GSAP
</div>
}
>
<div className="space-y-4">
<SegmentedControl
value={activeMotionPreset}
onChange={(next) => commitMotion({ preset: next as StudioGsapMotionPreset })}
options={MOTION_PRESET_OPTIONS}
/>
<div className={RESPONSIVE_GRID}>
<SelectField
label="Direction"
value={activeMotionDirection}
onChange={(next) => commitMotion({ direction: next as StudioGsapMotionDirection })}
options={MOTION_DIRECTION_OPTIONS}
/>
<SelectField
label="Ease"
value={easeSelectValue}
onChange={(next) => {
if (next === "CustomEase") return;
commitMotion({ ease: next, customEaseData: "" });
}}
options={easeSelectOptions}
/>
</div>
<div className={RESPONSIVE_GRID}>
<DetailField
label="Start"
value={formatNumericValue(activeMotionStart)}
onCommit={(next) => commitMotion({ start: parsePlainNumber(next) ?? 0 })}
/>
<DetailField
label="Duration"
value={formatNumericValue(activeMotionDuration)}
onCommit={(next) => commitMotion({ duration: parsePlainNumber(next) ?? 0.6 })}
/>
<DetailField
label="Distance"
value={formatNumericValue(activeMotionDistance)}
onCommit={(next) => commitMotion({ distance: parsePlainNumber(next) ?? 32 })}
/>
</div>
</div>
</MotionSection>
<MotionSection
title="Ease Curve"
accessory={
<div className="rounded-full border border-yellow-400/30 bg-yellow-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-yellow-300">
CustomEase
</div>
}
>
<div className="space-y-4">
<EaseCurveEditor points={activeCustomEasePoints} onCommit={commitCustomEase} />
<DetailField
label="CustomEase path"
value={customEaseData}
onCommit={(next) => {
const parsed = parseStudioCustomEaseData(next);
if (parsed) commitCustomEase(parsed);
}}
/>
<div className="flex justify-end">
<button
type="button"
onClick={() => onClearMotion(element)}
disabled={!motion}
className="inline-flex h-8 items-center rounded-xl border border-neutral-700 bg-neutral-950 px-3 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:border-neutral-800 disabled:text-neutral-600"
>
Clear motion
</button>
</div>
</div>
</MotionSection>
</div>
</div>
);
});
@@ -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 (
<input
type="text"
value={draft}
disabled={disabled}
onFocus={() => {
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 (
<label className="grid min-w-0 gap-1.5">
<span className={LABEL}>{label}</span>
<div className={FIELD}>
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
</div>
</label>
);
}
// ── SegmentedControl ──
export function SegmentedControl({
value,
options,
onChange,
}: {
value: string;
options: Array<{ label: string; value: string }>;
onChange: (value: string) => void;
}) {
return (
<div className="grid grid-cols-3 gap-1 rounded-2xl border border-neutral-800 bg-neutral-950 p-1">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`h-9 rounded-xl text-[11px] font-semibold transition-colors ${
option.value === value
? "bg-neutral-800 text-white shadow-sm"
: "text-neutral-500 hover:bg-neutral-900 hover:text-neutral-200"
}`}
>
{option.label}
</button>
))}
</div>
);
}
// ── SelectField ──
export function SelectField({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: readonly string[];
onChange: (value: string) => void;
}) {
return (
<label className="grid min-w-0 gap-1.5">
<span className={LABEL}>{label}</span>
<div className={FIELD}>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full min-w-0 appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none"
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</label>
);
}
// ── MotionSection ──
export function MotionSection({
title,
children,
accessory,
}: {
title: string;
children: ReactNode;
accessory?: ReactNode;
}) {
return (
<section className="border-b border-neutral-800 px-4 py-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Zap size={15} className="text-neutral-500" />
<h3 className="text-[11px] font-semibold uppercase tracking-[0.22em] text-neutral-300">
{title}
</h3>
</div>
{accessory}
</div>
{children}
</section>
);
}
@@ -1,146 +0,0 @@
import { memo, useMemo, type RefObject } from "react";
import type { ArcPathConfig } from "@hyperframes/core/gsap-parser";
interface MotionPathOverlayProps {
iframeRef: RefObject<HTMLIFrameElement | null>;
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 (
<svg className="absolute inset-0 pointer-events-none z-20 overflow-visible">
<path d={pathD} fill="none" stroke="rgba(45, 212, 191, 0.4)" strokeWidth={2} />
{controlPoints.map((cp) => (
<g key={`${cp.segIndex}-${cp.type}`}>
<line
x1={cp.anchorX}
y1={cp.anchorY}
x2={cp.x}
y2={cp.y}
stroke="rgba(167, 139, 250, 0.3)"
strokeWidth={1}
strokeDasharray="3 2"
/>
<circle
cx={cp.x}
cy={cp.y}
r={4}
fill="#a78bfa"
className="pointer-events-auto cursor-grab"
/>
</g>
))}
{anchorPoints.map((pt, i) => (
<circle
key={i}
cx={pt.x}
cy={pt.y}
r={5}
fill="#3CE6AC"
stroke="rgba(255,255,255,0.5)"
strokeWidth={1}
className="pointer-events-auto cursor-pointer"
/>
))}
</svg>
);
});
@@ -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<SpringParams>(DEFAULT_SPRING);
const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="overflow-hidden rounded-2xl border border-neutral-800 bg-black/40">
<div className="flex items-center justify-between gap-3 border-b border-neutral-800 px-3 py-2">
<div>
<div className={LABEL}>Spring Ease</div>
<div className="mt-1 font-mono text-[10px] text-neutral-500">
{activePreset?.label ?? `m${params.mass} k${params.stiffness} d${params.damping}`}
</div>
</div>
<button
type="button"
onClick={reset}
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-100"
>
<RotateCcw size={13} />
Reset
</button>
</div>
{/* Curve preview */}
<svg viewBox={`0 0 ${width} ${height}`} className="block w-full select-none touch-none">
<rect x="0" y="0" width={width} height={height} fill="transparent" />
{[0, 0.5, 1].map((value) => {
const mapped = mapPoint({ x: 0, y: value });
return (
<g key={value}>
<line
x1={plot.left}
x2={plot.left + plot.width}
y1={mapped.y}
y2={mapped.y}
stroke="rgba(255,255,255,0.12)"
strokeDasharray="5 8"
/>
<text
x={plot.left - 12}
y={mapped.y + 4}
textAnchor="end"
className="fill-neutral-500 text-[10px] font-semibold"
>
{value}
</text>
</g>
);
})}
<line
x1={plot.left}
x2={plot.left + plot.width}
y1={plot.top + plot.height}
y2={plot.top + plot.height}
stroke="rgba(255,255,255,0.18)"
/>
<line
x1={plot.left}
x2={plot.left}
y1={plot.top}
y2={plot.top + plot.height}
stroke="rgba(255,255,255,0.18)"
/>
<path d={curvePath} fill="none" stroke="#ffdd57" strokeWidth="4" strokeLinecap="round" />
<circle cx={start.x} cy={start.y} r="5" fill="#ffdd57" />
<circle cx={end.x} cy={end.y} r="5" fill="#ffdd57" />
</svg>
{/* Presets */}
<div className="flex gap-1.5 border-t border-neutral-800 px-3 py-2">
{SPRING_PRESETS.map((preset) => {
const isActive =
preset.mass === params.mass &&
preset.stiffness === params.stiffness &&
preset.damping === params.damping;
return (
<button
key={preset.name}
type="button"
onClick={() => applyPreset(preset)}
className={`flex-1 rounded-lg px-1.5 py-1.5 text-[10px] font-semibold transition-colors ${
isActive
? "border border-yellow-400/40 bg-yellow-400/10 text-yellow-300"
: "border border-neutral-800 bg-neutral-950 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300"
}`}
>
{preset.label}
</button>
);
})}
</div>
{/* Sliders */}
<div className="space-y-3 border-t border-neutral-800 px-3 py-3">
{SLIDERS.map((slider) => (
<div key={slider.key}>
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-neutral-500">
{slider.label}
</span>
<span className="min-w-[36px] text-right font-mono text-[10px] text-neutral-400">
{params[slider.key]}
</span>
</div>
<input
type="range"
min={slider.min}
max={slider.max}
step={slider.step}
value={params[slider.key]}
onChange={(e) => 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"
/>
</div>
))}
</div>
</div>
);
}
@@ -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 () => {
@@ -2,7 +2,6 @@ export type StudioFeatureFlagEnv = Record<string, boolean | string | undefined>;
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"],
@@ -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,
@@ -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-attributebacked) ──
// fallow-ignore-next-line complexity
const handleDomMotionCommit = useCallback(
(
selection: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
) => {
// 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<string, unknown>) => 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,
};
@@ -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,
@@ -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<typeof readStudioMotionFromElement> | 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
+1 -1
View File
@@ -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;
@@ -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 () => {
+2 -8
View File
@@ -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;
}