diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 12ee79368..b2671a45e 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -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('[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('[data-flat-group-pin="true"]'); + act(() => pinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const unpinButton = host.querySelector('[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, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 5656f3329..efa15347f 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -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; +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([]); // 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: ( + + ), + }); + } + 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: ( + + ), + }); + } + groups.push({ + id: "layout", + title: "Layout", + accessory: drag values to scrub, + 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: ( + + ), + }); + } + + const pinned = groups.filter((g) => pinnedGroupIds.includes(g.id)); + const unpinned = groups.filter((g) => !pinnedGroupIds.includes(g.id)); + return (
- {isTextEditable && ( - toggleOpen("text")} - onTogglePin={() => togglePin("text")} - summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")} + {pinned.map((g) => ( + togglePin(g.id)} > - - - )} - - {showEditableSections && ( + {g.content} + + ))} + {pinned.length > 0 && unpinned.length > 0 && } + {unpinned.map((g) => ( 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} > - + {g.content} - )} - - toggleOpen("layout")} - onTogglePin={() => togglePin("layout")} - accessory={drag values to scrub} - summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`} - > - - - - {showMotionGroup && ( - toggleOpen("motion")} - onTogglePin={() => togglePin("motion")} - summary={`${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`} - > - - - )} - {sections.colorGrading && ( - toggleOpen("grade")} - onTogglePin={() => togglePin("grade")} - accessory={} - summary={`${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`} - > - void colorGradingController.applyToScope()} - onApplyScopeAvailable={Boolean(onApplyColorGradingScope)} - mediaMetadata={colorGradingController.mediaMetadata} - /> - - )} + ))} {sections.colorGrading && ( )} - {sections.media && ( - toggleOpen("media")} - onTogglePin={() => togglePin("media")} - summary={element.tagName} - > - - - )} {showEditableSections && (