diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 12ee793688..d303768545 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(); }); @@ -77,9 +81,10 @@ function nonTextElement() { }; } -// Bug 2 fixture: 2+ text fields, which routes FlatTextSection to the legacy -// multi-field fallback — must not double-render the "Text" -// heading (FlatGroup's own heading + TextSection's internal Section heading). +// Bug 2 fixture: 2+ text fields, which routes FlatTextSection to its own +// flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) — +// must not double-render the "Text" heading (FlatGroup's own heading; this +// component never renders one of its own). function multiFieldTextElement() { const base = baseElement(); return { @@ -308,10 +313,11 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => { const { host, root } = await renderPanel(true, multiFieldTextElement()); // The FlatGroup's own "Text" heading is the only one that should exist — // the legacy TextSection's internal Section heading (data-panel-section - // ="text") must be suppressed when it's used as the flat fallback. + // ="text") must never appear, since the flat multi-field path no longer + // delegates to that component at all. expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull(); expect(host.querySelector('[data-panel-section="text"]')).toBeNull(); - // Content from the legacy multi-field fallback must still render. + // Content from the flat multi-field layer list must render. expect(host.textContent).toContain("Text layers"); act(() => root.unmount()); }, @@ -751,3 +757,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 accordion stack position", + 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 5656f33293..efa15347f0 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 && ( { act(() => root.unmount()); }); }); + +describe("PinnedGroupRow", () => { + it("renders a 'Pinned' badge, filled pin icon, and always shows children", () => { + const onUnpin = vi.fn(); + const { host, root } = renderInto( + +
body
+
, + ); + expect(host.textContent).toContain("Pinned"); + expect(host.textContent).toContain("Motion"); + expect(host.querySelector('[data-testid="body"]')).not.toBeNull(); + const unpin = host.querySelector('[data-pinned-group-unpin="true"]'); + act(() => unpin?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onUnpin).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 078845bd1a..21f02295f5 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -449,3 +449,47 @@ export function FlatToggle({
); } + +/* ------------------------------------------------------------------ */ +/* PinnedGroupRow — always-open pinned group (design_handoff #8a) */ +/* ------------------------------------------------------------------ */ + +export function PinnedGroupRow({ + title, + accessory, + onUnpin, + children, +}: { + title: string; + accessory?: ReactNode; + onUnpin: () => void; + children: ReactNode; +}) { + return ( +
+
+ + + Pinned + + {title} + + + {accessory} + + +
+ {children} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx new file mode 100644 index 0000000000..4ca2cb36db --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -0,0 +1,252 @@ +// @vitest-environment happy-dom + +import React, { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection"; +import type { DomEditSelection, DomEditTextField } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +const FIELDS = [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, + { + key: "b", + label: "Text", + value: "Subhead", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, +]; + +describe("FlatTextLayerList", () => { + it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => { + const onSelect = vi.fn(); + const onAdd = vi.fn(); + const onRemove = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Headline"); + expect(host.textContent).toContain("Subhead"); + + const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + expect((rows[1] as HTMLElement).getAttribute("data-active")).toBe("false"); + + act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSelect).toHaveBeenCalledWith("b"); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onAdd).toHaveBeenCalledTimes(1); + + const removeButton = host.querySelector( + '[data-flat-text-layer-remove="true"]', + ); + act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onRemove).toHaveBeenCalledWith("a"); + // stopPropagation on the remove button must prevent the row's own onClick + // from also firing onSelect for the removed field's key. + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalledWith("a"); + act(() => root.unmount()); + }); +}); + +function makeMultiFieldElement(): DomEditSelection { + return { + element: document.createElement("div"), + id: "multi", + selector: ".multi", + label: "Multi", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 100, height: 100 }, + textContent: "Headline Subhead", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + { + key: "b", + label: "Text", + value: "Subhead", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + } as DomEditSelection; +} + +describe("FlatTextSection — multi-field", () => { + it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("Headline"); + expect(host.textContent).toContain("Subhead"); + // Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor). + expect(host.textContent).toContain("Weight"); + // Exactly one "Text layers" micro-label — this component doesn't duplicate its own list. + const layerLabels = Array.from(host.querySelectorAll("div")).filter( + (el) => el.textContent === "Text layers", + ); + expect(layerLabels.length).toBeLessThanOrEqual(1); + + const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.textContent).toContain("Subhead"); + act(() => root.unmount()); + }); + + it("wires onAdd/onRemove end-to-end: async onAddTextField switches the active field once it appears in props, and the resync effect falls back to the first field when the active one disappears", async () => { + let addResolved = false; + + function Harness() { + const [fields, setFields] = useState(makeMultiFieldElement().textFields); + const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields }; + return ( + + Promise.resolve().then(() => { + addResolved = true; + setFields((prev) => [ + ...prev, + { + key: "c", + label: "Text", + value: "Third", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ]); + return "c"; + }) + } + onRemoveTextField={(fieldKey: string) => + setFields((prev) => prev.filter((field) => field.key !== fieldKey)) + } + /> + ); + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + let rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(addResolved).toBe(true); + rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(3); + expect((rows[2] as HTMLElement).getAttribute("data-active")).toBe("true"); + + // Remove the active field ("c") through the wired onRemoveTextField — the + // resync useEffect must fall back to the first remaining field ("a") + // since "c" no longer exists in element.textFields. + const removeButtons = host.querySelectorAll( + '[data-flat-text-layer-remove="true"]', + ); + act(() => { + removeButtons[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index 85685d5918..455053891e 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -1,4 +1,5 @@ -import { Plus } from "../../icons/SystemIcons"; +import { useEffect, useState } from "react"; +import { Plus, X } from "../../icons/SystemIcons"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; import { normalizeTextMetricValue } from "./propertyPanelHelpers"; @@ -12,10 +13,10 @@ import { } from "./propertyPanelValueTier"; import { detectAvailableWeights, + formatTextFieldPreview, getTextFieldColor, getTextStyleValue, TextAreaField, - TextSection, WEIGHT_LABELS, } from "./propertyPanelSections"; @@ -200,27 +201,47 @@ export function FlatTextSection({ onAddTextField: (afterFieldKey?: string) => string | Promise | null; onRemoveTextField: (fieldKey: string) => void; }) { + const [activeFieldKey, setActiveFieldKey] = useState( + element.textFields[0]?.key ?? null, + ); + + useEffect(() => { + const nextFields = element.textFields; + setActiveFieldKey((current) => { + if (current && nextFields.some((field) => field.key === current)) return current; + return nextFields[0]?.key ?? null; + }); + }, [element.id, element.selector, element.textFields]); + if (!isTextEditableSelection(element)) return null; const textFields = element.textFields; - const activeField = textFields[0]; + const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0]; if (!activeField) return null; if (textFields.length > 1) { - // The parent FlatGroup (PropertyPanelFlat) already renders a "Text" - // heading around this section — suppress TextSection's own internal - // heading so the flat panel doesn't show "Text" twice in a row. return ( - +
+ + void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => { + if (nextKey) setActiveFieldKey(nextKey); + }) + } + onRemove={onRemoveTextField} + /> + +
); } @@ -245,3 +266,85 @@ export function FlatTextSection({
); } + +/* ------------------------------------------------------------------ */ +/* Multi-field layer list (design_handoff_studio_inspector, #10a — */ +/* no mock exists for this row; layout originated by this plan, */ +/* following the "left-rule nested content" convention established */ +/* by Text's own content block, Motion's effect cards, and Media's */ +/* cutout block. Flag for design review.) */ +/* ------------------------------------------------------------------ */ + +export function FlatTextLayerList({ + fields, + activeFieldKey, + styles, + onSelect, + onAdd, + onRemove, +}: { + fields: DomEditSelection["textFields"]; + activeFieldKey: string; + styles: Record; + onSelect: (fieldKey: string) => void; + onAdd: () => void; + onRemove: (fieldKey: string) => void; +}) { + return ( +
+
+ Text layers +
+
+ {fields.map((field) => { + const active = field.key === activeFieldKey; + return ( +
onSelect(field.key)} + className={`flex min-h-[26px] cursor-pointer items-center gap-2 rounded px-1 ${ + active ? "bg-panel-accent/10" : "hover:bg-panel-hover" + }`} + > + + + {formatTextFieldPreview(field.value) || "Text"} + + + {field.tagName} + + {fields.length > 1 && ( + + )} +
+ ); + })} +
+ +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelSections.test.tsx b/packages/studio/src/components/editor/propertyPanelSections.test.tsx index fbcff5e0f9..075915ed1d 100644 --- a/packages/studio/src/components/editor/propertyPanelSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelSections.test.tsx @@ -133,7 +133,7 @@ describe("FlatTextSection", () => { act(() => root.unmount()); }); - it("suppresses TextSection's own heading when falling back for a multi-field element", () => { + it("renders the flat layer list (not the legacy TextSection) for a multi-field element", () => { const { host, root } = renderSection({ textFields: [ makeElement().textFields[0], @@ -149,12 +149,12 @@ describe("FlatTextSection", () => { }, ], }); - // TextSection's own Section wrapper (data-panel-section="text") must not - // render here — the caller (PropertyPanelFlat's FlatGroup) already shows - // a "Text" heading, so a second one from the legacy component would be a - // doubled heading. + // Legacy TextSection's own Section wrapper (data-panel-section="text") + // must never render here — multi-field elements now go through the flat + // FlatTextLayerList + FlatTextFieldEditor path end to end, not a + // delegation to the legacy component. expect(host.querySelector('[data-panel-section="text"]')).toBeNull(); - // The multi-field fallback's own content must still render. + // The flat layer list's own content must render. expect(host.textContent).toContain("Text layers"); act(() => root.unmount()); }); diff --git a/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts b/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts new file mode 100644 index 0000000000..f233bae7ba --- /dev/null +++ b/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { usePersistedPinnedGroups } from "./usePersistedPinnedGroups"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + window.localStorage.clear(); +}); + +function Harness({ + elementKind, + onReady, +}: { + elementKind: string; + onReady: (api: ReturnType) => void; +}) { + const api = usePersistedPinnedGroups(elementKind); + onReady(api); + return null; +} + +function mount(elementKind: string) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let api!: ReturnType; + act(() => { + root.render(React.createElement(Harness, { elementKind, onReady: (a) => (api = a) })); + }); + return { + host, + root, + get api() { + return api; + }, + }; +} + +describe("usePersistedPinnedGroups", () => { + it("starts empty, toggling a pin adds it, toggling again removes it", () => { + const m = mount("text"); + expect(m.api.pinnedGroupIds).toEqual([]); + act(() => m.api.togglePin("motion")); + expect(m.api.pinnedGroupIds).toEqual(["motion"]); + act(() => m.api.togglePin("motion")); + expect(m.api.pinnedGroupIds).toEqual([]); + act(() => m.root.unmount()); + }); + + it("persists across remounts, scoped per element kind", () => { + const first = mount("text"); + act(() => first.api.togglePin("motion")); + act(() => first.root.unmount()); + + const secondSameKind = mount("text"); + expect(secondSameKind.api.pinnedGroupIds).toEqual(["motion"]); + act(() => secondSameKind.root.unmount()); + + const thirdOtherKind = mount("media"); + expect(thirdOtherKind.api.pinnedGroupIds).toEqual([]); + act(() => thirdOtherKind.root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/usePersistedPinnedGroups.ts b/packages/studio/src/hooks/usePersistedPinnedGroups.ts new file mode 100644 index 0000000000..57e364f2f0 --- /dev/null +++ b/packages/studio/src/hooks/usePersistedPinnedGroups.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from "react"; +import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences"; + +export function usePersistedPinnedGroups(elementKind: string) { + const [pinnedGroupIds, setPinnedGroupIds] = useState( + () => readStudioUiPreferences().pinnedGroupsByElementType?.[elementKind] ?? [], + ); + + const togglePin = useCallback( + (groupId: string) => { + setPinnedGroupIds((current) => { + const next = current.includes(groupId) + ? current.filter((id) => id !== groupId) + : [...current, groupId]; + const existing = readStudioUiPreferences().pinnedGroupsByElementType ?? {}; + writeStudioUiPreferences({ + pinnedGroupsByElementType: { ...existing, [elementKind]: next }, + }); + return next; + }); + }, + [elementKind], + ); + + return { pinnedGroupIds, togglePin }; +} diff --git a/packages/studio/src/utils/studioUiPreferences.test.ts b/packages/studio/src/utils/studioUiPreferences.test.ts index c5dbd50968..c5c0733c54 100644 --- a/packages/studio/src/utils/studioUiPreferences.test.ts +++ b/packages/studio/src/utils/studioUiPreferences.test.ts @@ -50,3 +50,48 @@ describe("studio UI preferences", () => { }); }); }); + +function fakeStorage(): Storage { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + clear: () => map.clear(), + key: () => null, + get length() { + return map.size; + }, + } as Storage; +} + +describe("pinnedGroupsByElementType", () => { + it("round-trips a per-element-type pin map", () => { + const storage = fakeStorage(); + writeStudioUiPreferences( + { pinnedGroupsByElementType: { text: ["motion"], media: ["grade"] } }, + storage, + ); + const read = readStudioUiPreferences(storage); + expect(read.pinnedGroupsByElementType).toEqual({ text: ["motion"], media: ["grade"] }); + }); + + it("ignores a malformed pinnedGroupsByElementType (non-object, or non-string-array values)", () => { + const storage = fakeStorage(); + storage.setItem( + "hf-studio-ui-preferences", + JSON.stringify({ pinnedGroupsByElementType: { text: "not-an-array", media: [1, 2] } }), + ); + const read = readStudioUiPreferences(storage); + expect(read.pinnedGroupsByElementType).toEqual({ media: [] }); + }); + + it("merges a pin-map patch without clobbering other preferences", () => { + const storage = fakeStorage(); + writeStudioUiPreferences({ audioMuted: true }, storage); + writeStudioUiPreferences({ pinnedGroupsByElementType: { text: ["style"] } }, storage); + const read = readStudioUiPreferences(storage); + expect(read.audioMuted).toBe(true); + expect(read.pinnedGroupsByElementType).toEqual({ text: ["style"] }); + }); +}); diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts index 13804959a6..ca7f432e79 100644 --- a/packages/studio/src/utils/studioUiPreferences.ts +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -16,6 +16,7 @@ export interface StudioUiPreferences { gridVisible?: boolean; gridSpacing?: number; snapToGrid?: boolean; + pinnedGroupsByElementType?: Record; } const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences"; @@ -88,6 +89,15 @@ function readStorage(storage: Storage | null): StudioUiPreferences { if (typeof parsed.snapToGrid === "boolean") { preferences.snapToGrid = parsed.snapToGrid; } + if (isRecord(parsed.pinnedGroupsByElementType)) { + const map: Record = {}; + for (const [kind, ids] of Object.entries(parsed.pinnedGroupsByElementType)) { + if (Array.isArray(ids)) { + map[kind] = ids.filter((id): id is string => typeof id === "string"); + } + } + preferences.pinnedGroupsByElementType = map; + } return preferences; } catch { return {};