mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): render the flat Ledger inspector shell behind STUDIO_FLAT_INSPECTOR_ENABLED
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
82f3f340aa
commit
e0066834b7
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// PropertyPanel calls useStudioShellContext() unconditionally; supply the one
|
||||
// field it reads (showToast) so the component can mount without the full shell.
|
||||
vi.mock("../../contexts/StudioContext", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
|
||||
"../../contexts/StudioContext",
|
||||
);
|
||||
return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.doUnmock("./manualEditingAvailability");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function baseElement() {
|
||||
return {
|
||||
element: document.createElement("div"),
|
||||
id: "mono-label",
|
||||
selector: ".mono-label",
|
||||
label: "Mono Label",
|
||||
tagName: "div",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 0, y: -24, width: 257, height: 29 },
|
||||
textContent: "PACKETS / FRAME",
|
||||
dataAttributes: {},
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [
|
||||
{
|
||||
key: "field-0",
|
||||
label: "Text",
|
||||
value: "PACKETS / FRAME",
|
||||
tagName: "div",
|
||||
attributes: [],
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
source: "self",
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPanel(flatEnabled: boolean) {
|
||||
vi.resetModules();
|
||||
vi.doMock("./manualEditingAvailability", async () => {
|
||||
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
|
||||
"./manualEditingAvailability",
|
||||
);
|
||||
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
|
||||
});
|
||||
const { PropertyPanel } = await import("./PropertyPanel");
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
// Only the props the render path touches are supplied; the rest are unused at
|
||||
// mount (handlers fire on interaction), so cast a minimal object to the full
|
||||
// props shape rather than stubbing all ~15 required fields.
|
||||
const props = {
|
||||
element: baseElement(),
|
||||
onSetStyle: vi.fn(),
|
||||
onSetText: vi.fn(),
|
||||
} as unknown as PropertyPanelProps;
|
||||
act(() => {
|
||||
root.render(<PropertyPanel {...props} />);
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
// renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
|
||||
// flag read); transforming the full section graph uncached can exceed the 5s
|
||||
// default under heavy parallel full-suite load, so give these a wider margin.
|
||||
const RENDER_TIMEOUT_MS = 20_000;
|
||||
|
||||
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
|
||||
it(
|
||||
"renders the legacy header, not the flat header",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(false);
|
||||
expect(host.querySelector('[data-flat-header-icon="true"]')).toBeNull();
|
||||
expect(host.textContent).toContain("Mono Label");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
|
||||
it(
|
||||
"renders the flat header, the Text group open by default, and the flat footer",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true);
|
||||
expect(host.querySelector('[data-flat-header-icon="true"]')).not.toBeNull();
|
||||
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
||||
expect(host.textContent).toContain("Ask agent about this element");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"collapses the Text group on caret click and can reopen it",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true);
|
||||
const collapseButton = host.querySelector<HTMLButtonElement>(
|
||||
'[data-flat-group-open="true"] button[title="Collapse"]',
|
||||
);
|
||||
act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
|
||||
const collapsedRow = host.querySelector<HTMLButtonElement>(
|
||||
'[data-flat-group-collapsed="true"]',
|
||||
);
|
||||
expect(collapsedRow).not.toBeNull();
|
||||
act(() => collapsedRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
||||
import { useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
import {
|
||||
buildElementInfoText,
|
||||
EMPTY_STYLES,
|
||||
formatPxMetricValue,
|
||||
parsePxMetricValue,
|
||||
@@ -24,7 +25,12 @@ import { TextSection, StyleSections } from "./propertyPanelSections";
|
||||
import { GsapAnimationSection } from "./GsapAnimationSection";
|
||||
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
||||
import {
|
||||
STUDIO_FLAT_INSPECTOR_ENABLED,
|
||||
STUDIO_GSAP_PANEL_ENABLED,
|
||||
STUDIO_KEYFRAMES_ENABLED,
|
||||
} from "./manualEditingAvailability";
|
||||
import { PropertyPanelFlat } from "./PropertyPanelFlat";
|
||||
import { usePlayerStore, liveTime } from "../../player";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
@@ -46,61 +52,64 @@ export {
|
||||
} from "./propertyPanelHelpers";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const PropertyPanel = memo(function PropertyPanel({
|
||||
projectId,
|
||||
projectDir,
|
||||
assets,
|
||||
element,
|
||||
multiSelectCount = 0,
|
||||
copiedAgentPrompt: _copiedAgentPrompt,
|
||||
onClearSelection,
|
||||
onUngroup,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetAttributeLive,
|
||||
onApplyColorGradingScope,
|
||||
onSetHtmlAttribute,
|
||||
onRemoveBackground,
|
||||
onSetManualOffset,
|
||||
onSetManualSize,
|
||||
onSetManualRotation,
|
||||
onSetText,
|
||||
onSetTextFieldStyle,
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onAskAgent: _onAskAgent,
|
||||
onToggleElementHidden,
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
onImportFonts,
|
||||
previewIframeRef,
|
||||
gsapAnimations = [],
|
||||
gsapMultipleTimelines,
|
||||
gsapUnsupportedTimelinePattern,
|
||||
onUpdateGsapProperty,
|
||||
onUpdateGsapMeta,
|
||||
onDeleteGsapAnimation,
|
||||
onAddGsapProperty,
|
||||
onRemoveGsapProperty,
|
||||
onUpdateGsapFromProperty,
|
||||
onAddGsapFromProperty,
|
||||
onRemoveGsapFromProperty,
|
||||
onAddGsapAnimation,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
onUnroll,
|
||||
onUpdateKeyframeEase,
|
||||
onSetAllKeyframeEases,
|
||||
onAddKeyframe,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onCommitAnimatedProperty,
|
||||
onCommitAnimatedProperties,
|
||||
onSeekToTime,
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
}: PropertyPanelProps) {
|
||||
export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelProps) {
|
||||
const {
|
||||
projectId,
|
||||
projectDir,
|
||||
assets,
|
||||
element,
|
||||
multiSelectCount = 0,
|
||||
multiSelectedElements,
|
||||
onGroupSelection,
|
||||
onHideAllSelected,
|
||||
copiedAgentPrompt: _copiedAgentPrompt,
|
||||
onClearSelection,
|
||||
onUngroup,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetAttributeLive,
|
||||
onApplyColorGradingScope,
|
||||
onSetHtmlAttribute,
|
||||
onRemoveBackground,
|
||||
onSetManualOffset,
|
||||
onSetManualSize,
|
||||
onSetManualRotation,
|
||||
onSetText,
|
||||
onSetTextFieldStyle,
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onToggleElementHidden,
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
onImportFonts,
|
||||
previewIframeRef,
|
||||
gsapAnimations = [],
|
||||
gsapMultipleTimelines,
|
||||
gsapUnsupportedTimelinePattern,
|
||||
onUpdateGsapProperty,
|
||||
onUpdateGsapMeta,
|
||||
onDeleteGsapAnimation,
|
||||
onAddGsapProperty,
|
||||
onRemoveGsapProperty,
|
||||
onUpdateGsapFromProperty,
|
||||
onAddGsapFromProperty,
|
||||
onRemoveGsapFromProperty,
|
||||
onAddGsapAnimation,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
onUnroll,
|
||||
onUpdateKeyframeEase,
|
||||
onSetAllKeyframeEases,
|
||||
onAddKeyframe,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onCommitAnimatedProperty,
|
||||
onCommitAnimatedProperties,
|
||||
onSeekToTime,
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
} = props;
|
||||
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
||||
const { showToast } = useStudioShellContext();
|
||||
const [clipboardCopied, setClipboardCopied] = useState(false);
|
||||
@@ -170,13 +179,22 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
};
|
||||
|
||||
if (!element) {
|
||||
return <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
|
||||
return (
|
||||
<PropertyPanelEmptyState
|
||||
flat={STUDIO_FLAT_INSPECTOR_ENABLED}
|
||||
multiSelectCount={multiSelectCount}
|
||||
multiSelectedElements={multiSelectedElements}
|
||||
onGroupSelection={onGroupSelection}
|
||||
onHideAllSelected={onHideAllSelected}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
|
||||
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
|
||||
const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
|
||||
const sourceLabel = element.id ? `#${element.id}` : element.selector;
|
||||
const sourceLabel = element.id ? `#${element.id}` : (element.selector ?? "");
|
||||
const showEditableSections = element.capabilities.canEditStyles;
|
||||
// Capabilities are already resolved on the selection; recompute only sections,
|
||||
// feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the
|
||||
@@ -234,48 +252,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
|
||||
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleCopyElementInfo = () => {
|
||||
const file = element.sourceFile ?? "index.html";
|
||||
let lineNum: number | null = null;
|
||||
try {
|
||||
const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
|
||||
if (src && element.id) {
|
||||
const idx = src.indexOf(`id="${element.id}"`);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
if (!lineNum && element.selector) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const cls = element.selector.startsWith(".")
|
||||
? element.selector.slice(1).split(".")[0]
|
||||
: null;
|
||||
const search = cls ? `class="${cls}` : `<${tag}`;
|
||||
const idx = src.indexOf(search);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
} catch {}
|
||||
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
||||
const lines = [
|
||||
`Element: ${element.label} (${sourceLabel})`,
|
||||
`File: ${fileLoc}`,
|
||||
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
||||
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
||||
`Tag: <${element.tagName}>`,
|
||||
];
|
||||
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
|
||||
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
||||
}
|
||||
if (gsapAnimations.length > 0) {
|
||||
const anim = gsapAnimations[0];
|
||||
lines.push(
|
||||
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
||||
);
|
||||
const props = Object.entries(anim.properties)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(", ");
|
||||
if (props) lines.push(`Properties: ${props}`);
|
||||
}
|
||||
const text = lines.join("\n");
|
||||
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
|
||||
void navigator.clipboard.writeText(text);
|
||||
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
|
||||
setClipboardCopied(true);
|
||||
@@ -283,6 +261,27 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
||||
};
|
||||
|
||||
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
||||
// Forward the raw props (handlers, ids, assets, recording, fonts, etc.) and
|
||||
// the values the legacy path already computed above (so they aren't derived
|
||||
// twice). PropertyPanelFlat owns the one-open/pin group state.
|
||||
return (
|
||||
<PropertyPanelFlat
|
||||
{...props}
|
||||
element={element}
|
||||
styles={styles}
|
||||
sections={sections}
|
||||
sourceLabel={sourceLabel}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
showEditableSections={showEditableSections}
|
||||
selectedElementHidden={selectedElementHidden}
|
||||
selectedElementId={selectedElementId}
|
||||
clipboardCopied={clipboardCopied}
|
||||
onCopyElementInfo={handleCopyElementInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||
<div className="px-4 py-3">
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from "react";
|
||||
import { resolveEditingSections } from "@hyperframes/core/editing";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
||||
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
||||
import { FlatGroup } from "./propertyPanelFlatPrimitives";
|
||||
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
||||
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
||||
import { MediaSection } from "./propertyPanelMediaSection";
|
||||
|
||||
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
||||
|
||||
/**
|
||||
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
|
||||
*
|
||||
* Extracted from PropertyPanel so that file stays under the 600-LOC gate
|
||||
* (same one-directional-import precedent as FlatTextSection). Rendered only
|
||||
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
|
||||
*
|
||||
* Intentionally omits the Layout `Section` and `GsapAnimationSection` (Motion)
|
||||
* — flattening those is Layout/Motion plan territory (plans 3–4). A text
|
||||
* element with the flag on will not show Layout/Motion controls; that
|
||||
* regression is scoped and acceptable for an unreleased, flag-gated feature.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function PropertyPanelFlat({
|
||||
element,
|
||||
styles,
|
||||
sections,
|
||||
sourceLabel,
|
||||
gsapAnimations = [],
|
||||
gsapBorderRadius,
|
||||
fontAssets = [],
|
||||
showEditableSections,
|
||||
selectedElementHidden,
|
||||
selectedElementId,
|
||||
clipboardCopied,
|
||||
onCopyElementInfo,
|
||||
projectId,
|
||||
projectDir,
|
||||
assets,
|
||||
previewIframeRef,
|
||||
onClearSelection,
|
||||
onUngroup,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetAttributeLive,
|
||||
onApplyColorGradingScope,
|
||||
onSetHtmlAttribute,
|
||||
onRemoveBackground,
|
||||
onSetText,
|
||||
onSetTextFieldStyle,
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onAskAgent,
|
||||
onToggleElementHidden,
|
||||
onImportAssets,
|
||||
onImportFonts,
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
}: Pick<
|
||||
PropertyPanelProps,
|
||||
| "projectId"
|
||||
| "projectDir"
|
||||
| "assets"
|
||||
| "previewIframeRef"
|
||||
| "onClearSelection"
|
||||
| "onUngroup"
|
||||
| "onSetStyle"
|
||||
| "onSetAttribute"
|
||||
| "onSetAttributeLive"
|
||||
| "onApplyColorGradingScope"
|
||||
| "onSetHtmlAttribute"
|
||||
| "onRemoveBackground"
|
||||
| "onSetText"
|
||||
| "onSetTextFieldStyle"
|
||||
| "onAddTextField"
|
||||
| "onRemoveTextField"
|
||||
| "onAskAgent"
|
||||
| "onToggleElementHidden"
|
||||
| "onImportAssets"
|
||||
| "onImportFonts"
|
||||
| "fontAssets"
|
||||
| "gsapAnimations"
|
||||
| "recordingState"
|
||||
| "recordingDuration"
|
||||
| "onToggleRecording"
|
||||
> & {
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
sections: EditingSections;
|
||||
sourceLabel: string;
|
||||
gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
showEditableSections: boolean;
|
||||
selectedElementHidden: boolean;
|
||||
selectedElementId: string | null;
|
||||
clipboardCopied: boolean;
|
||||
onCopyElementInfo: () => void;
|
||||
}) {
|
||||
const [openGroupId, setOpenGroupId] = useState<string>("text");
|
||||
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
|
||||
|
||||
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||
<PropertyPanelFlatHeader
|
||||
name={element.label}
|
||||
meta={`${sourceLabel} · ${element.tagName}`}
|
||||
elementKind={elementKind}
|
||||
hidden={selectedElementHidden}
|
||||
onToggleHidden={
|
||||
selectedElementId && onToggleElementHidden
|
||||
? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
|
||||
: undefined
|
||||
}
|
||||
copied={clipboardCopied}
|
||||
onCopy={onCopyElementInfo}
|
||||
onClear={onClearSelection}
|
||||
onUngroup={onUngroup}
|
||||
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<FlatGroup
|
||||
title="Text"
|
||||
isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")}
|
||||
isPinned={pinnedGroupIds.includes("text")}
|
||||
onToggleOpen={() => setOpenGroupId((current) => (current === "text" ? "" : "text"))}
|
||||
onTogglePin={() =>
|
||||
setPinnedGroupIds((current) =>
|
||||
current.includes("text")
|
||||
? current.filter((id) => id !== "text")
|
||||
: [...current, "text"],
|
||||
)
|
||||
}
|
||||
summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")}
|
||||
>
|
||||
<FlatTextSection
|
||||
element={element}
|
||||
styles={styles}
|
||||
fontAssets={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onSetText={onSetText}
|
||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||
onAddTextField={onAddTextField}
|
||||
onRemoveTextField={onRemoveTextField}
|
||||
/>
|
||||
</FlatGroup>
|
||||
|
||||
{sections.timing && (
|
||||
<TimingSection
|
||||
element={element}
|
||||
animations={gsapAnimations}
|
||||
onSetAttribute={onSetAttribute}
|
||||
/>
|
||||
)}
|
||||
{sections.colorGrading && (
|
||||
<ColorGradingSection
|
||||
key={[
|
||||
element.id ?? "",
|
||||
element.hfId ?? "",
|
||||
element.selector ?? "",
|
||||
String(element.selectorIndex ?? ""),
|
||||
].join("|")}
|
||||
projectId={projectId}
|
||||
element={element}
|
||||
assets={assets}
|
||||
previewIframeRef={previewIframeRef}
|
||||
onImportAssets={onImportAssets}
|
||||
onSetAttributeLive={onSetAttributeLive}
|
||||
onApplyScope={onApplyColorGradingScope}
|
||||
/>
|
||||
)}
|
||||
{sections.media && (
|
||||
<MediaSection
|
||||
projectDir={projectDir}
|
||||
element={element}
|
||||
styles={styles}
|
||||
onSetStyle={onSetStyle}
|
||||
onSetAttribute={onSetAttribute}
|
||||
onSetHtmlAttribute={onSetHtmlAttribute}
|
||||
onRemoveBackground={onRemoveBackground}
|
||||
/>
|
||||
)}
|
||||
{showEditableSections && (
|
||||
<StyleSections
|
||||
projectId={projectId}
|
||||
element={element}
|
||||
styles={styles}
|
||||
assets={assets}
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PropertyPanelFlatFooter
|
||||
onAskAgent={onAskAgent}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -505,3 +505,56 @@ export function readGsapBorderRadiusForPanel(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the multi-line "element info" text copied to the clipboard for an AI
|
||||
* agent. Shared by both the legacy and flat inspector headers (the flat split
|
||||
* needs the same string), so it lives here rather than as a PropertyPanel
|
||||
* closure. Pure — the caller owns the clipboard write, toast, and copied state.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function buildElementInfoText(
|
||||
element: DomEditSelection,
|
||||
sourceLabel: string,
|
||||
gsapAnimations: GsapAnimation[],
|
||||
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
||||
): string {
|
||||
const file = element.sourceFile ?? "index.html";
|
||||
let lineNum: number | null = null;
|
||||
try {
|
||||
const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
|
||||
if (src && element.id) {
|
||||
const idx = src.indexOf(`id="${element.id}"`);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
if (!lineNum && element.selector) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const cls = element.selector.startsWith(".") ? element.selector.slice(1).split(".")[0] : null;
|
||||
const search = cls ? `class="${cls}` : `<${tag}`;
|
||||
const idx = src.indexOf(search);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
} catch {}
|
||||
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
||||
const lines = [
|
||||
`Element: ${element.label} (${sourceLabel})`,
|
||||
`File: ${fileLoc}`,
|
||||
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
||||
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
||||
`Tag: <${element.tagName}>`,
|
||||
];
|
||||
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
|
||||
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
||||
}
|
||||
if (gsapAnimations.length > 0) {
|
||||
const anim = gsapAnimations[0];
|
||||
lines.push(
|
||||
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
||||
);
|
||||
const props = Object.entries(anim.properties)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(", ");
|
||||
if (props) lines.push(`Properties: ${props}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PromotableControl } from "./PromotableControl";
|
||||
/* Text helpers (used only by text section components) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function formatTextFieldPreview(value: string): string {
|
||||
export function formatTextFieldPreview(value: string): string {
|
||||
const collapsed = value.trim().replace(/\s+/g, " ");
|
||||
if (collapsed.length <= 56) return collapsed;
|
||||
return `${collapsed.slice(0, 55)}…`;
|
||||
|
||||
Reference in New Issue
Block a user