mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2125 from heygen-com/studio-flat-06-pinning-multifield
feat(studio): flat inspector — persisted pinning + multi-field Text
This commit is contained in:
@@ -18,6 +18,10 @@ vi.mock("../../contexts/StudioContext", async () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = "";
|
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.doUnmock("./manualEditingAvailability");
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
@@ -77,9 +81,10 @@ function nonTextElement() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bug 2 fixture: 2+ text fields, which routes FlatTextSection to the legacy
|
// Bug 2 fixture: 2+ text fields, which routes FlatTextSection to its own
|
||||||
// multi-field <TextSection> fallback — must not double-render the "Text"
|
// flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) —
|
||||||
// heading (FlatGroup's own heading + TextSection's internal Section heading).
|
// must not double-render the "Text" heading (FlatGroup's own heading; this
|
||||||
|
// component never renders one of its own).
|
||||||
function multiFieldTextElement() {
|
function multiFieldTextElement() {
|
||||||
const base = baseElement();
|
const base = baseElement();
|
||||||
return {
|
return {
|
||||||
@@ -308,10 +313,11 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
|
|||||||
const { host, root } = await renderPanel(true, multiFieldTextElement());
|
const { host, root } = await renderPanel(true, multiFieldTextElement());
|
||||||
// The FlatGroup's own "Text" heading is the only one that should exist —
|
// The FlatGroup's own "Text" heading is the only one that should exist —
|
||||||
// the legacy TextSection's internal Section heading (data-panel-section
|
// 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-flat-group-open="true"]')).not.toBeNull();
|
||||||
expect(host.querySelector('[data-panel-section="text"]')).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");
|
expect(host.textContent).toContain("Text layers");
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
},
|
},
|
||||||
@@ -751,3 +757,44 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
|
|||||||
RENDER_TIMEOUT_MS,
|
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 accordion stack position",
|
||||||
|
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 { resolveEditingSections } from "@hyperframes/core/editing";
|
||||||
import type { DomEditSelection } from "./domEditing";
|
import type { DomEditSelection } from "./domEditing";
|
||||||
import { isTextEditableSelection } from "./domEditing";
|
import { isTextEditableSelection } from "./domEditing";
|
||||||
@@ -6,7 +6,7 @@ import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
|||||||
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
||||||
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
||||||
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
||||||
import { FlatGroup } from "./propertyPanelFlatPrimitives";
|
import { FlatGroup, PinnedGroupRow, PinnedZoneDivider } from "./propertyPanelFlatPrimitives";
|
||||||
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
||||||
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
||||||
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
||||||
@@ -18,6 +18,7 @@ import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
|
|||||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||||
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
||||||
import { useColorGradingController } from "./useColorGradingController";
|
import { useColorGradingController } from "./useColorGradingController";
|
||||||
|
import { usePersistedPinnedGroups } from "../../hooks/usePersistedPinnedGroups";
|
||||||
import {
|
import {
|
||||||
FlatColorGradingAccessory,
|
FlatColorGradingAccessory,
|
||||||
FlatColorGradingSection,
|
FlatColorGradingSection,
|
||||||
@@ -25,6 +26,14 @@ import {
|
|||||||
|
|
||||||
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
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
|
// Type-only fallback for the Motion effect-card callbacks. Used solely to
|
||||||
// satisfy FlatMotionSection's required-callback shape when the effect list is
|
// satisfy FlatMotionSection's required-callback shape when the effect list is
|
||||||
// gated off (showEffects === false, so none of these are ever invoked). Keeps
|
// gated off (showEffects === false, so none of these are ever invoked). Keeps
|
||||||
@@ -228,7 +237,6 @@ export function PropertyPanelFlat({
|
|||||||
? "media"
|
? "media"
|
||||||
: "layout",
|
: "layout",
|
||||||
);
|
);
|
||||||
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// Grade group state. Called unconditionally (React rules-of-hooks) even when
|
// Grade group state. Called unconditionally (React rules-of-hooks) even when
|
||||||
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
|
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
|
||||||
@@ -246,12 +254,9 @@ export function PropertyPanelFlat({
|
|||||||
|
|
||||||
const isTextEditable = isTextEditableSelection(element);
|
const isTextEditable = isTextEditableSelection(element);
|
||||||
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
||||||
|
const { pinnedGroupIds, togglePin } = usePersistedPinnedGroups(elementKind);
|
||||||
const toggleOpen = (groupId: string) =>
|
const toggleOpen = (groupId: string) =>
|
||||||
setOpenGroupId((current) => (current === groupId ? "" : groupId));
|
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) —
|
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
|
||||||
// must agree with Motion's Timing row (FlatTimingRow), which infers the
|
// must agree with Motion's Timing row (FlatTimingRow), which infers the
|
||||||
// range from animations when there's no explicit data-duration. Computed
|
// range from animations when there's no explicit data-duration. Computed
|
||||||
@@ -307,6 +312,152 @@ export function PropertyPanelFlat({
|
|||||||
const showMotionEffects = gsapEffectHandlers !== null;
|
const showMotionEffects = gsapEffectHandlers !== null;
|
||||||
const showMotionGroup = showMotionTiming || showMotionEffects;
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||||
<PropertyPanelFlatHeader
|
<PropertyPanelFlatHeader
|
||||||
@@ -326,138 +477,31 @@ export function PropertyPanelFlat({
|
|||||||
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
|
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isTextEditable && (
|
{pinned.map((g) => (
|
||||||
<FlatGroup
|
<PinnedGroupRow
|
||||||
title="Text"
|
key={g.id}
|
||||||
isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")}
|
title={g.title}
|
||||||
isPinned={pinnedGroupIds.includes("text")}
|
accessory={g.accessory}
|
||||||
onToggleOpen={() => toggleOpen("text")}
|
onUnpin={() => togglePin(g.id)}
|
||||||
onTogglePin={() => togglePin("text")}
|
|
||||||
summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")}
|
|
||||||
>
|
>
|
||||||
<FlatTextSection
|
{g.content}
|
||||||
element={element}
|
</PinnedGroupRow>
|
||||||
styles={styles}
|
))}
|
||||||
fontAssets={fontAssets}
|
{pinned.length > 0 && unpinned.length > 0 && <PinnedZoneDivider />}
|
||||||
onImportFonts={onImportFonts}
|
{unpinned.map((g) => (
|
||||||
onSetText={onSetText}
|
|
||||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
||||||
onAddTextField={onAddTextField}
|
|
||||||
onRemoveTextField={onRemoveTextField}
|
|
||||||
/>
|
|
||||||
</FlatGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showEditableSections && (
|
|
||||||
<FlatGroup
|
<FlatGroup
|
||||||
title="Style"
|
key={g.id}
|
||||||
isOpen={openGroupId === "style" || pinnedGroupIds.includes("style")}
|
title={g.title}
|
||||||
isPinned={pinnedGroupIds.includes("style")}
|
isOpen={openGroupId === g.id}
|
||||||
onToggleOpen={() => toggleOpen("style")}
|
isPinned={false}
|
||||||
onTogglePin={() => togglePin("style")}
|
onToggleOpen={() => toggleOpen(g.id)}
|
||||||
summary={`fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`}
|
onTogglePin={() => togglePin(g.id)}
|
||||||
|
summary={g.summary}
|
||||||
|
accessory={g.accessory}
|
||||||
>
|
>
|
||||||
<FlatStyleSection
|
{g.content}
|
||||||
projectId={projectId}
|
|
||||||
element={element}
|
|
||||||
styles={styles}
|
|
||||||
assets={assets}
|
|
||||||
onSetStyle={onSetStyle}
|
|
||||||
onImportAssets={onImportAssets}
|
|
||||||
gsapBorderRadius={gsapBorderRadius}
|
|
||||||
/>
|
|
||||||
</FlatGroup>
|
</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 && (
|
{sections.colorGrading && (
|
||||||
<ColorGradingSection
|
<ColorGradingSection
|
||||||
key={[
|
key={[
|
||||||
@@ -475,26 +519,6 @@ export function PropertyPanelFlat({
|
|||||||
onApplyScope={onApplyColorGradingScope}
|
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 && (
|
{showEditableSections && (
|
||||||
<StyleSections
|
<StyleSections
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
FlatSelectRow,
|
FlatSelectRow,
|
||||||
FlatSlider,
|
FlatSlider,
|
||||||
FlatToggle,
|
FlatToggle,
|
||||||
|
PinnedGroupRow,
|
||||||
PinnedZoneDivider,
|
PinnedZoneDivider,
|
||||||
} from "./propertyPanelFlatPrimitives";
|
} from "./propertyPanelFlatPrimitives";
|
||||||
|
|
||||||
@@ -456,3 +457,21 @@ describe("FlatToggle", () => {
|
|||||||
act(() => root.unmount());
|
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(
|
||||||
|
<PinnedGroupRow title="Motion" onUnpin={onUnpin}>
|
||||||
|
<div data-testid="body">body</div>
|
||||||
|
</PinnedGroupRow>,
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain("Pinned");
|
||||||
|
expect(host.textContent).toContain("Motion");
|
||||||
|
expect(host.querySelector('[data-testid="body"]')).not.toBeNull();
|
||||||
|
const unpin = host.querySelector<HTMLButtonElement>('[data-pinned-group-unpin="true"]');
|
||||||
|
act(() => unpin?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onUnpin).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -449,3 +449,47 @@ export function FlatToggle({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* PinnedGroupRow — always-open pinned group (design_handoff #8a) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function PinnedGroupRow({
|
||||||
|
title,
|
||||||
|
accessory,
|
||||||
|
onUnpin,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
accessory?: ReactNode;
|
||||||
|
onUnpin: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-panel-hairline px-4 py-3" data-pinned-group="true">
|
||||||
|
<div className="mb-2.5 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-panel-accent">
|
||||||
|
Pinned
|
||||||
|
</span>
|
||||||
|
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2.5 text-panel-text-5">
|
||||||
|
{accessory}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-pinned-group-unpin="true"
|
||||||
|
title="Unpin — returns to the stack"
|
||||||
|
onClick={onUnpin}
|
||||||
|
className="text-panel-accent"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||||
|
<path d="M4 1h4v3.2l1.4 1.4V7H7v4L6 12l-1-1V7H2.6V5.6L4 4.2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<FlatTextLayerList
|
||||||
|
fields={FIELDS as never}
|
||||||
|
activeFieldKey="a"
|
||||||
|
styles={{}}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
|
||||||
|
act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const removeButton = host.querySelector<HTMLButtonElement>(
|
||||||
|
'[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(
|
||||||
|
<FlatTextSection
|
||||||
|
element={makeMultiFieldElement()}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={vi.fn()}
|
||||||
|
onAddTextField={vi.fn()}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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<DomEditTextField[]>(makeMultiFieldElement().textFields);
|
||||||
|
const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields };
|
||||||
|
return (
|
||||||
|
<FlatTextSection
|
||||||
|
element={element}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={vi.fn()}
|
||||||
|
onAddTextField={() =>
|
||||||
|
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(<Harness />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<HTMLButtonElement>('[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<HTMLButtonElement>(
|
||||||
|
'[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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||||
import type { ImportedFontAsset } from "./fontAssets";
|
import type { ImportedFontAsset } from "./fontAssets";
|
||||||
import { normalizeTextMetricValue } from "./propertyPanelHelpers";
|
import { normalizeTextMetricValue } from "./propertyPanelHelpers";
|
||||||
@@ -12,10 +13,10 @@ import {
|
|||||||
} from "./propertyPanelValueTier";
|
} from "./propertyPanelValueTier";
|
||||||
import {
|
import {
|
||||||
detectAvailableWeights,
|
detectAvailableWeights,
|
||||||
|
formatTextFieldPreview,
|
||||||
getTextFieldColor,
|
getTextFieldColor,
|
||||||
getTextStyleValue,
|
getTextStyleValue,
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
TextSection,
|
|
||||||
WEIGHT_LABELS,
|
WEIGHT_LABELS,
|
||||||
} from "./propertyPanelSections";
|
} from "./propertyPanelSections";
|
||||||
|
|
||||||
@@ -200,27 +201,47 @@ export function FlatTextSection({
|
|||||||
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
||||||
onRemoveTextField: (fieldKey: string) => void;
|
onRemoveTextField: (fieldKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
|
||||||
|
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;
|
if (!isTextEditableSelection(element)) return null;
|
||||||
const textFields = element.textFields;
|
const textFields = element.textFields;
|
||||||
const activeField = textFields[0];
|
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
|
||||||
if (!activeField) return null;
|
if (!activeField) return null;
|
||||||
|
|
||||||
if (textFields.length > 1) {
|
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 (
|
return (
|
||||||
<TextSection
|
<div className="space-y-1.5">
|
||||||
element={element}
|
<FlatTextLayerList
|
||||||
styles={styles}
|
fields={textFields}
|
||||||
fontAssets={fontAssets}
|
activeFieldKey={activeField.key}
|
||||||
onImportFonts={onImportFonts}
|
styles={styles}
|
||||||
onSetText={onSetText}
|
onSelect={setActiveFieldKey}
|
||||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
onAdd={() =>
|
||||||
onAddTextField={onAddTextField}
|
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
|
||||||
onRemoveTextField={onRemoveTextField}
|
if (nextKey) setActiveFieldKey(nextKey);
|
||||||
hideOwnHeading
|
})
|
||||||
/>
|
}
|
||||||
|
onRemove={onRemoveTextField}
|
||||||
|
/>
|
||||||
|
<FlatTextFieldEditor
|
||||||
|
field={activeField}
|
||||||
|
styles={styles}
|
||||||
|
fontAssets={fontAssets}
|
||||||
|
onImportFonts={onImportFonts}
|
||||||
|
onSetText={onSetText}
|
||||||
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,3 +266,85 @@ export function FlatTextSection({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 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<string, string>;
|
||||||
|
onSelect: (fieldKey: string) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: (fieldKey: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 border-l-2 border-panel-border-input py-0.5 pl-[10px]">
|
||||||
|
<div className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||||
|
Text layers
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{fields.map((field) => {
|
||||||
|
const active = field.key === activeFieldKey;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.key}
|
||||||
|
data-flat-text-layer-row="true"
|
||||||
|
data-active={active}
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 flex-shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
|
||||||
|
{formatTextFieldPreview(field.value) || "Text"}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
|
||||||
|
{field.tagName}
|
||||||
|
</span>
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-text-layer-remove="true"
|
||||||
|
aria-label="Remove text field"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(field.key);
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-text-layer-add="true"
|
||||||
|
onClick={onAdd}
|
||||||
|
className="mt-1 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
|
||||||
|
>
|
||||||
|
<Plus size={10} />
|
||||||
|
Add text field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ describe("FlatTextSection", () => {
|
|||||||
act(() => root.unmount());
|
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({
|
const { host, root } = renderSection({
|
||||||
textFields: [
|
textFields: [
|
||||||
makeElement().textFields[0],
|
makeElement().textFields[0],
|
||||||
@@ -149,12 +149,12 @@ describe("FlatTextSection", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
// TextSection's own Section wrapper (data-panel-section="text") must not
|
// Legacy TextSection's own Section wrapper (data-panel-section="text")
|
||||||
// render here — the caller (PropertyPanelFlat's FlatGroup) already shows
|
// must never render here — multi-field elements now go through the flat
|
||||||
// a "Text" heading, so a second one from the legacy component would be a
|
// FlatTextLayerList + FlatTextFieldEditor path end to end, not a
|
||||||
// doubled heading.
|
// delegation to the legacy component.
|
||||||
expect(host.querySelector('[data-panel-section="text"]')).toBeNull();
|
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");
|
expect(host.textContent).toContain("Text layers");
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<typeof usePersistedPinnedGroups>) => 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<typeof usePersistedPinnedGroups>;
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||||
|
|
||||||
|
export function usePersistedPinnedGroups(elementKind: string) {
|
||||||
|
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>(
|
||||||
|
() => 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 };
|
||||||
|
}
|
||||||
@@ -88,3 +88,48 @@ describe("timeline zoom pin persistence", () => {
|
|||||||
expect(prefs.timelineManualZoomPercent).toBeUndefined();
|
expect(prefs.timelineManualZoomPercent).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fakeStorage(): Storage {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface StudioUiPreferences {
|
|||||||
timelineZoomMode?: "fit" | "manual";
|
timelineZoomMode?: "fit" | "manual";
|
||||||
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
||||||
timelineManualZoomPercent?: number;
|
timelineManualZoomPercent?: number;
|
||||||
|
pinnedGroupsByElementType?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
||||||
@@ -115,6 +116,15 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|||||||
) {
|
) {
|
||||||
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
||||||
}
|
}
|
||||||
|
if (isRecord(parsed.pinnedGroupsByElementType)) {
|
||||||
|
const map: Record<string, string[]> = {};
|
||||||
|
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;
|
return preferences;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
Reference in New Issue
Block a user