import { memo, useEffect, useMemo, useRef, useState } from "react"; import { Move } from "../../icons/SystemIcons"; import { InspectorHeaderActions } from "./InspectorHeaderActions"; import { useStudioShellContext } from "../../contexts/StudioContext"; import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; import { buildElementInfoText, EMPTY_STYLES, formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID, readGsapRuntimeValuesForPanel, readGsapBorderRadiusForPanel, isSelectedElementHidden, selectionIdentityKey, } from "./propertyPanelHelpers"; import { MetricField, Section } from "./propertyPanelPrimitives"; import { createTransformCommitHandlers } from "./propertyPanelTransformCommit"; import { resolveAnimIdForProperty } from "../../player/components/TimelinePropertyLanes"; import { resolveEditingSections } from "@hyperframes/core/editing"; import { MediaSection } from "./propertyPanelMediaSection"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; import { domEditSelectionToFacts } from "./domEditingLayers"; import { TextSection, StyleSections } from "./propertyPanelSections"; import { GsapAnimationSection } from "./GsapAnimationSection"; import { PropertyPanel3dTransform } from "./propertyPanel3dTransform"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { STUDIO_FLAT_INSPECTOR_ENABLED, STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED, } from "./manualEditingAvailability"; import { PropertyPanelFlat } from "./PropertyPanelFlat"; import { createGsapLivePreview } from "./gsapLivePreview"; import { usePlayerStore, liveTime } from "../../player"; import { TimingSection } from "./propertyPanelTimingSection"; import { type PropertyPanelProps } from "./propertyPanelHelpers"; import { GestureRecordPanelButton } from "./GestureRecordControl"; import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState"; import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext"; // Re-export helpers that external consumers import from this module export { buildInsetClipPathSides, buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, getCssFilterFunctionPx, getClipPathInsetPx, inferBoxShadowPreset, inferClipPathPreset, normalizePanelPxValue, parseInsetClipPathSides, setCssFilterFunctionPx, } from "./propertyPanelHelpers"; // fallow-ignore-next-line complexity export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelProps) { const { projectId, projectDir, assets, element, multiSelectCount = 0, multiSelectedElements, onGroupSelection, onHideAllSelected, copiedAgentPrompt: _copiedAgentPrompt, onClearSelection, onUngroup, onSetStyle, onSetAttribute, onSetAttributeLive, onApplyColorGradingScope, onSetHtmlAttribute, onRemoveBackground, onSetManualOffset, onSetManualSize, onSetManualRotation, onSetText, onSetTextFieldStyle, onAddTextField, onRemoveTextField, onToggleElementHidden, onImportAssets, fontAssets = [], onImportFonts, previewIframeRef, gsapAnimations = [], gsapMultipleTimelines, gsapUnsupportedTimelinePattern, onUpdateGsapProperty, onUpdateGsapMeta, onDeleteGsapAnimation, onAddGsapProperty, onRemoveGsapProperty, onUpdateGsapFromProperty, onAddGsapFromProperty, onRemoveGsapFromProperty, onAddGsapAnimation, onSetArcPath, onUpdateArcSegment, onUnroll, onUpdateKeyframeEase, onSetAllKeyframeEases, onAddKeyframe, onRemoveKeyframe, onConvertToKeyframes, onCommitAnimatedProperty, onCommitAnimatedProperties, onSeekToTime, recordingState, recordingDuration, onToggleRecording, } = props; const styles = element?.computedStyles ?? EMPTY_STYLES; const { showToast } = useStudioShellContext(); const [clipboardCopied, setClipboardCopied] = useState(false); const clipboardTimerRef = useRef>(undefined); const storeTime = usePlayerStore((s) => s.currentTime); const isPlaying = usePlayerStore((s) => s.isPlaying); const timelineElements = usePlayerStore((s) => s.elements); const selectedElementId = usePlayerStore((s) => s.selectedElementId); const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId); const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element"; const liveTimeRef = useRef(storeTime); const [, forceRender] = useState(0); useEffect(() => { if (!isPlaying) return; let timerId: ReturnType | 0 = 0; const unsub = liveTime.subscribe((t) => { liveTimeRef.current = t; if (!timerId) timerId = setTimeout(() => { timerId = 0; forceRender((v) => v + 1); }, 33); }); return () => { unsub(); if (timerId) clearTimeout(timerId); }; }, [isPlaying]); const currentTime = isPlaying ? liveTimeRef.current : storeTime; const cacheElementKey = element?.id ?? element?.selector ?? ""; const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey)); const iframeRef = previewIframeRef ?? { current: null }; const gsapAnimIdForMemo = element ? (gsapAnimations?.find((a: { keyframes?: unknown }) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null) : null; const gsapRuntimeValues = useMemo( () => element ? readGsapRuntimeValuesForPanel(gsapAnimIdForMemo, gsapAnimations, element, iframeRef) : null, // eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef is stable; currentTime drives re-reads during playback [gsapAnimIdForMemo, gsapAnimations, element, currentTime], ); const gsapBorderRadius = useMemo( () => element ? readGsapBorderRadiusForPanel(gsapRuntimeValues, gsapAnimations, element, iframeRef) : null, // eslint-disable-next-line react-hooks/exhaustive-deps [gsapRuntimeValues, gsapAnimations, element, currentTime], ); // The 3D Transform panel should be reachable on ANY element, not only ones GSAP is // already animating — otherwise you can't add depth/rotation to a fresh static // element (the panel never appears, the classic chicken-and-egg). Default to // identity when there are no runtime values yet; the first edit creates the // gsap.set via commitStaticSet, after which real runtime values flow in. const gsap3dValues: Record = gsapRuntimeValues ?? { rotationX: 0, rotationY: 0, rotationZ: 0, z: 0, scale: 1, transformPerspective: 0, }; if (!element) { return ( ); } const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset; const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize; const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation; const sourceLabel = element.id ? `#${element.id}` : (element.selector ?? ""); // Capabilities are already resolved on the selection; recompute only sections, // feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the // selection) so the Timing section shows for pure-GSAP elements with no data-start. const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length)); const showEditableSections = element.capabilities.canEditStyles && sections.style; const manualOffset = readStudioPathOffset(element.element); const manualSize = readStudioBoxSize(element.element); const resolvedWidth = manualSize.width > 0 ? manualSize.width : (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width); const resolvedHeight = manualSize.height > 0 ? manualSize.height : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height); const manualRotation = readStudioRotation(element.element); const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0; const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; const gsapKfAnim = gsapAnimations?.find((a) => a.keyframes) ?? null; const gsapKeyframes = gsapKfAnim?.keyframes?.keyframes ?? null; const gsapAnimId = gsapKfAnim?.id ?? gsapAnimations?.[0]?.id ?? null; const hasGsapAnimation = !!(gsapAnimId || gsapAnimations.length > 0); const { commitManualOffset, commitManualSize, commitManualRotation } = createTransformCommitHandlers({ element, styles, hasGsapAnimation, gsapAnimId, gsapKeyframes, currentPct, onCommitAnimatedProperty, onAddKeyframe, onSetManualOffset, onSetManualSize, onSetManualRotation, showToast, }); const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes; const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration); const animIdForProp = (prop: string): string => resolveAnimIdForProperty(prop, gsapAnimations, gsapAnimId); const displayX = gsapRuntimeValues?.x ?? manualOffset.x; const displayY = gsapRuntimeValues?.y ?? manualOffset.y; const displayW = gsapRuntimeValues?.width ?? resolvedWidth; const displayH = gsapRuntimeValues?.height ?? resolvedHeight; const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle; const handleCopyElementInfo = () => { const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef); void navigator.clipboard.writeText(text); showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info"); setClipboardCopied(true); clearTimeout(clipboardTimerRef.current); clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500); }; if (STUDIO_FLAT_INSPECTOR_ENABLED) { // Forward the raw props (handlers, ids, assets, recording, fonts, etc.) and // the values the legacy path already computed above (so they aren't derived // twice). PropertyPanelFlat owns the one-open group state. return ( ); } const classicPanel = (
{element.label}
{sourceLabel}
{onToggleRecording && ( )} {sections.timing && ( // Render whenever there's an authored clip range OR animations to infer // one from — a pure-GSAP element with no data-start still gets a Timing // range (TimingSection derives it from its tweens). )} {sections.colorGrading && ( )} {sections.media && ( )} {sections.layout && (
}>
commitManualOffset("x", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "x", displayX) } onRemoveKeyframe={(pct, animationId) => onRemoveKeyframe?.(animationId ?? animIdForProp("x"), pct) } onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))} /> )}
commitManualOffset("y", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "y", displayY) } onRemoveKeyframe={(pct, animationId) => onRemoveKeyframe?.(animationId ?? animIdForProp("y"), pct) } onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))} /> )}
commitManualSize("width", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "width", displayW) } onRemoveKeyframe={(pct, animationId) => onRemoveKeyframe?.(animationId ?? animIdForProp("width"), pct) } onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))} /> )}
commitManualSize("height", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "height", displayH) } onRemoveKeyframe={(pct, animationId) => onRemoveKeyframe?.(animationId ?? animIdForProp("height"), pct) } onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))} /> )}
commitManualRotation(next.replace("°", ""))} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "rotation", displayR) } onRemoveKeyframe={(pct, animationId) => onRemoveKeyframe?.(animationId ?? animIdForProp("rotation"), pct) } onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))} /> )}
Stacking
onSetStyle("z-index", next)} />
)} {STUDIO_GSAP_PANEL_ENABLED && onUpdateGsapProperty && onUpdateGsapMeta && onDeleteGsapAnimation && onAddGsapProperty && onAddGsapAnimation && ( {})} onUpdateFromProperty={onUpdateGsapFromProperty} onAddFromProperty={onAddGsapFromProperty} onRemoveFromProperty={onRemoveGsapFromProperty} onAddAnimation={onAddGsapAnimation} onSetArcPath={onSetArcPath} onUpdateArcSegment={onUpdateArcSegment} onUnroll={onUnroll} onUpdateKeyframeEase={onUpdateKeyframeEase} onSetAllKeyframeEases={onSetAllKeyframeEases} /> )} {showEditableSections && ( )}
); return {classicPanel}; });