import { memo, useEffect, useMemo, useRef, useState } from "react"; import { Eye, Layers, Move, X } from "../../icons/SystemIcons"; import { useStudioShellContext } from "../../contexts/StudioContext"; import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; import { EMPTY_STYLES, formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID, readGsapRuntimeValuesForPanel, readGsapBorderRadiusForPanel, } from "./propertyPanelHelpers"; import { MetricField, Section } from "./propertyPanelPrimitives"; import { createTransformCommitHandlers } from "./propertyPanelTransformCommit"; import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; import { isMediaElement, MediaSection } from "./propertyPanelMediaSection"; import { ColorGradingSection, isColorGradingCapableElement, } from "./propertyPanelColorGradingSection"; import { TextSection, StyleSections } from "./propertyPanelSections"; import { GsapAnimationSection } from "./GsapAnimationSection"; import { PropertyPanel3dTransform } from "./propertyPanel3dTransform"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { STUDIO_COLOR_GRADING_ENABLED, STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED, } from "./manualEditingAvailability"; import { usePlayerStore, liveTime } from "../../player"; import { TimingSection } from "./propertyPanelTimingSection"; import { type PropertyPanelProps } from "./propertyPanelHelpers"; import { GestureRecordPanelButton } from "./GestureRecordControl"; // Re-export helpers that external consumers import from this module export { buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, getCssFilterFunctionPx, getClipPathInsetPx, inferBoxShadowPreset, inferClipPathPreset, normalizePanelPxValue, setCssFilterFunctionPx, } from "./propertyPanelHelpers"; // fallow-ignore-next-line complexity export const PropertyPanel = memo(function PropertyPanel({ projectId, projectDir, assets, element, multiSelectCount = 0, copiedAgentPrompt: _copiedAgentPrompt, onClearSelection, onSetStyle, onSetAttribute, onSetAttributeLive, onSetHtmlAttribute, onSetManualOffset, onSetManualSize, onSetManualRotation, onSetText, onSetTextFieldStyle, onAddTextField, onRemoveTextField, onAskAgent: _onAskAgent, onImportAssets, fontAssets = [], onImportFonts, previewIframeRef, gsapAnimations = [], gsapMultipleTimelines, gsapUnsupportedTimelinePattern, onUpdateGsapProperty, onUpdateGsapMeta, onDeleteGsapAnimation, onAddGsapProperty, onRemoveGsapProperty, onUpdateGsapFromProperty, onAddGsapFromProperty, onRemoveGsapFromProperty, onAddGsapAnimation, onSetArcPath, onUpdateArcSegment, onUnroll, onUpdateKeyframeEase, onAddKeyframe, onRemoveKeyframe, onConvertToKeyframes, onCommitAnimatedProperty, onSeekToTime, recordingState, recordingDuration, onToggleRecording, }: PropertyPanelProps) { 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 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], ); if (!element) { return (
{multiSelectCount > 1 ? ( <>

{multiSelectCount} elements selected

Select a single element to edit its properties. Click an element in the preview or use the timeline layer panel.

) : ( <>

Select an element in the preview.

The inspector is tuned for element edits with safer geometry controls, color picking, and cleaner grouped layer controls.

)}
); } const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset; const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize; const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation; const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; 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 => { const group = classifyPropertyGroup(prop); const groupAnim = gsapAnimations?.find((a) => a.propertyGroup === group); if (groupAnim) return groupAnim.id; return 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; // fallow-ignore-next-line complexity const handleCopyElementInfo = () => { const file = element.sourceFile ?? "index.html"; let lineNum: number | null = null; try { const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? ""; if (src && element.id) { const idx = src.indexOf(`id="${element.id}"`); if (idx > -1) lineNum = src.slice(0, idx).split("\n").length; } if (!lineNum && element.selector) { const tag = element.tagName.toLowerCase(); const cls = element.selector.startsWith(".") ? element.selector.slice(1).split(".")[0] : null; const search = cls ? `class="${cls}` : `<${tag}`; const idx = src.indexOf(search); if (idx > -1) lineNum = src.slice(0, idx).split("\n").length; } } catch {} const fileLoc = lineNum ? `${file}:${lineNum}` : file; const lines = [ `Element: ${element.label} (${sourceLabel})`, `File: ${fileLoc}`, `Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`, `Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`, `Tag: <${element.tagName}>`, ]; if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") { lines.push(`Z-index: ${element.computedStyles["z-index"]}`); } if (gsapAnimations.length > 0) { const anim = gsapAnimations[0]; lines.push( `Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`, ); const props = Object.entries(anim.properties) .map(([k, v]) => `${k}: ${v}`) .join(", "); if (props) lines.push(`Properties: ${props}`); } const text = lines.join("\n"); 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); }; return (
{element.label}
{sourceLabel}
{onToggleRecording && ( )} {element.dataAttributes.start != null && ( )} {isMediaElement(element) && ( )} {STUDIO_COLOR_GRADING_ENABLED && isColorGradingCapableElement(element) && ( )}
}>
commitManualOffset("x", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "x", displayX) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))} /> )}
commitManualOffset("y", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "y", displayY) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))} /> )}
commitManualSize("width", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "width", displayW) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))} /> )}
commitManualSize("height", next)} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "height", displayH) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))} /> )}
commitManualRotation(next.replace("°", ""))} />
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( onCommitAnimatedProperty && void onCommitAnimatedProperty(element, "rotation", displayR) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))} /> )}
{gsapRuntimeValues && ( )}
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} /> )} {showEditableSections && ( )}
); });