feat(studio): render pinned groups first with a persisted, per-element-kind pin zone

This commit is contained in:
Vance Ingalls
2026-07-14 15:51:54 -07:00
parent 31849d62e2
commit b2384fad67
2 changed files with 224 additions and 155 deletions
@@ -18,6 +18,10 @@ vi.mock("../../contexts/StudioContext", async () => {
afterEach(() => {
document.body.innerHTML = "";
// usePersistedPinnedGroups persists to localStorage; clear it so a pinned
// group from one test can't leak into the next (which would move a group out
// of the accordion and break an unrelated open-by-default assertion).
window.localStorage.clear();
vi.doUnmock("./manualEditingAvailability");
vi.resetModules();
});
@@ -751,3 +755,44 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
RENDER_TIMEOUT_MS,
);
});
describe("PropertyPanel — pinning", () => {
it(
"renders a pinned group first, always open, above the PinnedZoneDivider",
async () => {
const { host, root } = await renderPanel(true);
// Pin the Text group via its pin button.
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
if (!pinButton) throw new Error("expected a pin button on the open Text group");
act(() => pinButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const pinnedRow = host.querySelector('[data-pinned-group="true"]');
expect(pinnedRow?.textContent).toContain("Text");
expect(pinnedRow?.textContent).toContain("Pinned");
// The divider must appear after the pinned zone.
const container = host.querySelector(".flex-1.overflow-y-auto");
const children = Array.from(container?.children ?? []);
const pinnedIndex = children.indexOf(pinnedRow as Element);
const dividerIndex = children.findIndex((el) => el.textContent?.includes("one open below"));
expect(pinnedIndex).toBeGreaterThanOrEqual(0);
expect(dividerIndex).toBeGreaterThan(pinnedIndex);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"unpinning returns the group to its normal stack position and it closes",
async () => {
const { host, root } = await renderPanel(true);
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
act(() => pinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const unpinButton = host.querySelector<HTMLButtonElement>('[data-pinned-group-unpin="true"]');
act(() => unpinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.querySelector('[data-pinned-group="true"]')).toBeNull();
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -1,4 +1,4 @@
import { useState } from "react";
import { type ReactNode, useState } from "react";
import { resolveEditingSections } from "@hyperframes/core/editing";
import type { DomEditSelection } from "./domEditing";
import { isTextEditableSelection } from "./domEditing";
@@ -6,7 +6,7 @@ import type { PropertyPanelProps } from "./propertyPanelHelpers";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroup } from "./propertyPanelFlatPrimitives";
import { FlatGroup, PinnedGroupRow, PinnedZoneDivider } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
@@ -18,6 +18,7 @@ import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
import { useColorGradingController } from "./useColorGradingController";
import { usePersistedPinnedGroups } from "../../hooks/usePersistedPinnedGroups";
import {
FlatColorGradingAccessory,
FlatColorGradingSection,
@@ -25,6 +26,14 @@ import {
type EditingSections = ReturnType<typeof resolveEditingSections>;
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
@@ -228,7 +237,6 @@ export function PropertyPanelFlat({
? "media"
: "layout",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
// Grade group state. Called unconditionally (React rules-of-hooks) even when
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
@@ -246,12 +254,9 @@ export function PropertyPanelFlat({
const isTextEditable = isTextEditableSelection(element);
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
const { pinnedGroupIds, togglePin } = usePersistedPinnedGroups(elementKind);
const toggleOpen = (groupId: string) =>
setOpenGroupId((current) => (current === groupId ? "" : groupId));
const togglePin = (groupId: string) =>
setPinnedGroupIds((current) =>
current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId],
);
// 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
@@ -307,6 +312,152 @@ export function PropertyPanelFlat({
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. Partitioned into pinned/unpinned
// below so pinned groups render first (always open, no accordion) above the
// PinnedZoneDivider, with the rest in the one-open accordion beneath it.
const groups: FlatGroupDescriptor[] = [];
if (isTextEditable) {
groups.push({
id: "text",
title: "Text",
summary: formatTextFieldPreview(element.textFields[0]?.value ?? ""),
content: (
<FlatTextSection
element={element}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={onAddTextField}
onRemoveTextField={onRemoveTextField}
/>
),
});
}
if (showEditableSections) {
groups.push({
id: "style",
title: "Style",
summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`,
content: (
<FlatStyleSection
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
),
});
}
groups.push({
id: "layout",
title: "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)}`,
content: (
<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 })}
/>
),
});
if (showMotionGroup) {
groups.push({
id: "motion",
title: "Motion",
summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`,
content: (
<FlatMotionSection
element={element}
animations={gsapAnimations}
showTiming={showMotionTiming}
showEffects={showMotionEffects}
multipleTimelines={gsapMultipleTimelines}
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onSetAttribute={onSetAttribute}
{...(gsapEffectHandlers ?? EMPTY_GSAP_EFFECT_HANDLERS)}
/>
),
});
}
if (sections.colorGrading) {
groups.push({
id: "grade",
title: "Grade",
accessory: <FlatColorGradingAccessory state={colorGradingController} />,
summary: `${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`,
content: (
<FlatColorGradingSection
grading={colorGradingController.grading}
assets={assets}
onImportAssets={onImportAssets}
onCommitColorGrading={colorGradingController.commitColorGrading}
applyScope={colorGradingController.applyScope}
applyBusy={colorGradingController.applyBusy}
onSetApplyScope={colorGradingController.setApplyScope}
onApplyToScope={() => void colorGradingController.applyToScope()}
onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
mediaMetadata={colorGradingController.mediaMetadata}
/>
),
});
}
if (sections.media) {
groups.push({
id: "media",
title: "Media",
summary: element.tagName,
content: (
<FlatMediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
),
});
}
const pinned = groups.filter((g) => pinnedGroupIds.includes(g.id));
const unpinned = groups.filter((g) => !pinnedGroupIds.includes(g.id));
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<PropertyPanelFlatHeader
@@ -326,138 +477,31 @@ export function PropertyPanelFlat({
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
<div className="flex-1 overflow-y-auto">
{isTextEditable && (
<FlatGroup
title="Text"
isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")}
isPinned={pinnedGroupIds.includes("text")}
onToggleOpen={() => toggleOpen("text")}
onTogglePin={() => togglePin("text")}
summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")}
{pinned.map((g) => (
<PinnedGroupRow
key={g.id}
title={g.title}
accessory={g.accessory}
onUnpin={() => togglePin(g.id)}
>
<FlatTextSection
element={element}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={onAddTextField}
onRemoveTextField={onRemoveTextField}
/>
</FlatGroup>
)}
{showEditableSections && (
{g.content}
</PinnedGroupRow>
))}
{pinned.length > 0 && unpinned.length > 0 && <PinnedZoneDivider />}
{unpinned.map((g) => (
<FlatGroup
title="Style"
isOpen={openGroupId === "style" || pinnedGroupIds.includes("style")}
isPinned={pinnedGroupIds.includes("style")}
onToggleOpen={() => toggleOpen("style")}
onTogglePin={() => togglePin("style")}
summary={`fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`}
key={g.id}
title={g.title}
isOpen={openGroupId === g.id}
isPinned={false}
onToggleOpen={() => toggleOpen(g.id)}
onTogglePin={() => togglePin(g.id)}
summary={g.summary}
accessory={g.accessory}
>
<FlatStyleSection
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
{g.content}
</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>
{showMotionGroup && (
<FlatGroup
title="Motion"
isOpen={openGroupId === "motion" || pinnedGroupIds.includes("motion")}
isPinned={pinnedGroupIds.includes("motion")}
onToggleOpen={() => toggleOpen("motion")}
onTogglePin={() => togglePin("motion")}
summary={`${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`}
>
<FlatMotionSection
element={element}
animations={gsapAnimations}
showTiming={showMotionTiming}
showEffects={showMotionEffects}
multipleTimelines={gsapMultipleTimelines}
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onSetAttribute={onSetAttribute}
{...(gsapEffectHandlers ?? EMPTY_GSAP_EFFECT_HANDLERS)}
/>
</FlatGroup>
)}
{sections.colorGrading && (
<FlatGroup
title="Grade"
isOpen={openGroupId === "grade" || pinnedGroupIds.includes("grade")}
isPinned={pinnedGroupIds.includes("grade")}
onToggleOpen={() => toggleOpen("grade")}
onTogglePin={() => togglePin("grade")}
accessory={<FlatColorGradingAccessory state={colorGradingController} />}
summary={`${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`}
>
<FlatColorGradingSection
grading={colorGradingController.grading}
assets={assets}
onImportAssets={onImportAssets}
onCommitColorGrading={colorGradingController.commitColorGrading}
applyScope={colorGradingController.applyScope}
applyBusy={colorGradingController.applyBusy}
onSetApplyScope={colorGradingController.setApplyScope}
onApplyToScope={() => void colorGradingController.applyToScope()}
onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
mediaMetadata={colorGradingController.mediaMetadata}
/>
</FlatGroup>
)}
))}
{sections.colorGrading && (
<ColorGradingSection
key={[
@@ -475,26 +519,6 @@ export function PropertyPanelFlat({
onApplyScope={onApplyColorGradingScope}
/>
)}
{sections.media && (
<FlatGroup
title="Media"
isOpen={openGroupId === "media" || pinnedGroupIds.includes("media")}
isPinned={pinnedGroupIds.includes("media")}
onToggleOpen={() => toggleOpen("media")}
onTogglePin={() => togglePin("media")}
summary={element.tagName}
>
<FlatMediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
</FlatGroup>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}