From 8602e1a8c9fcadf64022f0ded0a0d2ecb3845602 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 11:44:06 -0700 Subject: [PATCH] feat(studio): wire the flat Layout group into the one-open accordion Thread the Layout-group values through PropertyPanel -> PropertyPanelFlat and add the third FlatGroup to the one-open/pin accordion (unconditional, matching legacy Layout). Default-open Layout when neither Text nor Style applies. Fix the Flex double-render: the legacy StyleSections still renders its own Flex Section, and the new flat Layout group renders its own LayoutFlexBlock. Add an additive optional hideFlex prop to StyleSections and pass it on the flat path so Flex renders exactly once (from the flat Layout group). Non-flat callers omit it and are unchanged. Extract the shared onLivePreviewProps closure into gsapLivePreview.ts (it was duplicated inline in the legacy path) so PropertyPanel.tsx stays within the 600-LOC studio gate. Co-Authored-By: Claude Sonnet 5 --- .../components/editor/PropertyPanel.test.tsx | 63 +++++++++ .../src/components/editor/PropertyPanel.tsx | 30 ++-- .../components/editor/PropertyPanelFlat.tsx | 131 ++++++++++++++++-- .../src/components/editor/gsapLivePreview.ts | 22 +++ .../editor/propertyPanelStyleSections.tsx | 7 +- 5 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 packages/studio/src/components/editor/gsapLivePreview.ts diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index d38f67b6d..f43f16772 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -114,6 +114,22 @@ function styleOnlyElement() { }; } +// Flex fixture (Plan 3a Task 5): display:flex drives BOTH the legacy +// StyleSections Flex `Section` AND the new flat Layout group's +// LayoutFlexBlock. Used to prove Flex renders exactly once on the flat path. +// styles are read from computedStyles (PropertyPanel line ~113), so set it +// there. +function flexElement() { + return { + ...baseElement(), + id: "flex-row", + selector: ".flex-row", + label: "Flex Row", + textFields: [], + computedStyles: { display: "flex" }, + }; +} + async function renderPanel( flatEnabled: boolean, elementOverride: ReturnType = baseElement(), @@ -266,3 +282,50 @@ describe("PropertyPanel — Style group (flag on)", () => { RENDER_TIMEOUT_MS, ); }); + +describe("PropertyPanel — Layout group (Plan 3a)", () => { + it( + "always renders the Layout group, and opening it closes whichever other group was open", + async () => { + const { host, root } = await renderPanel(true); + // Text group is open by default for the base text-editable fixture. + expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text"); + + const layoutCollapsedRow = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ).find((el) => el.textContent?.includes("Layout")); + if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row"); + act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + const openGroup = host.querySelector('[data-flat-group-open="true"]'); + expect(openGroup?.textContent).toContain("Layout"); + expect(openGroup?.textContent).toContain("X"); + expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)", + async () => { + const { host, root } = await renderPanel(true, flexElement()); + const layoutCollapsedRow = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ).find((el) => el.textContent?.includes("Layout")); + if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row"); + act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + // The legacy StyleSections Flex `Section` (data-panel-section="flex") must + // NOT render on the flat path — the only two Flex renderers are the legacy + // Section and the flat LayoutFlexBlock, so its absence + the flat block's + // presence proves Flex renders exactly once (not twice, not zero). + expect(host.querySelector('[data-panel-section="flex"]')).toBeNull(); + const openGroup = host.querySelector('[data-flat-group-open="true"]'); + expect(openGroup?.textContent).toContain("Layout"); + expect(openGroup?.textContent).toContain("Flex"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 574a8d778..97218c390 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -31,6 +31,7 @@ import { 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"; @@ -279,6 +280,24 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro selectedElementId={selectedElementId} clipboardCopied={clipboardCopied} onCopyElementInfo={handleCopyElementInfo} + displayX={displayX} + displayY={displayY} + displayW={displayW} + displayH={displayH} + displayR={displayR} + manualOffsetEditingDisabled={manualOffsetEditingDisabled} + manualSizeEditingDisabled={manualSizeEditingDisabled} + manualRotationEditingDisabled={manualRotationEditingDisabled} + commitManualOffset={commitManualOffset} + commitManualSize={commitManualSize} + commitManualRotation={commitManualRotation} + gsapAnimId={gsapAnimId} + navKeyframes={navKeyframes} + currentPct={currentPct} + animIdForProp={animIdForProp} + gsapRuntimeValues={gsap3dValues} + elStart={elStart} + elDuration={elDuration} /> ); } @@ -522,16 +541,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro onSeekToTime={onSeekToTime} onRemoveKeyframe={onRemoveKeyframe} onConvertToKeyframes={onConvertToKeyframes} - onLivePreviewProps={(el, props) => { - const iframe = iframeRef.current; - const win = iframe?.contentWindow as - | { gsap?: { set: (t: Element, v: Record) => void } } - | null - | undefined; - const sel = el.id ? `#${el.id}` : el.selector; - const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; - if (win?.gsap && node) win.gsap.set(node, props); - }} + onLivePreviewProps={createGsapLivePreview(iframeRef)} />
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 53e4d31c8..d606be1de 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -3,11 +3,14 @@ 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 { FlatGroup } from "./propertyPanelFlatPrimitives"; import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; +import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; +import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { TimingSection } from "./propertyPanelTimingSection"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; @@ -64,6 +67,29 @@ export function PropertyPanelFlat({ recordingState, recordingDuration, onToggleRecording, + displayX, + displayY, + displayW, + displayH, + displayR, + manualOffsetEditingDisabled, + manualSizeEditingDisabled, + manualRotationEditingDisabled, + commitManualOffset, + commitManualSize, + commitManualRotation, + gsapAnimId, + navKeyframes, + currentPct, + animIdForProp, + gsapRuntimeValues, + elStart, + elDuration, + onCommitAnimatedProperty, + onCommitAnimatedProperties, + onSeekToTime, + onRemoveKeyframe, + onConvertToKeyframes, }: Pick< PropertyPanelProps, | "projectId" @@ -91,18 +117,47 @@ export function PropertyPanelFlat({ | "recordingState" | "recordingDuration" | "onToggleRecording" -> & { - 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; -}) { +> & + // 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" + | "currentPct" + | "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; + }) { // 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 @@ -110,7 +165,7 @@ export function PropertyPanelFlat({ // 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" : "", + isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout", ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); @@ -122,6 +177,9 @@ export function PropertyPanelFlat({ setPinnedGroupIds((current) => current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId], ); + // 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); return (
@@ -185,6 +243,50 @@ export function PropertyPanelFlat({ )} + toggleOpen("layout")} + onTogglePin={() => togglePin("layout")} + accessory={drag values to scrub} + summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`} + > + + + {sections.timing && ( )}
diff --git a/packages/studio/src/components/editor/gsapLivePreview.ts b/packages/studio/src/components/editor/gsapLivePreview.ts new file mode 100644 index 000000000..b6daaa5bc --- /dev/null +++ b/packages/studio/src/components/editor/gsapLivePreview.ts @@ -0,0 +1,22 @@ +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * Build the "live preview" callback the 3D-transform sub-view fires while a + * value is being dragged: apply a gsap.set() to the matching node inside the + * preview iframe so the edit is reflected immediately, before it's committed. + * + * Extracted so the identical closure exists once — shared by the legacy + * PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat). + */ +export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) { + return (el: DomEditSelection, props: Record) => { + const iframe = iframeRef.current; + const win = iframe?.contentWindow as + | { gsap?: { set: (t: Element, v: Record) => void } } + | null + | undefined; + const sel = el.id ? `#${el.id}` : el.selector; + const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; + if (win?.gsap && node) win.gsap.set(node, props); + }; +} diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index 94e8d57eb..a6c3b9111 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -47,6 +47,7 @@ export function StyleSections({ onSetStyle, onImportAssets, gsapBorderRadius, + hideFlex = false, }: { projectId: string; element: DomEditSelection; @@ -55,6 +56,10 @@ export function StyleSections({ onSetStyle: (prop: string, value: string) => void | Promise; onImportAssets?: (files: FileList) => Promise; gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; + // When true, the Flex `Section` is suppressed. The flat inspector renders + // its own Flex controls inside the Layout group (LayoutFlexBlock), so the + // flat path passes this to avoid a double-render. Non-flat callers omit it. + hideFlex?: boolean; }) { const styleEditingDisabled = !element.capabilities.canEditStyles; const isFlex = styles.display === "flex" || styles.display === "inline-flex"; @@ -145,7 +150,7 @@ export function StyleSections({ return ( <> - {isFlex && ( + {isFlex && !hideFlex && (
} defaultCollapsed>