mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0369865ddb
commit
8602e1a8c9
@@ -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<typeof baseElement> = 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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string, number>) => 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)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<typeof FlatLayoutSection>[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<string, string>;
|
||||
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<string>(() =>
|
||||
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "",
|
||||
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout",
|
||||
);
|
||||
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||
@@ -185,6 +243,50 @@ export function PropertyPanelFlat({
|
||||
</FlatGroup>
|
||||
)}
|
||||
|
||||
<FlatGroup
|
||||
title="Layout"
|
||||
isOpen={openGroupId === "layout" || pinnedGroupIds.includes("layout")}
|
||||
isPinned={pinnedGroupIds.includes("layout")}
|
||||
onToggleOpen={() => toggleOpen("layout")}
|
||||
onTogglePin={() => togglePin("layout")}
|
||||
accessory={<span className="text-[9px] text-panel-text-5">drag values to scrub</span>}
|
||||
summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`}
|
||||
>
|
||||
<FlatLayoutSection
|
||||
element={element}
|
||||
styles={styles}
|
||||
onSetStyle={onSetStyle}
|
||||
disabled={!element.capabilities.canEditStyles}
|
||||
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}
|
||||
seekFromKfPct={seekFromKfPct}
|
||||
animIdForProp={animIdForProp}
|
||||
resolveAnimIdForProp={animIdForProp}
|
||||
gsapRuntimeValues={gsapRuntimeValues}
|
||||
gsapKeyframes={navKeyframes}
|
||||
elStart={elStart}
|
||||
elDuration={elDuration}
|
||||
onCommitAnimatedProperty={onCommitAnimatedProperty}
|
||||
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
||||
onSeekToTime={onSeekToTime}
|
||||
onRemoveKeyframe={onRemoveKeyframe}
|
||||
onConvertToKeyframes={onConvertToKeyframes}
|
||||
onLivePreviewProps={createGsapLivePreview(previewIframeRef ?? { current: null })}
|
||||
/>
|
||||
</FlatGroup>
|
||||
|
||||
{sections.timing && (
|
||||
<TimingSection
|
||||
element={element}
|
||||
@@ -229,6 +331,9 @@ export function PropertyPanelFlat({
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
// Flex now lives in the flat Layout group (LayoutFlexBlock); suppress
|
||||
// the legacy StyleSections Flex `Section` so it renders exactly once.
|
||||
hideFlex
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, number>) => {
|
||||
const iframe = iframeRef.current;
|
||||
const win = iframe?.contentWindow as
|
||||
| { gsap?: { set: (t: Element, v: Record<string, number>) => 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);
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
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 && (
|
||||
<Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
|
||||
<div className="space-y-4">
|
||||
<SegmentedControl
|
||||
|
||||
Reference in New Issue
Block a user