import { type ReactNode, useEffect, useRef, useState } from "react"; import { resolveEditingSections } from "@hyperframes/core/editing"; import type { DomEditSelection } from "./domEditing"; import { isTextEditableSelection } from "./domEditing"; import type { PropertyPanelProps } from "./propertyPanelHelpers"; import { formatPxMetricValue } from "./propertyPanelHelpers"; import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; import { FlatGroupHeader } from "./propertyPanelFlatPrimitives"; import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview } from "./propertyPanelSections"; import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; import { useColorGradingController } from "./useColorGradingController"; import { FlatColorGradingAccessory, FlatColorGradingSection, } from "./propertyPanelFlatColorGradingSection"; type EditingSections = ReturnType; type FlatGroupDescriptor = { id: string; title: string; summary?: string; accessory?: ReactNode; content: ReactNode; }; // Type-only fallback for the Motion effect-card callbacks. Used solely to // satisfy FlatMotionSection's required-callback shape when the effect list is // gated off (showEffects === false, so none of these are ever invoked). Keeps // the gated-off path free of `!` non-null assertions — the real, narrowed // handlers flow through only when the double-gate below passes. const EMPTY_GSAP_EFFECT_HANDLERS = { onAddAnimation: () => {}, onUpdateProperty: () => {}, onUpdateMeta: () => {}, onDeleteAnimation: () => {}, onAddProperty: () => {}, onRemoveProperty: () => {}, }; /** * The flat "Ledger" inspector shell (design_handoff_studio_inspector). * * Extracted from PropertyPanel so that file stays under the 600-LOC gate * (same one-directional-import precedent as FlatTextSection). Rendered only * when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state. * * The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion. */ // fallow-ignore-next-line complexity export function PropertyPanelFlat({ element, styles, sections, sourceLabel, gsapAnimations = [], gsapBorderRadius, fontAssets = [], showEditableSections, selectedElementHidden, selectedElementId, clipboardCopied, onCopyElementInfo, projectId, projectDir, assets, previewIframeRef, onClearSelection, onUngroup, onSetStyle, onSetAttribute, onSetAttributeLive, onApplyColorGradingScope, onSetHtmlAttribute, onRemoveBackground, onSetText, onSetTextFieldStyle, onAddTextField, onRemoveTextField, onAskAgent, onToggleElementHidden, onImportAssets, onImportFonts, recordingState, recordingDuration, onToggleRecording, displayX, displayY, displayW, displayH, displayR, manualOffsetEditingDisabled, manualSizeEditingDisabled, manualRotationEditingDisabled, commitManualOffset, commitManualSize, commitManualRotation, gsapAnimId, navKeyframes, currentTime, animIdForProp, gsapRuntimeValues, // Renamed: PropertyPanel.tsx still computes/passes these for its own legacy // (non-flat) panel, but the flat path recomputes its own basis below via // deriveElementTiming so it agrees with Motion's Timing row — ignore the // parent's naive `elDuration ?? 1` fallback. elStart: _elStart, elDuration: _elDuration, onCommitAnimatedProperty, onCommitAnimatedProperties, onSeekToTime, onRemoveKeyframe, onConvertToKeyframes, gsapMultipleTimelines, gsapUnsupportedTimelinePattern, onUpdateGsapProperty, onUpdateGsapMeta, onDeleteGsapAnimation, onAddGsapProperty, onRemoveGsapProperty, onUpdateGsapFromProperty, onAddGsapFromProperty, onRemoveGsapFromProperty, onAddGsapAnimation, onSetArcPath, onUpdateArcSegment, onUnroll, onUpdateKeyframeEase, onSetAllKeyframeEases, }: Pick< PropertyPanelProps, | "projectId" | "projectDir" | "assets" | "previewIframeRef" | "onClearSelection" | "onUngroup" | "onSetStyle" | "onSetAttribute" | "onSetAttributeLive" | "onApplyColorGradingScope" | "onSetHtmlAttribute" | "onRemoveBackground" | "onSetText" | "onSetTextFieldStyle" | "onAddTextField" | "onRemoveTextField" | "onAskAgent" | "onToggleElementHidden" | "onImportAssets" | "onImportFonts" | "fontAssets" | "gsapAnimations" | "gsapMultipleTimelines" | "gsapUnsupportedTimelinePattern" | "onUpdateGsapProperty" | "onUpdateGsapMeta" | "onDeleteGsapAnimation" | "onAddGsapProperty" | "onRemoveGsapProperty" | "onUpdateGsapFromProperty" | "onAddGsapFromProperty" | "onRemoveGsapFromProperty" | "onAddGsapAnimation" | "onSetArcPath" | "onUpdateArcSegment" | "onUnroll" | "onUpdateKeyframeEase" | "onSetAllKeyframeEases" | "recordingState" | "recordingDuration" | "onToggleRecording" > & // Layout-group values (Plan 3a Task 5). All are derived locals or handlers in // PropertyPanel; compose their exact shapes from FlatLayoutSection's own props // via Pick so a signature change there propagates here instead of drifting. Pick< Parameters[0], | "displayX" | "displayY" | "displayW" | "displayH" | "displayR" | "manualOffsetEditingDisabled" | "manualSizeEditingDisabled" | "manualRotationEditingDisabled" | "commitManualOffset" | "commitManualSize" | "commitManualRotation" | "gsapAnimId" | "navKeyframes" | "animIdForProp" | "gsapRuntimeValues" | "elStart" | "elDuration" | "onCommitAnimatedProperty" | "onCommitAnimatedProperties" | "onSeekToTime" | "onRemoveKeyframe" | "onConvertToKeyframes" > & { element: DomEditSelection; styles: Record; sections: EditingSections; sourceLabel: string; gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null; showEditableSections: boolean; selectedElementHidden: boolean; selectedElementId: string | null; clipboardCopied: boolean; onCopyElementInfo: () => void; currentTime: number; }) { // Lazy initializer: pick whichever group actually renders for this element // (Text if text-editable, else Style if style-editable, else none open) so a // style-only element doesn't start with everything collapsed. Only runs on // mount — PropertyPanel.tsx keys by element identity so // switching the selection re-mounts this component and re-derives the // default instead of preserving stale state across unrelated elements. const [openGroupId, setOpenGroupId] = useState(() => isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : sections.media ? "media" : "layout", ); // Tracks which group(s) are actively transitioning this toggle cycle, so // their header/body gets the fast entrance animation (hf-flat-group-enter) // and no one else's does. Deliberately NOT derived from remounting alone: // FlatGroupHeader instances are keyed by group id and React normally // preserves them across re-renders, but toggling a non-adjacent group still // shifts the untouched collapsed siblings between the before/after-open // slices below, and Chromium restarts a CSS animation on that kind of // position shift even though nothing about the sibling actually changed. // Gating on these ids (cleared shortly after the 120ms CSS animation // finishes) keeps the animation scoped to only the groups that actually // just toggled. Two ids, not one: the clicked (newly-opening/closing) group // AND whichever group was open immediately before the click and got // implicitly closed by it — both freshly-mounted headers need to animate. const [justToggledIds, setJustToggledIds] = useState([]); const justToggledTimeoutRef = useRef | null>(null); useEffect(() => { return () => { if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current); }; }, []); // Grade group state. Called unconditionally (React rules-of-hooks) even when // sections.colorGrading is false — unlike the legacy ColorGradingSection, // which is only mounted when the section is active, PropertyPanelFlat is not // remounted per-section so the hook must run every render. Shares one state // object between the group's header accessory (compare/status/reset) and its // body (the FlatColorGradingSection controls). const colorGradingController = useColorGradingController({ projectId, element, previewIframeRef, onSetAttributeLive, onApplyScope: onApplyColorGradingScope, }); const isTextEditable = isTextEditableSelection(element); const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; const toggleOpen = (groupId: string) => { // Capture what was open BEFORE this click (this render's closure over // openGroupId), so the group that's about to be implicitly closed can be // tracked too — not just the one the user clicked. const previousOpenGroupId = openGroupId; setOpenGroupId((current) => (current === groupId ? "" : groupId)); const implicitlyClosedId = previousOpenGroupId && previousOpenGroupId !== groupId ? previousOpenGroupId : null; setJustToggledIds(implicitlyClosedId ? [groupId, implicitlyClosedId] : [groupId]); if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current); justToggledTimeoutRef.current = setTimeout(() => setJustToggledIds([]), 200); }; // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) — // must agree with Motion's Timing row (FlatTimingRow), which infers the // range from animations when there's no explicit data-duration. Computed // here (not threaded from PropertyPanel) both to keep that file under its // 600-LOC gate and because element/gsapAnimations are already in scope. const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations); // Trivial percentage→time seek, derived here rather than threaded from // PropertyPanel (keeps that file under its 600-LOC gate). const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration); // Playhead position within the SAME corrected elStart/elDuration basis as // seekFromKfPct above — recomputed here (not threaded as `currentPct` from // PropertyPanel, which still derives it against its own naive basis for the // legacy panel) so KeyframeNavigation's diamond active-state and prev/next // arrow targeting agree with where a keyframe click actually seeks to // (follow-up fix to 684ec4e87, which corrected the seek basis but left this // one still naive). const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; // Motion group double-gate — reproduces the legacy PropertyPanel gate exactly: // • Timing (sections.timing) shows via resolveEditingSections, same as today. // • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND // all five edit handlers are present (identical to PropertyPanel's legacy // `` guard). // Computing the narrowed handler bundle inside the `&&`-guarded ternary lets // TypeScript prove each handler non-undefined without a `!` assertion; the // noop bundle only fills the type when the gate is off (never invoked, since // FlatMotionSection guards every call behind showEffects). const showMotionTiming = Boolean(sections.timing); const gsapEffectHandlers = STUDIO_GSAP_PANEL_ENABLED && onUpdateGsapProperty && onUpdateGsapMeta && onDeleteGsapAnimation && onAddGsapProperty && onAddGsapAnimation ? { onAddAnimation: onAddGsapAnimation, onUpdateProperty: onUpdateGsapProperty, onUpdateMeta: onUpdateGsapMeta, onDeleteAnimation: onDeleteGsapAnimation, onAddProperty: onAddGsapProperty, onRemoveProperty: onRemoveGsapProperty ?? (() => {}), onUpdateFromProperty: onUpdateGsapFromProperty, onAddFromProperty: onAddGsapFromProperty, onRemoveFromProperty: onRemoveGsapFromProperty, onSetArcPath, onUpdateArcSegment, onUnroll, onUpdateKeyframeEase, onSetAllKeyframeEases, } : null; const showMotionEffects = gsapEffectHandlers !== null; const showMotionGroup = showMotionTiming || showMotionEffects; // Ordered group descriptors — one per FlatGroup this panel renders, gated by // the same conditions the inline JSX used. Split below into before-open/ // open/after-open regions for the one-open accordion. const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ id: "text", title: "Text", summary: formatTextFieldPreview(element.textFields[0]?.value ?? ""), content: ( ), }); } if (showEditableSections) { // Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an // invisible element must summarize as 0%, not 100%. const opacityValue = parseFloat(styles.opacity ?? "1"); const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100); groups.push({ id: "style", title: "Style", summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${opacityPct}%`, content: ( ), }); } groups.push({ id: "layout", title: "Layout", // No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing // (wheel/arrow keys only) — advertising "drag values to scrub" here lies. summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`, content: ( ), }); if (showMotionGroup) { groups.push({ id: "motion", title: "Motion", summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`, content: ( ), }); } if (sections.colorGrading) { groups.push({ id: "grade", title: "Grade", accessory: , summary: `${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`, content: ( void colorGradingController.applyToScope()} onApplyScopeAvailable={Boolean(onApplyColorGradingScope)} mediaMetadata={colorGradingController.mediaMetadata} /> ), }); } if (sections.media) { groups.push({ id: "media", title: "Media", summary: element.tagName, content: ( ), }); } // Fixed-headers + scrollable-open-section layout (design_handoff // scrollable-open-section, replaces the prior sticky-stacking mechanism): // collapsed headers before/after the open group render in normal document // flow and never move. Only the open group's own body content scrolls, in // a dedicated region between the two fixed header stacks. When no group is // open, every group is just a collapsed header — there's no scrollable // middle region at all, since nothing is expanded. const openIndex = groups.findIndex((g) => g.id === openGroupId); const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex); const openGroup = openIndex === -1 ? null : groups[openIndex]; const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1); return (
); }