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>