mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): track design-panel input usage across both inspector UIs (#2467)
* feat(studio): add design-panel input usage tracking primitive * feat(studio): track input usage in classic inspector panel * feat(studio): track input usage in flat inspector panel * fix(studio): attribute animation meta by field, track classic chrome, widen coverage guard
This commit is contained in:
@@ -113,6 +113,7 @@ export const ArcPathControls = memo(function ArcPathControls({
|
||||
)}
|
||||
</div>
|
||||
<SliderControl
|
||||
trackName={segmentCount === 1 ? "Curviness" : `Segment ${i + 1} curviness`}
|
||||
value={seg.curviness}
|
||||
min={0}
|
||||
max={3}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
export type GestureRecordingState = "idle" | "recording" | "preview";
|
||||
|
||||
interface GestureRecordIconProps {
|
||||
@@ -28,13 +30,17 @@ export function GestureRecordPanelButton({
|
||||
onToggleRecording,
|
||||
}: GestureRecordPanelButtonProps) {
|
||||
const recording = recordingState === "recording";
|
||||
const track = useTrackDesignInput();
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onToggleRecording}
|
||||
onClick={() => {
|
||||
track("button", "Gesture recording");
|
||||
onToggleRecording();
|
||||
}}
|
||||
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
|
||||
recording
|
||||
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
|
||||
|
||||
@@ -4,7 +4,11 @@ import { Film } from "../../icons/SystemIcons";
|
||||
import { Section } from "./propertyPanelPrimitives";
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
||||
import {
|
||||
trackAnimationMetaUpdate,
|
||||
type GsapAnimationEditCallbacks,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
animations: GsapAnimation[];
|
||||
@@ -34,7 +38,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
onSetAllKeyframeEases,
|
||||
onUnroll,
|
||||
}: GsapAnimationSectionProps) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackProperty = (property: string) => {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
};
|
||||
const updateMeta = (
|
||||
animationId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
onUpdateMeta(animationId, updates);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Animation" icon={<Film size={15} />}>
|
||||
@@ -58,21 +79,94 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onUpdateMeta={onUpdateMeta}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={onAddProperty}
|
||||
onRemoveProperty={onRemoveProperty}
|
||||
onUpdateFromProperty={onUpdateFromProperty}
|
||||
onAddFromProperty={onAddFromProperty}
|
||||
onRemoveFromProperty={onRemoveFromProperty}
|
||||
onUpdateProperty={(animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
onUpdateProperty(animationId, property, value);
|
||||
}}
|
||||
onUpdateMeta={updateMeta}
|
||||
onDeleteAnimation={(animationId) => {
|
||||
track("button", "Remove animation");
|
||||
onDeleteAnimation(animationId);
|
||||
}}
|
||||
onAddProperty={(animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
onAddProperty(animationId, property);
|
||||
}}
|
||||
onRemoveProperty={(animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
onRemoveProperty(animationId, property);
|
||||
}}
|
||||
onUpdateFromProperty={
|
||||
onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
onUpdateFromProperty(animationId, property, value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAddFromProperty={
|
||||
onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
onAddFromProperty(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemoveFromProperty={
|
||||
onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
onRemoveFromProperty(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onLivePreview={onLivePreview}
|
||||
onLivePreviewEnd={onLivePreviewEnd}
|
||||
onSetArcPath={onSetArcPath}
|
||||
onUpdateArcSegment={onUpdateArcSegment}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
||||
onUnroll={onUnroll}
|
||||
onSetArcPath={
|
||||
onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track(
|
||||
"toggle",
|
||||
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
|
||||
);
|
||||
onSetArcPath(animationId, config);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateArcSegment={
|
||||
onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
onUpdateArcSegment(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateKeyframeEase={
|
||||
onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
onUpdateKeyframeEase(animationId, percentage, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSetAllKeyframeEases={
|
||||
onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
onSetAllKeyframeEases(animationId, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUnroll={
|
||||
onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
onUnroll(animationId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -85,6 +179,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setAddMenuOpen(false);
|
||||
}}
|
||||
|
||||
@@ -1,26 +1,58 @@
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { X } from "../../icons/SystemIcons";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
/** The action buttons in the inspector header: Ungroup (groups only), copy, clear. */
|
||||
/** The action buttons in the inspector header: visibility, Ungroup (groups only), copy, clear. */
|
||||
export function InspectorHeaderActions({
|
||||
element,
|
||||
copied,
|
||||
onCopy,
|
||||
onClear,
|
||||
onUngroup,
|
||||
selectedElementId,
|
||||
selectedElementHidden,
|
||||
visibilityLabel,
|
||||
onToggleHidden,
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onClear: () => void;
|
||||
onUngroup?: () => void;
|
||||
selectedElementId?: string | null;
|
||||
selectedElementHidden?: boolean;
|
||||
visibilityLabel?: string;
|
||||
onToggleHidden?: (id: string, hidden: boolean) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedElementId && onToggleHidden && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visibilityLabel}
|
||||
title={visibilityLabel}
|
||||
onClick={() => {
|
||||
track("toggle", "Element visibility");
|
||||
void onToggleHidden(selectedElementId, !selectedElementHidden);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
>
|
||||
{selectedElementHidden ? (
|
||||
<EyeSlash size={13} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={13} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onUngroup && element.dataAttributes["hf-group"] != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUngroup}
|
||||
onClick={() => {
|
||||
track("button", "Ungroup");
|
||||
onUngroup();
|
||||
}}
|
||||
title="Ungroup (⌘⇧G)"
|
||||
className="flex h-6 items-center rounded px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
|
||||
>
|
||||
@@ -29,7 +61,10 @@ export function InspectorHeaderActions({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
onClick={() => {
|
||||
track("button", "Copy element info");
|
||||
onCopy();
|
||||
}}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
|
||||
copied
|
||||
? "text-studio-accent"
|
||||
@@ -52,7 +87,10 @@ export function InspectorHeaderActions({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
onClick={onClear}
|
||||
onClick={() => {
|
||||
track("button", "Clear selection");
|
||||
onClear();
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
>
|
||||
<X size={13} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Move } from "../../icons/SystemIcons";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
||||
import { useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
@@ -38,6 +37,7 @@ import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
import { GestureRecordPanelButton } from "./GestureRecordControl";
|
||||
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
|
||||
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
// Re-export helpers that external consumers import from this module
|
||||
export {
|
||||
@@ -303,51 +303,40 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const classicPanel = (
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-neutral-100">
|
||||
{element.label}
|
||||
<DesignPanelInputProvider section="header">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-neutral-100">
|
||||
{element.label}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedElementId && onToggleElementHidden && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visibilityToggleLabel}
|
||||
title={visibilityToggleLabel}
|
||||
onClick={() => {
|
||||
void onToggleElementHidden(selectedElementId, !selectedElementHidden);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
>
|
||||
{selectedElementHidden ? (
|
||||
<EyeSlash size={13} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={13} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<InspectorHeaderActions
|
||||
element={element}
|
||||
copied={clipboardCopied}
|
||||
onCopy={handleCopyElementInfo}
|
||||
onClear={onClearSelection}
|
||||
onUngroup={onUngroup}
|
||||
selectedElementId={selectedElementId}
|
||||
selectedElementHidden={selectedElementHidden}
|
||||
visibilityLabel={visibilityToggleLabel}
|
||||
onToggleHidden={onToggleElementHidden}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DesignPanelInputProvider>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{onToggleRecording && (
|
||||
<GestureRecordPanelButton
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
<DesignPanelInputProvider section="footer">
|
||||
<GestureRecordPanelButton
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
)}
|
||||
|
||||
<TextSection
|
||||
@@ -593,4 +582,5 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <DesignPanelInputProvider ui="classic">{classicPanel}</DesignPanelInputProvider>;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { resolveEditingSections } from "@hyperframes/core/editing";
|
||||
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
||||
import { slugifyDesignInput } from "../../utils/designInputTracking";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { isTextEditableSelection } from "./domEditing";
|
||||
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
@@ -502,67 +504,77 @@ export function PropertyPanelFlat({
|
||||
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
|
||||
|
||||
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 data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{beforeOpen.map((g) => (
|
||||
<FlatGroupHeader
|
||||
key={g.id}
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
<DesignPanelInputProvider ui="flat">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||
<DesignPanelInputProvider section="header">
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
{openGroup && (
|
||||
<div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
|
||||
<FlatGroupHeader
|
||||
title={openGroup.title}
|
||||
isOpen
|
||||
onToggleOpen={() => toggleOpen(openGroup.id)}
|
||||
accessory={openGroup.accessory}
|
||||
animateEntrance={justToggledIds.includes(openGroup.id)}
|
||||
/>
|
||||
<div
|
||||
className={`${justToggledIds.includes(openGroup.id) ? "hf-flat-group-enter " : ""}min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline bg-panel-bg-inset px-4 py-3 shadow-[inset_0_2px_4px_-1px_rgba(0,0,0,0.5)]`}
|
||||
>
|
||||
{openGroup.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{afterOpen.map((g) => (
|
||||
<FlatGroupHeader
|
||||
key={g.id}
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
</DesignPanelInputProvider>
|
||||
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{beforeOpen.map((g) => (
|
||||
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
|
||||
<FlatGroupHeader
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
))}
|
||||
{openGroup && (
|
||||
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
|
||||
<div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
|
||||
<FlatGroupHeader
|
||||
title={openGroup.title}
|
||||
isOpen
|
||||
onToggleOpen={() => toggleOpen(openGroup.id)}
|
||||
accessory={openGroup.accessory}
|
||||
animateEntrance={justToggledIds.includes(openGroup.id)}
|
||||
/>
|
||||
<div
|
||||
className={`${justToggledIds.includes(openGroup.id) ? "hf-flat-group-enter " : ""}min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline bg-panel-bg-inset px-4 py-3 shadow-[inset_0_2px_4px_-1px_rgba(0,0,0,0.5)]`}
|
||||
>
|
||||
{openGroup.content}
|
||||
</div>
|
||||
</div>
|
||||
</DesignPanelInputProvider>
|
||||
)}
|
||||
{afterOpen.map((g) => (
|
||||
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
|
||||
<FlatGroupHeader
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
))}
|
||||
</div>
|
||||
<DesignPanelInputProvider section="footer">
|
||||
<PropertyPanelFlatFooter
|
||||
onAskAgent={onAskAgent}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
))}
|
||||
</DesignPanelInputProvider>
|
||||
</div>
|
||||
<PropertyPanelFlatFooter
|
||||
onAskAgent={onAskAgent}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
</div>
|
||||
</DesignPanelInputProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
export function PropertyPanelFlatFooter({
|
||||
onAskAgent,
|
||||
recordingState,
|
||||
@@ -9,6 +11,7 @@ export function PropertyPanelFlatFooter({
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const recording = recordingState === "recording";
|
||||
const recordTitle = recording
|
||||
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s`
|
||||
@@ -25,7 +28,10 @@ export function PropertyPanelFlatFooter({
|
||||
<button
|
||||
type="button"
|
||||
data-flat-footer-ask="true"
|
||||
onClick={onAskAgent}
|
||||
onClick={() => {
|
||||
track("button", "Ask agent");
|
||||
onAskAgent?.();
|
||||
}}
|
||||
disabled={!onAskAgent}
|
||||
className="flex items-center gap-[7px] text-[11px] font-medium text-panel-text-2 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -47,7 +53,10 @@ export function PropertyPanelFlatFooter({
|
||||
aria-label={recordTitle}
|
||||
title={recordTitle}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onToggleRecording}
|
||||
onClick={() => {
|
||||
track("button", "Gesture recording");
|
||||
onToggleRecording();
|
||||
}}
|
||||
className={recording ? "text-panel-danger animate-pulse" : "text-panel-danger"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons";
|
||||
|
||||
const ICON_BY_KIND = { text: Type, media: Film, other: Square } as const;
|
||||
@@ -31,6 +32,7 @@ export function PropertyPanelFlatHeader({
|
||||
onUngroup?: () => void;
|
||||
showUngroup: boolean;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const Icon = ICON_BY_KIND[elementKind];
|
||||
const visibilityLabel = hidden ? "Show element" : "Hide element";
|
||||
|
||||
@@ -47,7 +49,15 @@ export function PropertyPanelFlatHeader({
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2.5 text-panel-text-3">
|
||||
{showUngroup && (
|
||||
<button type="button" aria-label="Ungroup" title="Ungroup (⌘⇧G)" onClick={onUngroup}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Ungroup"
|
||||
title="Ungroup (⌘⇧G)"
|
||||
onClick={() => {
|
||||
track("button", "Ungroup");
|
||||
onUngroup?.();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
@@ -66,7 +76,10 @@ export function PropertyPanelFlatHeader({
|
||||
type="button"
|
||||
aria-label={visibilityLabel}
|
||||
title={visibilityLabel}
|
||||
onClick={onToggleHidden}
|
||||
onClick={() => {
|
||||
track("toggle", "Element visibility");
|
||||
onToggleHidden();
|
||||
}}
|
||||
>
|
||||
{hidden ? <EyeSlash size={13} weight="bold" /> : <Eye size={13} weight="bold" />}
|
||||
</button>
|
||||
@@ -75,12 +88,22 @@ export function PropertyPanelFlatHeader({
|
||||
type="button"
|
||||
aria-label="Copy element info to clipboard"
|
||||
title={copied ? "Copied!" : "Copy element info for any AI agent"}
|
||||
onClick={onCopy}
|
||||
onClick={() => {
|
||||
track("button", "Copy element info");
|
||||
onCopy();
|
||||
}}
|
||||
className={copied ? "text-panel-accent" : undefined}
|
||||
>
|
||||
<ClipboardList size={13} />
|
||||
</button>
|
||||
<button type="button" aria-label="Clear selection" onClick={onClear}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
onClick={() => {
|
||||
track("button", "Clear selection");
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,3 +34,30 @@ export interface GsapAnimationEditCallbacks {
|
||||
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
|
||||
onUnroll?: (animationId: string) => void;
|
||||
}
|
||||
|
||||
// User-facing control label for each animation-meta field. The ease control is
|
||||
// labelled "Speed" in the card UI, so ease/easeEach map there.
|
||||
const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
|
||||
duration: { control: "metric", name: "Length" },
|
||||
position: { control: "metric", name: "Starts at" },
|
||||
ease: { control: "select", name: "Speed" },
|
||||
easeEach: { control: "select", name: "Speed" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit design-input telemetry for an `onUpdateMeta` payload, attributing each
|
||||
* changed field to the control the user actually touched. Iterates the real keys
|
||||
* present rather than falling through to a single placeholder — so a meta field
|
||||
* added later is attributed honestly by its own key instead of poisoning another
|
||||
* control's usage count.
|
||||
*/
|
||||
export function trackAnimationMetaUpdate(
|
||||
track: (control: string, name: string) => void,
|
||||
updates: Record<string, unknown>,
|
||||
): void {
|
||||
for (const key of Object.keys(updates)) {
|
||||
const mapped = ANIMATION_META_LABELS[key];
|
||||
if (mapped) track(mapped.control, mapped.name);
|
||||
else track("select", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MetricField } from "./propertyPanelPrimitives";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { Transform3DCube, type CubePose } from "./Transform3DCube";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
// translateZ only foreshortens under a perspective lens. Rather than hardcode one
|
||||
// (an arbitrary px value reads wrong at different canvas sizes), derive it from the
|
||||
@@ -71,6 +72,7 @@ function Cube3dControl({
|
||||
onKeyframe?: () => void;
|
||||
keyframed?: boolean;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const pose: CubePose = {
|
||||
rotationX: gsapRuntimeValues.rotationX ?? 0,
|
||||
rotationY: gsapRuntimeValues.rotationY ?? 0,
|
||||
@@ -96,6 +98,7 @@ function Cube3dControl({
|
||||
}
|
||||
const axes = Object.keys(changedProps);
|
||||
if (axes.length === 0) return;
|
||||
track("slider", "3D rotation pose");
|
||||
// ONE keyframe for the whole pose change — avoids per-axis commits racing into
|
||||
// adjacent duplicate keyframes.
|
||||
void onCommitAnimatedProperties(element, changedProps);
|
||||
@@ -111,6 +114,7 @@ function Cube3dControl({
|
||||
scale: 1,
|
||||
transformPerspective: 0,
|
||||
};
|
||||
track("button", "Reset 3D transform");
|
||||
void onCommitAnimatedProperties(element, identity);
|
||||
};
|
||||
// Immediate element feedback while dragging — set the live transform without a
|
||||
@@ -168,6 +172,7 @@ function Cube3dControl({
|
||||
}
|
||||
// One commit for all props so the writes can't race read-modify-write on
|
||||
// the same script (which dropped a prop and reverted after a seek).
|
||||
track("slider", "3D depth");
|
||||
void onCommitAnimatedProperties(element, props);
|
||||
}}
|
||||
onRecenter={recenter}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./colorValue";
|
||||
import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
|
||||
import { colorFromCss, FIELD, LABEL } from "./propertyPanelHelpers";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
const COLOR_PICKER_SIZE = { width: 292, height: 386 };
|
||||
|
||||
@@ -130,6 +131,7 @@ export function ColorField({
|
||||
flat?: boolean;
|
||||
onCommit: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -203,7 +205,9 @@ export function ColorField({
|
||||
const commitColor = (nextColor: ParsedColor) => {
|
||||
setDraftColor(nextColor);
|
||||
setHexDraft(toHexColor(nextColor).toUpperCase());
|
||||
onCommit(formatCssColor(nextColor));
|
||||
const nextValue = formatCssColor(nextColor);
|
||||
if (nextValue !== value) track("color", label);
|
||||
onCommit(nextValue);
|
||||
};
|
||||
|
||||
const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChevronDown, ChevronRight, Plus, X } from "../../icons/SystemIcons";
|
||||
import { LUT_EXT } from "../../utils/mediaTypes";
|
||||
import { LABEL } from "./propertyPanelHelpers";
|
||||
import { ColorGradingSliderControl } from "./propertyPanelColorGradingSlider";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
const LUT_UPLOAD_DIR = "assets/luts";
|
||||
|
||||
@@ -175,6 +176,7 @@ export function ColorGradingControls({
|
||||
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
||||
onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const lutInputRef = useRef<HTMLInputElement>(null);
|
||||
const [lutOpen, setLutOpen] = useState(false);
|
||||
const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
|
||||
@@ -195,7 +197,10 @@ export function ColorGradingControls({
|
||||
|
||||
const applyPreset = (preset: string) => {
|
||||
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
|
||||
if (next) onCommitColorGrading(next);
|
||||
if (next) {
|
||||
track("select", "Preset");
|
||||
onCommitColorGrading(next);
|
||||
}
|
||||
};
|
||||
const updateFilterIntensity = (value: number) => {
|
||||
onCommitColorGrading({
|
||||
@@ -218,7 +223,10 @@ export function ColorGradingControls({
|
||||
if (!files?.length || !onImportAssets) return;
|
||||
const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR);
|
||||
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
|
||||
if (firstLut) applyLut(firstLut, 1);
|
||||
if (firstLut) {
|
||||
track("button", "Import LUT");
|
||||
applyLut(firstLut, 1);
|
||||
}
|
||||
};
|
||||
const commitDetailSlider = (slider: DetailSlider, next: number) => {
|
||||
onCommitColorGrading({
|
||||
@@ -313,6 +321,7 @@ export function ColorGradingControls({
|
||||
value={selectedLut}
|
||||
onChange={(event) => {
|
||||
const nextSrc = event.target.value;
|
||||
track("select", "Custom LUT");
|
||||
applyLut(
|
||||
nextSrc || null,
|
||||
nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type MediaMetadata,
|
||||
type RuntimeColorGradingStatus,
|
||||
} from "./useColorGradingController";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
|
||||
const dotClass =
|
||||
@@ -68,10 +69,12 @@ function HoldBeforeButton({
|
||||
disabled: boolean;
|
||||
onHoldChange: (holding: boolean) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const startHold = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
track("toggle", "Compare original");
|
||||
onHoldChange(true);
|
||||
const release = () => {
|
||||
onHoldChange(false);
|
||||
@@ -105,7 +108,10 @@ function HoldBeforeButton({
|
||||
onKeyDown={(event) => {
|
||||
if (disabled || (event.key !== " " && event.key !== "Enter")) return;
|
||||
event.preventDefault();
|
||||
if (!active) onHoldChange(true);
|
||||
if (!active) {
|
||||
track("toggle", "Compare original");
|
||||
onHoldChange(true);
|
||||
}
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (disabled || (event.key !== " " && event.key !== "Enter")) return;
|
||||
@@ -148,6 +154,7 @@ export function ColorGradingSection({
|
||||
value: string | null,
|
||||
) => Promise<{ changedFiles: number; changedElements: number }>;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const {
|
||||
grading,
|
||||
compareEnabled,
|
||||
@@ -184,6 +191,7 @@ export function ColorGradingSection({
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
track("button", "Reset color grading");
|
||||
resetGrading();
|
||||
}}
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
|
||||
@@ -205,7 +213,10 @@ export function ColorGradingSection({
|
||||
<div className="mt-4 grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<select
|
||||
value={applyScope}
|
||||
onChange={(event) => setApplyScope(event.currentTarget.value as typeof applyScope)}
|
||||
onChange={(event) => {
|
||||
track("select", "Apply scope");
|
||||
setApplyScope(event.currentTarget.value as typeof applyScope);
|
||||
}}
|
||||
disabled={applyBusy}
|
||||
className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Choose where to copy these color grading settings"
|
||||
@@ -218,6 +229,7 @@ export function ColorGradingSection({
|
||||
disabled={applyBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
track("button", "Apply color grading scope");
|
||||
void applyToScope();
|
||||
}}
|
||||
className="h-8 rounded-md bg-panel-input px-3 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Minus, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
|
||||
import { LABEL } from "./propertyPanelHelpers";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
const SLIDER_THUMB_SIZE = 10;
|
||||
const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2;
|
||||
@@ -59,9 +60,11 @@ export function ColorGradingSliderControl({
|
||||
onClick: () => void;
|
||||
};
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null);
|
||||
const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const interactionChangedRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
@@ -92,9 +95,13 @@ export function ColorGradingSliderControl({
|
||||
(nextValue: number) => {
|
||||
const clamped = setLocalDraft(nextValue);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (interactionChangedRef.current) {
|
||||
interactionChangedRef.current = false;
|
||||
track("slider", label);
|
||||
}
|
||||
if (clamped !== valueRef.current) onCommit(clamped);
|
||||
},
|
||||
[onCommit, setLocalDraft],
|
||||
[label, onCommit, setLocalDraft, track],
|
||||
);
|
||||
|
||||
const scheduleCommit = useCallback(
|
||||
@@ -115,6 +122,7 @@ export function ColorGradingSliderControl({
|
||||
const commitInputDraft = useCallback(() => {
|
||||
const parsed = parseNumericInput(inputValue, scale);
|
||||
if (parsed === null) {
|
||||
interactionChangedRef.current = false;
|
||||
setInputDraft(null);
|
||||
return;
|
||||
}
|
||||
@@ -123,6 +131,7 @@ export function ColorGradingSliderControl({
|
||||
|
||||
const nudge = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
interactionChangedRef.current = true;
|
||||
commitDraft(draft + step * direction);
|
||||
},
|
||||
[commitDraft, draft, step],
|
||||
@@ -166,6 +175,7 @@ export function ColorGradingSliderControl({
|
||||
aria-label={`Reset ${label}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
track("button", `Reset ${label}`);
|
||||
onReset();
|
||||
}}
|
||||
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
@@ -205,7 +215,10 @@ export function ColorGradingSliderControl({
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
onChange={(event) => scheduleCommit(Number(event.currentTarget.value))}
|
||||
onChange={(event) => {
|
||||
interactionChangedRef.current = true;
|
||||
scheduleCommit(Number(event.currentTarget.value));
|
||||
}}
|
||||
onMouseUp={() => commitDraft(draft)}
|
||||
onTouchEnd={() => commitDraft(draft)}
|
||||
onBlur={() => commitDraft(draft)}
|
||||
@@ -223,9 +236,10 @@ export function ColorGradingSliderControl({
|
||||
max={max / scale}
|
||||
step={step / scale}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
setInputDraft({ value: event.currentTarget.value, source: valueRef.current })
|
||||
}
|
||||
onChange={(event) => {
|
||||
interactionChangedRef.current = true;
|
||||
setInputDraft({ value: event.currentTarget.value, source: valueRef.current });
|
||||
}}
|
||||
onBlur={commitInputDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SliderControl,
|
||||
} from "./propertyPanelPrimitives";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Asset path helpers */
|
||||
@@ -87,6 +88,7 @@ export function ImageFillField({
|
||||
onCommit: (nextValue: string) => void;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
|
||||
@@ -102,7 +104,10 @@ export function ImageFillField({
|
||||
try {
|
||||
const uploaded = await onImportAssets(files);
|
||||
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
|
||||
if (nextImage) onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
||||
if (nextImage) {
|
||||
track("button", "Upload image");
|
||||
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -156,6 +161,7 @@ export function ImageFillField({
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
track("select", "Project asset");
|
||||
if (!next) {
|
||||
onCommit("none");
|
||||
return;
|
||||
@@ -205,6 +211,7 @@ export function GradientField({
|
||||
disabled?: boolean;
|
||||
onCommit: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
|
||||
|
||||
@@ -226,11 +233,13 @@ export function GradientField({
|
||||
? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
|
||||
: 100,
|
||||
);
|
||||
track("button", "Add gradient stop");
|
||||
commit(nextGradient);
|
||||
};
|
||||
|
||||
const removeStop = (index: number) => {
|
||||
if (parsed.stops.length <= 2) return;
|
||||
track("button", `Remove gradient stop ${index + 1}`);
|
||||
commit({ ...parsed, stops: parsed.stops.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
@@ -263,6 +272,7 @@ export function GradientField({
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<SegmentedControl
|
||||
trackName="Gradient type"
|
||||
disabled={disabled}
|
||||
value={parsed.kind}
|
||||
onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
|
||||
@@ -277,7 +287,10 @@ export function GradientField({
|
||||
type="checkbox"
|
||||
checked={parsed.repeating}
|
||||
disabled={disabled}
|
||||
onChange={(e) => patch({ repeating: e.target.checked })}
|
||||
onChange={(e) => {
|
||||
track("toggle", "Repeat gradient");
|
||||
patch({ repeating: e.target.checked });
|
||||
}}
|
||||
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-panel-accent focus:ring-panel-accent"
|
||||
/>
|
||||
Repeat
|
||||
@@ -285,15 +298,16 @@ export function GradientField({
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
track("button", "Reverse gradient");
|
||||
commit({
|
||||
...parsed,
|
||||
stops: [...parsed.stops].reverse().map((stop) => ({
|
||||
...stop,
|
||||
position: 100 - stop.position,
|
||||
})),
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
@@ -306,6 +320,7 @@ export function GradientField({
|
||||
<div className="grid gap-1.5">
|
||||
<span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
|
||||
<SliderControl
|
||||
trackName={parsed.kind === "linear" ? "Angle" : "Start angle"}
|
||||
value={parsed.angle}
|
||||
min={0}
|
||||
max={360}
|
||||
@@ -342,6 +357,7 @@ export function GradientField({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Center X</span>
|
||||
<SliderControl
|
||||
trackName="Center X"
|
||||
value={parsed.centerX}
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -355,6 +371,7 @@ export function GradientField({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Center Y</span>
|
||||
<SliderControl
|
||||
trackName="Center Y"
|
||||
value={parsed.centerY}
|
||||
min={0}
|
||||
max={100}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type NormalizedHfColorGrading,
|
||||
} from "@hyperframes/core/color-grading";
|
||||
import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { LUT_EXT } from "../../utils/mediaTypes";
|
||||
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
|
||||
import { resolveValueTier } from "./propertyPanelValueTier";
|
||||
@@ -30,6 +31,7 @@ export function FlatColorGradingAccessory({
|
||||
"grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
|
||||
>;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
|
||||
const gradingActive = isHfColorGradingActive(grading);
|
||||
// Tracks the active hold's cleanup so it can be torn down on unmount too —
|
||||
@@ -56,6 +58,7 @@ export function FlatColorGradingAccessory({
|
||||
if (!gradingActive) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
track("button", "Compare original");
|
||||
commitCompare(true);
|
||||
const release = () => {
|
||||
commitCompare(false);
|
||||
@@ -75,7 +78,10 @@ export function FlatColorGradingAccessory({
|
||||
onKeyDown={(e) => {
|
||||
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
|
||||
e.preventDefault();
|
||||
if (!compareEnabled) commitCompare(true);
|
||||
if (!compareEnabled) {
|
||||
track("button", "Compare original");
|
||||
commitCompare(true);
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
|
||||
@@ -106,6 +112,7 @@ export function FlatColorGradingAccessory({
|
||||
title="Reset color grading"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
track("button", "Reset color grading");
|
||||
resetGrading();
|
||||
}}
|
||||
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
|
||||
@@ -242,6 +249,7 @@ export function FlatColorGradingSection({
|
||||
onApplyScopeAvailable: boolean;
|
||||
mediaMetadata: MediaMetadata | null;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const lutInputRef = useRef<HTMLInputElement>(null);
|
||||
const [lutOpen, setLutOpen] = useState(false);
|
||||
const [detailSettingsOpen, setDetailSettingsOpen] = useState<"vignette" | "grain" | null>(null);
|
||||
@@ -270,7 +278,10 @@ export function FlatColorGradingSection({
|
||||
if (!files?.length || !onImportAssets) return;
|
||||
const uploaded = await onImportAssets(files, "assets/luts");
|
||||
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
|
||||
if (firstLut) applyLut(firstLut, 1);
|
||||
if (firstLut) {
|
||||
track("button", "Import LUT");
|
||||
applyLut(firstLut, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDetailSlider = (key: HfColorGradingDetailKey) => {
|
||||
@@ -363,6 +374,7 @@ export function FlatColorGradingSection({
|
||||
value={lut?.src ?? ""}
|
||||
onChange={(e) => {
|
||||
const src = e.target.value;
|
||||
track("select", "Custom LUT");
|
||||
applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
|
||||
}}
|
||||
className="bg-transparent font-mono text-[10px] text-panel-text-3 outline-none"
|
||||
@@ -528,7 +540,10 @@ export function FlatColorGradingSection({
|
||||
<select
|
||||
aria-label="Copy grade to"
|
||||
value={applyScope}
|
||||
onChange={(e) => onSetApplyScope(e.target.value as "source-file" | "project")}
|
||||
onChange={(e) => {
|
||||
track("select", "Copy grade scope");
|
||||
onSetApplyScope(e.target.value as "source-file" | "project");
|
||||
}}
|
||||
disabled={applyBusy}
|
||||
className="bg-transparent font-mono text-[11px] text-panel-text-0 outline-none disabled:opacity-50"
|
||||
>
|
||||
@@ -540,7 +555,10 @@ export function FlatColorGradingSection({
|
||||
type="button"
|
||||
data-flat-grade-apply="true"
|
||||
disabled={applyBusy}
|
||||
onClick={onApplyToScope}
|
||||
onClick={() => {
|
||||
track("button", "Apply grade to scope");
|
||||
onApplyToScope();
|
||||
}}
|
||||
className="text-[11px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{applyBusy ? "Applying" : "Apply"}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
||||
@@ -67,6 +68,7 @@ function KeyframeGutter({
|
||||
| "onRemoveKeyframe"
|
||||
| "onConvertToKeyframes"
|
||||
>) {
|
||||
const track = useTrackDesignInput();
|
||||
if (!STUDIO_KEYFRAMES_ENABLED || !gsapAnimId) return null;
|
||||
const hasKeyframesOnProp = Boolean(navKeyframes?.some((kf) => property in kf.properties));
|
||||
return (
|
||||
@@ -76,11 +78,21 @@ function KeyframeGutter({
|
||||
keyframes={navKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={seekFromKfPct}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))}
|
||||
onAddKeyframe={() => {
|
||||
if (!onCommitAnimatedProperty) return;
|
||||
track("button", `Add ${property} keyframe`);
|
||||
void onCommitAnimatedProperty(element, property, displayValue);
|
||||
}}
|
||||
onRemoveKeyframe={(pct) => {
|
||||
if (!onRemoveKeyframe) return;
|
||||
track("button", `Remove ${property} keyframe`);
|
||||
onRemoveKeyframe(animIdForProp(property), pct);
|
||||
}}
|
||||
onConvertToKeyframes={() => {
|
||||
if (!onConvertToKeyframes) return;
|
||||
track("button", `Convert ${property} to keyframes`);
|
||||
onConvertToKeyframes(animIdForProp(property));
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { Check, ClipboardList } from "../../icons/SystemIcons";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ export function FlatMediaSection({
|
||||
},
|
||||
) => Promise<BackgroundRemovalResult>;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const isVideo = element.tagName === "video";
|
||||
const isAudio = element.tagName === "audio";
|
||||
const isImage = element.tagName === "img";
|
||||
@@ -91,6 +93,7 @@ export function FlatMediaSection({
|
||||
|
||||
const runBackgroundRemoval = async () => {
|
||||
if (!onRemoveBackground || !projectSrc || removeBusy) return;
|
||||
track("button", "Remove background");
|
||||
setRemoveBusy(true);
|
||||
setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" });
|
||||
try {
|
||||
@@ -126,6 +129,7 @@ export function FlatMediaSection({
|
||||
type="button"
|
||||
data-flat-media-copy="true"
|
||||
onClick={() => {
|
||||
track("button", "Copy media path");
|
||||
void navigator.clipboard.writeText(absoluteSrc).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { parseTimingValue } from "./propertyPanelTimingSection";
|
||||
import { CommitField } from "./propertyPanelPrimitives";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
||||
import {
|
||||
trackAnimationMetaUpdate,
|
||||
type GsapAnimationEditCallbacks,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
|
||||
export function FlatTimingRow({
|
||||
@@ -25,6 +29,7 @@ export function FlatTimingRow({
|
||||
* documented below) when the caller doesn't wire it up. */
|
||||
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const { start, duration, inferred: derived } = deriveElementTiming(element, animations);
|
||||
const end = start + duration;
|
||||
|
||||
@@ -81,7 +86,13 @@ export function FlatTimingRow({
|
||||
<div className="grid gap-px">
|
||||
<span className="text-[9px] text-panel-text-4">{label}</span>
|
||||
<span className="border-b border-transparent font-mono text-[11px] text-panel-text-0 hover:border-panel-border-input">
|
||||
<CommitField value={value} onCommit={onCommit} />
|
||||
<CommitField
|
||||
value={value}
|
||||
onCommit={(next) => {
|
||||
track("metric", label);
|
||||
onCommit(next);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -122,7 +133,17 @@ export function FlatMotionSection({
|
||||
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
} & GsapAnimationEditCallbacks) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackProperty = (property: string) => {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -155,7 +176,97 @@ export function FlatMotionSection({
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
flat
|
||||
{...callbacks}
|
||||
onUpdateProperty={(animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
callbacks.onUpdateProperty(animationId, property, value);
|
||||
}}
|
||||
onUpdateMeta={(animationId, updates) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
callbacks.onUpdateMeta(animationId, updates);
|
||||
}}
|
||||
onDeleteAnimation={(animationId) => {
|
||||
track("button", "Remove animation");
|
||||
callbacks.onDeleteAnimation(animationId);
|
||||
}}
|
||||
onAddProperty={(animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
callbacks.onAddProperty(animationId, property);
|
||||
}}
|
||||
onRemoveProperty={(animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
callbacks.onRemoveProperty(animationId, property);
|
||||
}}
|
||||
onUpdateFromProperty={
|
||||
callbacks.onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
callbacks.onUpdateFromProperty?.(animationId, property, value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAddFromProperty={
|
||||
callbacks.onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
callbacks.onAddFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemoveFromProperty={
|
||||
callbacks.onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
callbacks.onRemoveFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onLivePreview={callbacks.onLivePreview}
|
||||
onLivePreviewEnd={callbacks.onLivePreviewEnd}
|
||||
onSetArcPath={
|
||||
callbacks.onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track(
|
||||
"toggle",
|
||||
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
|
||||
);
|
||||
callbacks.onSetArcPath?.(animationId, config);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateArcSegment={
|
||||
callbacks.onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateKeyframeEase={
|
||||
callbacks.onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSetAllKeyframeEases={
|
||||
callbacks.onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
callbacks.onSetAllKeyframeEases?.(animationId, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUnroll={
|
||||
callbacks.onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
callbacks.onUnroll?.(animationId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className="relative pt-1">
|
||||
@@ -167,6 +278,7 @@ export function FlatMotionSection({
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setAddMenuOpen(false);
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { RotateCcw } from "../../icons/SystemIcons";
|
||||
import { CommitField } from "./propertyPanelPrimitives";
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ export function FlatRow({
|
||||
onCommit: (nextValue: string) => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
return (
|
||||
<div className="group flex min-h-[30px] items-center justify-between gap-3">
|
||||
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
||||
@@ -49,7 +51,10 @@ export function FlatRow({
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
liveCommit={liveCommit}
|
||||
onCommit={onCommit}
|
||||
onCommit={(nextValue) => {
|
||||
track("metric", label);
|
||||
onCommit(nextValue);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
{suffix}
|
||||
@@ -58,7 +63,10 @@ export function FlatRow({
|
||||
type="button"
|
||||
data-flat-row-reset="true"
|
||||
title="Remove — fall back to default"
|
||||
onClick={onReset}
|
||||
onClick={() => {
|
||||
track("button", `Reset ${label}`);
|
||||
onReset();
|
||||
}}
|
||||
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
@@ -109,6 +117,7 @@ export function FlatSegmentedRow({
|
||||
spacerAfterIndex?: number;
|
||||
onChange: (nextKey: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
return (
|
||||
<div className="flex min-h-[32px] items-center justify-between">
|
||||
<span className="text-[11px] text-panel-text-3">{label}</span>
|
||||
@@ -121,7 +130,10 @@ export function FlatSegmentedRow({
|
||||
aria-label={option.label}
|
||||
aria-pressed={option.active}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(option.key)}
|
||||
onClick={() => {
|
||||
if (!option.active) track("segmented", label);
|
||||
onChange(option.key);
|
||||
}}
|
||||
className={`px-1.5 py-1 text-[11px] transition-colors disabled:cursor-not-allowed ${
|
||||
option.active
|
||||
? "border-b-2 border-panel-accent text-panel-text-0"
|
||||
@@ -270,6 +282,7 @@ export function FlatSlider({
|
||||
onReset?: () => void;
|
||||
onCommit: (nextValue: number) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
// `draft` gives the knob instant, drag-local visual feedback. `onCommit` is
|
||||
// throttled (not debounced) to at most once per 40ms: a real drag fires
|
||||
// pointermove faster than that, and a pure debounce (reset the timer on
|
||||
@@ -439,6 +452,7 @@ export function FlatSlider({
|
||||
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
||||
setDraft(stepped);
|
||||
commitDraft(stepped);
|
||||
if (stepped !== dragStartValueRef.current) track("slider", label);
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
// A native pointercancel means the platform aborted the gesture (a
|
||||
@@ -481,6 +495,7 @@ export function FlatSlider({
|
||||
e.preventDefault();
|
||||
setDraft(next);
|
||||
commitDraft(next);
|
||||
if (next !== draft) track("slider", label);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
// Right-click during a drag must cancel it (revert to the pre-drag
|
||||
@@ -530,7 +545,10 @@ export function FlatSlider({
|
||||
data-flat-slider-reset="true"
|
||||
title="Remove — fall back to default"
|
||||
disabled={disabled}
|
||||
onClick={onReset}
|
||||
onClick={() => {
|
||||
track("button", `Reset ${label}`);
|
||||
onReset();
|
||||
}}
|
||||
className="text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RotateCcw } from "../../icons/SystemIcons";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import {
|
||||
VALUE_TIER_LABEL_CLASS,
|
||||
VALUE_TIER_VALUE_CLASS,
|
||||
@@ -32,6 +33,8 @@ export function FlatSelectRow({
|
||||
onChange: (nextValue: string) => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const trackName = ariaLabel || label;
|
||||
const normalizedOptions = options.map((option) =>
|
||||
typeof option === "string" ? { value: option, label: option } : option,
|
||||
);
|
||||
@@ -55,7 +58,10 @@ export function FlatSelectRow({
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || label || undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
track("select", trackName);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
|
||||
>
|
||||
{renderedOptions.map((option) => (
|
||||
@@ -80,7 +86,10 @@ export function FlatSelectRow({
|
||||
data-flat-select-reset="true"
|
||||
title="Remove — fall back to default"
|
||||
disabled={disabled}
|
||||
onClick={onReset}
|
||||
onClick={() => {
|
||||
track("button", `Reset ${trackName}`);
|
||||
onReset();
|
||||
}}
|
||||
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { Plus, X } from "../../icons/SystemIcons";
|
||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||
import type { ImportedFontAsset } from "./fontAssets";
|
||||
@@ -56,6 +57,7 @@ function FlatTextFieldEditor({
|
||||
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const weight = getTextStyleValue(field, styles, "font-weight", "400");
|
||||
const weightOptions = detectAvailableWeights(
|
||||
field.computedStyles["font-family"] || styles["font-family"] || "",
|
||||
@@ -112,7 +114,10 @@ function FlatTextFieldEditor({
|
||||
<label className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={weight}
|
||||
onChange={(e) => onSetTextFieldStyle(field.key, "font-weight", e.target.value)}
|
||||
onChange={(e) => {
|
||||
track("select", "Weight");
|
||||
onSetTextFieldStyle(field.key, "font-weight", e.target.value);
|
||||
}}
|
||||
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none ${
|
||||
VALUE_TIER_VALUE_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
|
||||
}`}
|
||||
@@ -242,6 +247,7 @@ export function FlatTextSection({
|
||||
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
|
||||
element.textFields[0]?.key ?? null,
|
||||
);
|
||||
@@ -300,7 +306,10 @@ export function FlatTextSection({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onAddTextField(activeField.key)}
|
||||
onClick={() => {
|
||||
track("button", "Add text field");
|
||||
void onAddTextField(activeField.key);
|
||||
}}
|
||||
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
|
||||
>
|
||||
<Plus size={10} />
|
||||
@@ -333,6 +342,7 @@ export function FlatTextLayerList({
|
||||
onAdd: () => void;
|
||||
onRemove: (fieldKey: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
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">
|
||||
@@ -368,6 +378,7 @@ export function FlatTextLayerList({
|
||||
aria-label="Remove text field"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
track("button", "Remove text field");
|
||||
onRemove(field.key);
|
||||
}}
|
||||
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
|
||||
@@ -382,7 +393,10 @@ export function FlatTextLayerList({
|
||||
<button
|
||||
type="button"
|
||||
data-flat-text-layer-add="true"
|
||||
onClick={onAdd}
|
||||
onClick={() => {
|
||||
track("button", "Add text field");
|
||||
onAdd();
|
||||
}}
|
||||
className="mt-1 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
|
||||
>
|
||||
<Plus size={10} />
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
/* 600-line file-size gate) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
export function FlatToggle({
|
||||
label,
|
||||
checked,
|
||||
@@ -15,6 +17,7 @@ export function FlatToggle({
|
||||
disabled?: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
return (
|
||||
<div className="flex min-h-[30px] items-center justify-between">
|
||||
<span
|
||||
@@ -30,7 +33,10 @@ export function FlatToggle({
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
onClick={() => {
|
||||
track("toggle", label);
|
||||
onChange(!checked);
|
||||
}}
|
||||
className={`relative h-[14px] w-6 flex-shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-panel-accent/35" : "bg-panel-hover"
|
||||
}`}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type FontOption,
|
||||
type LocalFontData,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Font helper functions */
|
||||
@@ -135,6 +136,7 @@ export function FontFamilyField({
|
||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||
onCommit: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const currentFamily = primaryFontFamily(value);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -150,6 +152,10 @@ export function FontFamilyField({
|
||||
const [fontNotice, setFontNotice] = useState<string | null>(null);
|
||||
const canQueryLocalFonts =
|
||||
typeof window !== "undefined" && typeof window.queryLocalFonts === "function";
|
||||
const commitFontFamily = (nextValue: string) => {
|
||||
if (nextValue !== value) track("select", "Font family");
|
||||
onCommit(nextValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -247,7 +253,7 @@ export function FontFamilyField({
|
||||
for (const font of imported) loadImportedFontStylesheet(font);
|
||||
const first = imported[0];
|
||||
if (first) {
|
||||
onCommit(buildFontFamilyValue(first.family));
|
||||
commitFontFamily(buildFontFamilyValue(first.family));
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
} else {
|
||||
@@ -349,7 +355,7 @@ export function FontFamilyField({
|
||||
: await importSystemFont(option.family);
|
||||
if (imported) {
|
||||
loadImportedFontStylesheet(imported);
|
||||
onCommit(buildFontFamilyValue(imported.family));
|
||||
commitFontFamily(buildFontFamilyValue(imported.family));
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
return;
|
||||
@@ -363,7 +369,7 @@ export function FontFamilyField({
|
||||
(f) => f.family.toLowerCase() === option.family.toLowerCase(),
|
||||
);
|
||||
if (imported) loadImportedFontStylesheet(imported);
|
||||
onCommit(buildFontFamilyValue(option.family));
|
||||
commitFontFamily(buildFontFamilyValue(option.family));
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, type ReactElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
||||
import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
|
||||
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { FontFamilyField } from "./propertyPanelFont";
|
||||
import {
|
||||
FlatRow,
|
||||
FlatSegmentedRow,
|
||||
FlatSelectRow,
|
||||
FlatSlider,
|
||||
} from "./propertyPanelFlatPrimitives";
|
||||
import { FlatToggle } from "./propertyPanelFlatToggle";
|
||||
import {
|
||||
DetailField,
|
||||
MetricField,
|
||||
Section,
|
||||
SegmentedControl,
|
||||
SelectField,
|
||||
SliderControl,
|
||||
} from "./propertyPanelPrimitives";
|
||||
import { TextAreaField } from "./propertyPanelSections";
|
||||
|
||||
const trackStudioEvent = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../utils/studioTelemetry", () => ({
|
||||
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/StudioContext", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
|
||||
"../../contexts/StudioContext",
|
||||
);
|
||||
return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
|
||||
});
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
trackStudioEvent.mockReset();
|
||||
__resetDesignInputThrottle();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify([]), { status: 200 })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots) act(() => root.unmount());
|
||||
roots = [];
|
||||
document.body.innerHTML = "";
|
||||
vi.useRealTimers();
|
||||
vi.doUnmock("./manualEditingAvailability");
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(ui: ReactElement): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
roots.push(root);
|
||||
act(() => root.render(ui));
|
||||
return host;
|
||||
}
|
||||
|
||||
function changeInput(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
if (!setter) throw new Error("expected native input value setter");
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function changeTextarea(textarea: HTMLTextAreaElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
if (!setter) throw new Error("expected native textarea value setter");
|
||||
setter.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function blurInput(input: HTMLInputElement) {
|
||||
input.focus();
|
||||
input.blur();
|
||||
}
|
||||
|
||||
function expectTracked(control: string, name: string, section = "style") {
|
||||
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
|
||||
ui: "classic",
|
||||
section,
|
||||
control,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
function expectFlatTracked(control: string, name: string, section = "layout") {
|
||||
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
|
||||
ui: "flat",
|
||||
section,
|
||||
control,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
function flatSection(children: ReactElement) {
|
||||
return (
|
||||
<DesignPanelInputProvider ui="flat" section="layout">
|
||||
{children}
|
||||
</DesignPanelInputProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function classicSection(children: ReactElement) {
|
||||
return (
|
||||
<DesignPanelInputProvider ui="classic">
|
||||
<Section title="Style" icon={null}>
|
||||
{children}
|
||||
</Section>
|
||||
</DesignPanelInputProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("classic property-panel primitive telemetry", () => {
|
||||
it("tracks MetricField only when a changed value commits", () => {
|
||||
const onCommit = vi.fn();
|
||||
const host = render(
|
||||
classicSection(<MetricField label="Opacity" value="20" onCommit={onCommit} />),
|
||||
);
|
||||
const input = host.querySelector("input");
|
||||
if (!input) throw new Error("expected metric input");
|
||||
|
||||
act(() => blurInput(input));
|
||||
expect(trackStudioEvent).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
changeInput(input, "40");
|
||||
});
|
||||
act(() => blurInput(input));
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith("40");
|
||||
expectTracked("metric", "opacity");
|
||||
});
|
||||
|
||||
it("tracks SliderControl on settle, not on its scheduled commit tick", () => {
|
||||
vi.useFakeTimers();
|
||||
const onCommit = vi.fn();
|
||||
const host = render(
|
||||
classicSection(
|
||||
<SliderControl
|
||||
trackName="Opacity"
|
||||
value={20}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
displayValue="20%"
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const input = host.querySelector<HTMLInputElement>('input[type="range"]');
|
||||
if (!input) throw new Error("expected slider input");
|
||||
|
||||
act(() => {
|
||||
changeInput(input, "40");
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(40));
|
||||
expect(trackStudioEvent).not.toHaveBeenCalled();
|
||||
|
||||
act(() => input.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })));
|
||||
expectTracked("slider", "opacity");
|
||||
});
|
||||
|
||||
it("tracks SelectField with its label", () => {
|
||||
const host = render(
|
||||
classicSection(
|
||||
<SelectField
|
||||
label="Blend mode"
|
||||
value="normal"
|
||||
options={["normal", "multiply"]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const select = host.querySelector("select");
|
||||
if (!select) throw new Error("expected select");
|
||||
act(() => {
|
||||
select.value = "multiply";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expectTracked("select", "blend-mode");
|
||||
});
|
||||
|
||||
it("tracks DetailField with its label", () => {
|
||||
const host = render(
|
||||
classicSection(<DetailField label="External URL" value="old.png" onCommit={vi.fn()} />),
|
||||
);
|
||||
const input = host.querySelector("input");
|
||||
if (!input) throw new Error("expected detail input");
|
||||
act(() => changeInput(input, "new.png"));
|
||||
act(() => blurInput(input));
|
||||
expectTracked("text", "external-url");
|
||||
});
|
||||
|
||||
it("tracks SegmentedControl with its explicit name", () => {
|
||||
const host = render(
|
||||
classicSection(
|
||||
<SegmentedControl
|
||||
trackName="Fill type"
|
||||
value="solid"
|
||||
options={[
|
||||
{ label: "Solid", value: "solid" },
|
||||
{ label: "Gradient", value: "gradient" },
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const gradient = Array.from(host.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Gradient",
|
||||
);
|
||||
if (!gradient) throw new Error("expected Gradient segment");
|
||||
act(() => gradient.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expectTracked("segmented", "fill-type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flat property-panel primitive telemetry", () => {
|
||||
it("tracks FlatRow commits with its label", () => {
|
||||
const host = render(
|
||||
flatSection(<FlatRow label="Z-index" value="1" tier="default" onCommit={vi.fn()} />),
|
||||
);
|
||||
const input = host.querySelector("input");
|
||||
if (!input) throw new Error("expected flat row input");
|
||||
|
||||
act(() => changeInput(input, "2"));
|
||||
act(() => blurInput(input));
|
||||
|
||||
expectFlatTracked("metric", "z-index");
|
||||
});
|
||||
|
||||
it("tracks FlatSlider once on pointer settle, not during drag commits", () => {
|
||||
const host = render(
|
||||
flatSection(
|
||||
<FlatSlider
|
||||
label="Opacity"
|
||||
value={10}
|
||||
min={0}
|
||||
max={100}
|
||||
tier="explicitCustom"
|
||||
displayValue="10%"
|
||||
onCommit={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const slider = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
|
||||
if (!slider) throw new Error("expected flat slider");
|
||||
Object.defineProperty(slider, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
slider.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
|
||||
);
|
||||
slider.dispatchEvent(
|
||||
new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }),
|
||||
);
|
||||
});
|
||||
expect(trackStudioEvent).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
slider.dispatchEvent(
|
||||
new PointerEvent("pointerup", { bubbles: true, clientX: 80, pointerId: 1 }),
|
||||
);
|
||||
});
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
||||
expectFlatTracked("slider", "opacity");
|
||||
});
|
||||
|
||||
it("tracks FlatSegmentedRow changes with its label", () => {
|
||||
const host = render(
|
||||
flatSection(
|
||||
<FlatSegmentedRow
|
||||
label="Direction"
|
||||
options={[
|
||||
{ key: "row", node: "Row", label: "Row", active: true },
|
||||
{ key: "column", node: "Column", label: "Column", active: false },
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const column = host.querySelector<HTMLButtonElement>('[aria-label="Column"]');
|
||||
act(() => column?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expectFlatTracked("segmented", "direction");
|
||||
});
|
||||
|
||||
it("tracks FlatToggle changes with its label", () => {
|
||||
const host = render(
|
||||
flatSection(<FlatToggle label="Loop" checked={false} onChange={vi.fn()} />),
|
||||
);
|
||||
const toggle = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
|
||||
act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expectFlatTracked("toggle", "loop");
|
||||
});
|
||||
|
||||
it("tracks FlatSelectRow changes with its accessible label", () => {
|
||||
const host = render(
|
||||
flatSection(
|
||||
<FlatSelectRow
|
||||
label=""
|
||||
ariaLabel="Preset"
|
||||
value="neutral"
|
||||
options={["neutral", "warm"]}
|
||||
tier="default"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const select = host.querySelector("select");
|
||||
if (!select) throw new Error("expected flat select");
|
||||
act(() => {
|
||||
select.value = "warm";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expectFlatTracked("select", "preset");
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["classic", "flat"] as const)("shared %s input telemetry", (ui) => {
|
||||
const section = (children: ReactElement) => (
|
||||
<DesignPanelInputProvider ui={ui} section="text">
|
||||
{children}
|
||||
</DesignPanelInputProvider>
|
||||
);
|
||||
|
||||
it("tracks ColorField exactly once for a real color change", () => {
|
||||
const host = render(
|
||||
section(<ColorField flat={ui === "flat"} label="Color" value="#FF0000" onCommit={vi.fn()} />),
|
||||
);
|
||||
const trigger = host.querySelector<HTMLButtonElement>('[aria-label="Pick color color"]');
|
||||
act(() => trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const hex = Array.from(document.body.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(input) => input.value === "#FF0000",
|
||||
);
|
||||
if (!hex) throw new Error("expected color hex input");
|
||||
act(() => changeInput(hex, "#00FF00"));
|
||||
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
|
||||
ui,
|
||||
section: "text",
|
||||
control: "color",
|
||||
name: "color",
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks FontFamilyField exactly once for a real selection", () => {
|
||||
const host = render(
|
||||
section(
|
||||
<FontFamilyField
|
||||
flat={ui === "flat"}
|
||||
value="Arial"
|
||||
importedFonts={[]}
|
||||
onCommit={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const trigger = host.querySelector<HTMLButtonElement>(
|
||||
ui === "flat" ? '[data-flat-font-trigger="true"]' : "button",
|
||||
);
|
||||
act(() => trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const option = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((button) =>
|
||||
button.textContent?.includes("sans-serif"),
|
||||
);
|
||||
if (!option) throw new Error("expected sans-serif font option");
|
||||
act(() => option.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
|
||||
ui,
|
||||
section: "text",
|
||||
control: "select",
|
||||
name: "font-family",
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks TextAreaField exactly once across scheduled commit and blur", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = render(
|
||||
section(
|
||||
<TextAreaField flat={ui === "flat"} label="Content" value="Before" onCommit={vi.fn()} />,
|
||||
),
|
||||
);
|
||||
const textarea = host.querySelector("textarea");
|
||||
if (!textarea) throw new Error("expected text area");
|
||||
act(() => changeTextarea(textarea, "After"));
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
act(() => {
|
||||
textarea.focus();
|
||||
textarea.blur();
|
||||
});
|
||||
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
|
||||
ui,
|
||||
section: "text",
|
||||
control: "text",
|
||||
name: "content",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function representativeElement() {
|
||||
return {
|
||||
element: document.createElement("div"),
|
||||
id: "panel-target",
|
||||
selector: "#panel-target",
|
||||
label: "Panel Target",
|
||||
tagName: "div",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
|
||||
textContent: "",
|
||||
dataAttributes: {},
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: false,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("classic PropertyPanel input coverage", () => {
|
||||
it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
|
||||
const { PropertyPanel } = await import("./PropertyPanel");
|
||||
const host = render(
|
||||
<PropertyPanel
|
||||
{...({
|
||||
element: representativeElement(),
|
||||
assets: [],
|
||||
onSetStyle: vi.fn(),
|
||||
onSetText: vi.fn(),
|
||||
onSetAttributeLive: vi.fn(),
|
||||
onSetManualOffset: vi.fn(),
|
||||
onSetManualSize: vi.fn(),
|
||||
onSetManualRotation: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
onAskAgent: vi.fn(),
|
||||
onToggleElementHidden: vi.fn(),
|
||||
recordingState: "idle",
|
||||
onToggleRecording: vi.fn(),
|
||||
} as unknown as PropertyPanelProps)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Fire every body text input across the WHOLE panel (not just the layout
|
||||
// section): a section rendered without a <DesignPanelInputProvider section="X">
|
||||
// would surface here as section "unknown" and fail the invariant below.
|
||||
const bodyInputs = Array.from(host.querySelectorAll<HTMLInputElement>('input[type="text"]'));
|
||||
expect(bodyInputs.length).toBeGreaterThan(0);
|
||||
for (const [index, input] of bodyInputs.entries()) {
|
||||
act(() => changeInput(input, String(100 + index)));
|
||||
act(() => blurInput(input));
|
||||
}
|
||||
|
||||
// Header + footer chrome — the classic siblings of the flat header/footer.
|
||||
// (Copy is skipped: its handler reaches for the clipboard, unavailable here.
|
||||
// The visibility toggle is store-gated on a live selection this unit mock does
|
||||
// not model; Clear selection already exercises the header section.)
|
||||
const clear = host.querySelector<HTMLButtonElement>('[aria-label="Clear selection"]');
|
||||
if (!clear) throw new Error("expected classic Clear selection control");
|
||||
act(() => clear.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const recordButton = Array.from(host.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Record gesture"),
|
||||
);
|
||||
if (!recordButton) throw new Error("expected classic gesture record button");
|
||||
act(() => recordButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
expect(trackStudioEvent).toHaveBeenCalled();
|
||||
const sections = new Set<string>();
|
||||
for (const [, payload] of trackStudioEvent.mock.calls) {
|
||||
expect(payload.ui).toBe("classic");
|
||||
expect(payload.name).not.toBe("");
|
||||
expect(payload.name).not.toBe("unnamed");
|
||||
expect(payload.section).not.toBe("");
|
||||
expect(payload.section).not.toBe("unknown");
|
||||
sections.add(payload.section as string);
|
||||
}
|
||||
// A body section plus both chrome regions: proves coverage beyond one section
|
||||
// and that classic chrome is wired in parallel with the flat header/footer.
|
||||
expect(sections.has("header")).toBe(true);
|
||||
expect(sections.has("footer")).toBe(true);
|
||||
expect(sections.size).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flat PropertyPanel input coverage", () => {
|
||||
it("emits only named flat events from known sections for every visible layout input", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("./manualEditingAvailability", async () => {
|
||||
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
|
||||
"./manualEditingAvailability",
|
||||
);
|
||||
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
|
||||
});
|
||||
const { PropertyPanel } = await import("./PropertyPanel");
|
||||
const host = render(
|
||||
<PropertyPanel
|
||||
{...({
|
||||
element: representativeElement(),
|
||||
assets: [],
|
||||
onSetStyle: vi.fn(),
|
||||
onSetText: vi.fn(),
|
||||
onSetAttributeLive: vi.fn(),
|
||||
onSetManualOffset: vi.fn(),
|
||||
onSetManualSize: vi.fn(),
|
||||
onSetManualRotation: vi.fn(),
|
||||
// Header/footer controls render only when their callbacks are wired —
|
||||
// supply them so the coverage guard exercises the header + footer sections.
|
||||
selectedElementId: "el-1",
|
||||
selectedElementHidden: false,
|
||||
onToggleElementHidden: vi.fn(),
|
||||
onCopyElementInfo: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
onAskAgent: vi.fn(),
|
||||
onToggleRecording: vi.fn(),
|
||||
} as unknown as PropertyPanelProps)}
|
||||
/>,
|
||||
);
|
||||
const layout = host.querySelector('[data-flat-group-open="true"]');
|
||||
if (!layout || !layout.textContent?.includes("Layout")) {
|
||||
throw new Error("expected open flat Layout group");
|
||||
}
|
||||
const inputs = Array.from(layout.querySelectorAll<HTMLInputElement>('input[type="text"]'));
|
||||
expect(inputs.length).toBeGreaterThan(0);
|
||||
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
act(() => changeInput(input, String(200 + index)));
|
||||
act(() => blurInput(input));
|
||||
}
|
||||
// Header (Clear selection) and footer (ask + record) controls — exercises the
|
||||
// "header" and "footer" sections. The visibility toggle is intentionally omitted:
|
||||
// it renders only when the dispatcher forwards a live selection handle, which this
|
||||
// unit-level mock does not model. Clear selection already covers the header section.
|
||||
for (const selector of [
|
||||
'[aria-label="Clear selection"]',
|
||||
'[data-flat-footer-ask="true"]',
|
||||
'[data-flat-footer-record="true"]',
|
||||
]) {
|
||||
const button = host.querySelector<HTMLButtonElement>(selector);
|
||||
if (!button) throw new Error(`expected flat panel control ${selector}`);
|
||||
act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
}
|
||||
|
||||
expect(trackStudioEvent).toHaveBeenCalled();
|
||||
expect(new Set(trackStudioEvent.mock.calls.map(([, payload]) => payload.section))).toEqual(
|
||||
new Set(["layout", "header", "footer"]),
|
||||
);
|
||||
for (const [, payload] of trackStudioEvent.mock.calls) {
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
ui: "flat",
|
||||
}),
|
||||
);
|
||||
expect(payload.name).not.toBe("");
|
||||
expect(payload.name).not.toBe("unnamed");
|
||||
expect(payload.section).not.toBe("");
|
||||
expect(payload.section).not.toBe("unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
stripQueryAndHash,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function MediaSection({
|
||||
@@ -38,6 +39,7 @@ export function MediaSection({
|
||||
},
|
||||
) => Promise<BackgroundRemovalResult>;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const isVideo = element.tagName === "video";
|
||||
const isAudio = element.tagName === "audio";
|
||||
const isImage = element.tagName === "img";
|
||||
@@ -98,6 +100,7 @@ export function MediaSection({
|
||||
|
||||
const runBackgroundRemoval = async () => {
|
||||
if (!onRemoveBackground || !projectSrc || removeBusy) return;
|
||||
track("button", "Remove background");
|
||||
setRemoveBusy(true);
|
||||
setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" });
|
||||
try {
|
||||
@@ -194,6 +197,7 @@ export function MediaSection({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>BG plate</span>
|
||||
<SegmentedControl
|
||||
trackName="BG plate"
|
||||
value={createPlate ? "on" : "off"}
|
||||
onChange={(next) => setCreatePlate(next === "on")}
|
||||
options={[
|
||||
@@ -245,6 +249,7 @@ export function MediaSection({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Volume</span>
|
||||
<SliderControl
|
||||
trackName="Volume"
|
||||
value={volumePercent}
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -260,6 +265,7 @@ export function MediaSection({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Playback rate</span>
|
||||
<SliderControl
|
||||
trackName="Playback rate"
|
||||
value={playbackRate * 100}
|
||||
min={25}
|
||||
max={300}
|
||||
@@ -275,6 +281,7 @@ export function MediaSection({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Media start</span>
|
||||
<SliderControl
|
||||
trackName="Media start"
|
||||
value={Math.round(mediaStart * 100)}
|
||||
min={0}
|
||||
max={mediaStartMax * 100}
|
||||
@@ -291,6 +298,7 @@ export function MediaSection({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Loop</span>
|
||||
<SegmentedControl
|
||||
trackName="Loop"
|
||||
value={hasLoop ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("loop", next === "on" ? "true" : null);
|
||||
@@ -304,6 +312,7 @@ export function MediaSection({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Muted</span>
|
||||
<SegmentedControl
|
||||
trackName="Muted"
|
||||
value={hasMuted ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
|
||||
@@ -320,6 +329,7 @@ export function MediaSection({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Has audio track</span>
|
||||
<SegmentedControl
|
||||
trackName="Has audio track"
|
||||
value={hasAudio ? "yes" : "no"}
|
||||
onChange={(next) => {
|
||||
if (next === "yes") {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
DesignPanelInputProvider,
|
||||
useTrackDesignInput,
|
||||
} from "../../contexts/DesignPanelInputContext";
|
||||
import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
|
||||
|
||||
export function CommitField({
|
||||
@@ -116,7 +120,15 @@ export function MetricField({
|
||||
tooltip?: string;
|
||||
onCommit: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
|
||||
const commit = useCallback(
|
||||
(nextValue: string) => {
|
||||
if (nextValue !== value) track("metric", label);
|
||||
onCommit(nextValue);
|
||||
},
|
||||
[label, onCommit, track, value],
|
||||
);
|
||||
|
||||
const handleScrubPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLSpanElement>) => {
|
||||
@@ -134,9 +146,9 @@ export function MetricField({
|
||||
const state = scrubRef.current;
|
||||
if (!state) return;
|
||||
const delta = e.clientX - state.startX;
|
||||
onCommit(String(Math.round(state.startValue + delta)));
|
||||
commit(String(Math.round(state.startValue + delta)));
|
||||
},
|
||||
[onCommit],
|
||||
[commit],
|
||||
);
|
||||
|
||||
const handleScrubPointerUp = useCallback(() => {
|
||||
@@ -158,12 +170,7 @@ export function MetricField({
|
||||
<div className={FIELD} title={tooltip}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span {...scrubProps}>{label}</span>
|
||||
<CommitField
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
liveCommit={liveCommit}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
<CommitField value={value} disabled={disabled} liveCommit={liveCommit} onCommit={commit} />
|
||||
{suffix && <span className="flex-shrink-0 text-[10px] text-neutral-600">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,17 +192,23 @@ export function DetailField({
|
||||
disabled?: boolean;
|
||||
onCommit: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const commit = (nextValue: string) => {
|
||||
if (nextValue !== value) track("text", label);
|
||||
onCommit(nextValue);
|
||||
};
|
||||
return (
|
||||
<label className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>{label}</span>
|
||||
<div className={FIELD}>
|
||||
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
|
||||
<CommitField value={value} disabled={disabled} onCommit={commit} />
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SliderControl({
|
||||
trackName,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
@@ -205,6 +218,7 @@ export function SliderControl({
|
||||
disabled,
|
||||
onCommit,
|
||||
}: {
|
||||
trackName: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -214,8 +228,10 @@ export function SliderControl({
|
||||
disabled?: boolean;
|
||||
onCommit: (nextValue: number) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const [draft, setDraft] = useState(value);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const interactionChangedRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
@@ -231,6 +247,10 @@ export function SliderControl({
|
||||
|
||||
const commitDraft = (nextDraft: number) => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (interactionChangedRef.current) {
|
||||
interactionChangedRef.current = false;
|
||||
track("slider", trackName);
|
||||
}
|
||||
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
||||
};
|
||||
const scheduleCommit = (nextDraft: number) => {
|
||||
@@ -252,6 +272,7 @@ export function SliderControl({
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setDraft(n);
|
||||
interactionChangedRef.current = true;
|
||||
scheduleCommit(n);
|
||||
}}
|
||||
onMouseUp={() => commitDraft(draft)}
|
||||
@@ -267,16 +288,19 @@ export function SliderControl({
|
||||
}
|
||||
|
||||
export function SegmentedControl({
|
||||
trackName,
|
||||
options,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
trackName: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
return (
|
||||
<div
|
||||
className="grid min-w-0 gap-[2px] rounded-md bg-panel-input p-[2px]"
|
||||
@@ -287,7 +311,10 @@ export function SegmentedControl({
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(option.value)}
|
||||
onClick={() => {
|
||||
if (option.value !== value) track("segmented", trackName);
|
||||
onChange(option.value);
|
||||
}}
|
||||
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
||||
option.value === value
|
||||
? "bg-panel-hover text-white"
|
||||
@@ -314,6 +341,7 @@ export function SelectField({
|
||||
options: string[];
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const renderedOptions = value && !options.includes(value) ? [value, ...options] : options;
|
||||
return (
|
||||
<label className={`${FIELD} flex items-center gap-3`}>
|
||||
@@ -321,7 +349,10 @@ export function SelectField({
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
track("select", label);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
||||
>
|
||||
{renderedOptions.map((option) => (
|
||||
@@ -370,24 +401,24 @@ export function Section({
|
||||
</svg>
|
||||
);
|
||||
|
||||
const section = slugifyPanelSectionTitle(title);
|
||||
return (
|
||||
<section
|
||||
className="min-w-0 border-t border-panel-border"
|
||||
data-panel-section={slugifyPanelSectionTitle(title)}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
|
||||
{collapseIcon}
|
||||
</button>
|
||||
{accessory && <div className="flex flex-shrink-0 items-center">{accessory}</div>}
|
||||
</div>
|
||||
{!collapsed && <div className="px-4 pb-3">{children}</div>}
|
||||
</section>
|
||||
<DesignPanelInputProvider section={section}>
|
||||
<section className="min-w-0 border-t border-panel-border" data-panel-section={section}>
|
||||
<div className="flex w-full items-center gap-2 px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
|
||||
{collapseIcon}
|
||||
</button>
|
||||
{accessory && <div className="flex flex-shrink-0 items-center">{accessory}</div>}
|
||||
</div>
|
||||
{!collapsed && <div className="px-4 pb-3">{children}</div>}
|
||||
</section>
|
||||
</DesignPanelInputProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MetricField, Section, SelectField } from "./propertyPanelPrimitives";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { FontFamilyField } from "./propertyPanelFont";
|
||||
import { PromotableControl } from "./PromotableControl";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Text helpers (used only by text section components) */
|
||||
@@ -74,9 +75,11 @@ export function TextAreaField({
|
||||
flat?: boolean;
|
||||
onCommit: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const [draft, setDraft] = useState(value);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const interactionChangedRef = useRef(false);
|
||||
const focusedRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
@@ -98,12 +101,22 @@ export function TextAreaField({
|
||||
|
||||
const commitDraft = (d: string) => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (interactionChangedRef.current) {
|
||||
interactionChangedRef.current = false;
|
||||
track("text", label);
|
||||
}
|
||||
if (d !== valueRef.current) onCommit(d);
|
||||
};
|
||||
const scheduleCommit = (d: string) => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => {
|
||||
if (d !== valueRef.current) onCommit(d);
|
||||
if (d !== valueRef.current) {
|
||||
if (interactionChangedRef.current) {
|
||||
interactionChangedRef.current = false;
|
||||
track("text", label);
|
||||
}
|
||||
onCommit(d);
|
||||
}
|
||||
}, 120);
|
||||
};
|
||||
|
||||
@@ -112,6 +125,7 @@ export function TextAreaField({
|
||||
};
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDraft(e.target.value);
|
||||
interactionChangedRef.current = true;
|
||||
scheduleCommit(e.target.value);
|
||||
};
|
||||
const handleBlur = () => {
|
||||
@@ -169,6 +183,7 @@ function FontWeightField({
|
||||
fontFamily?: string;
|
||||
onCommit: (nextValue: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const options = fontFamily ? detectAvailableWeights(fontFamily) : ALL_WEIGHTS;
|
||||
const displayOptions = value && !options.includes(value) ? [value, ...options] : options;
|
||||
return (
|
||||
@@ -178,7 +193,10 @@ function FontWeightField({
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onCommit(e.target.value)}
|
||||
onChange={(e) => {
|
||||
track("select", "Weight");
|
||||
onCommit(e.target.value);
|
||||
}}
|
||||
className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
||||
>
|
||||
{displayOptions.map((o) => (
|
||||
@@ -288,6 +306,7 @@ function TextFieldEditor({
|
||||
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={showRemove ? "flex min-w-0 items-center justify-between gap-2" : "min-w-0"}>
|
||||
@@ -300,7 +319,10 @@ function TextFieldEditor({
|
||||
{showRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveTextField(field.key)}
|
||||
onClick={() => {
|
||||
track("button", "Remove text field");
|
||||
onRemoveTextField(field.key);
|
||||
}}
|
||||
className="inline-flex h-7 flex-shrink-0 items-center rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
|
||||
>
|
||||
Remove
|
||||
@@ -398,6 +420,7 @@ export function TextSection({
|
||||
* false so the legacy (non-flat) call site is unaffected. */
|
||||
hideOwnHeading?: boolean;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const hasTextControls = isTextEditableSelection(element);
|
||||
const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
|
||||
element.textFields[0]?.key ?? null,
|
||||
@@ -446,6 +469,7 @@ export function TextSection({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
track("button", "Add text field");
|
||||
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
|
||||
if (nextKey) setActiveTextFieldKey(nextKey);
|
||||
});
|
||||
|
||||
@@ -154,6 +154,7 @@ export function StyleSections({
|
||||
<Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
|
||||
<div className="space-y-4">
|
||||
<SegmentedControl
|
||||
trackName="Flex direction"
|
||||
disabled={styleEditingDisabled}
|
||||
value={styles["flex-direction"] || "row"}
|
||||
onChange={(next) => onSetStyle("flex-direction", next)}
|
||||
@@ -298,6 +299,7 @@ export function StyleSections({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Layer blur</span>
|
||||
<SliderControl
|
||||
trackName="Layer blur"
|
||||
value={filterBlurValue}
|
||||
min={0}
|
||||
max={Math.max(40, Math.ceil(filterBlurValue))}
|
||||
@@ -313,6 +315,7 @@ export function StyleSections({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Backdrop</span>
|
||||
<SliderControl
|
||||
trackName="Backdrop blur"
|
||||
value={backdropBlurValue}
|
||||
min={0}
|
||||
max={Math.max(60, Math.ceil(backdropBlurValue))}
|
||||
@@ -363,6 +366,7 @@ export function StyleSections({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Mask inset</span>
|
||||
<SliderControl
|
||||
trackName="Mask inset"
|
||||
value={clipInsetValue}
|
||||
min={0}
|
||||
max={Math.max(120, Math.ceil(clipInsetValue))}
|
||||
@@ -411,6 +415,7 @@ export function StyleSections({
|
||||
<Section title="Transparency" icon={<Eye size={15} />} defaultCollapsed>
|
||||
<div className="space-y-4">
|
||||
<SliderControl
|
||||
trackName="Opacity"
|
||||
value={opacityValue}
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -433,6 +438,7 @@ export function StyleSections({
|
||||
<Section title="Fill" icon={<Palette size={15} />}>
|
||||
<div className="space-y-4">
|
||||
<SegmentedControl
|
||||
trackName="Fill type"
|
||||
disabled={styleEditingDisabled}
|
||||
value={preferredFillMode}
|
||||
onChange={handleFillModeChange}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const trackDesignInput = vi.fn();
|
||||
vi.mock("../utils/designInputTracking", () => ({
|
||||
trackDesignInput: (...args: unknown[]) => trackDesignInput(...args),
|
||||
}));
|
||||
|
||||
import { DesignPanelInputProvider, useTrackDesignInput } from "./DesignPanelInputContext";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
beforeEach(() => trackDesignInput.mockReset());
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function FireButton({ control, name }: { control: string; name: string }) {
|
||||
const track = useTrackDesignInput();
|
||||
return (
|
||||
<button type="button" onClick={() => track(control, name)}>
|
||||
fire
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAndClick(tree: React.ReactElement) {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
act(() => root.render(tree));
|
||||
act(() => {
|
||||
host.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
act(() => root.unmount());
|
||||
}
|
||||
|
||||
describe("DesignPanelInputContext", () => {
|
||||
it("binds the tracker to the enclosing ui + section", () => {
|
||||
renderAndClick(
|
||||
<DesignPanelInputProvider ui="flat" section="style">
|
||||
<FireButton control="metric" name="Opacity" />
|
||||
</DesignPanelInputProvider>,
|
||||
);
|
||||
expect(trackDesignInput).toHaveBeenCalledWith({
|
||||
ui: "flat",
|
||||
section: "style",
|
||||
control: "metric",
|
||||
name: "Opacity",
|
||||
});
|
||||
});
|
||||
|
||||
it("nested provider overrides section but inherits ui from parent", () => {
|
||||
renderAndClick(
|
||||
<DesignPanelInputProvider ui="flat" section="outer">
|
||||
<DesignPanelInputProvider section="color-grading">
|
||||
<FireButton control="slider" name="Exposure" />
|
||||
</DesignPanelInputProvider>
|
||||
</DesignPanelInputProvider>,
|
||||
);
|
||||
expect(trackDesignInput).toHaveBeenCalledWith({
|
||||
ui: "flat",
|
||||
section: "color-grading",
|
||||
control: "slider",
|
||||
name: "Exposure",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to classic/unknown with no provider", () => {
|
||||
renderAndClick(<FireButton control="button" name="Reset" />);
|
||||
expect(trackDesignInput).toHaveBeenCalledWith({
|
||||
ui: "classic",
|
||||
section: "unknown",
|
||||
control: "button",
|
||||
name: "Reset",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking";
|
||||
|
||||
// Carries which inspector UI and which section the currently-rendered design-panel
|
||||
// inputs belong to, so commit sites only pass { control, name } and never thread
|
||||
// section/ui through every call. Providers nest: PropertyPanel sets `ui` once at the
|
||||
// top; each Section sets `section` and inherits `ui` from the parent.
|
||||
|
||||
interface DesignPanelInputContextValue {
|
||||
ui: DesignInputUi;
|
||||
section: string;
|
||||
}
|
||||
|
||||
const DesignPanelInputContext = createContext<DesignPanelInputContextValue>({
|
||||
ui: "classic",
|
||||
section: "unknown",
|
||||
});
|
||||
|
||||
export function DesignPanelInputProvider({
|
||||
ui,
|
||||
section,
|
||||
children,
|
||||
}: {
|
||||
ui?: DesignInputUi;
|
||||
section?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const parent = useContext(DesignPanelInputContext);
|
||||
const value = useMemo(
|
||||
() => ({ ui: ui ?? parent.ui, section: section ?? parent.section }),
|
||||
[ui, section, parent.ui, parent.section],
|
||||
);
|
||||
return (
|
||||
<DesignPanelInputContext.Provider value={value}>{children}</DesignPanelInputContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable `track(control, name)` fn bound to the current UI + section.
|
||||
* Call it from any input's commit handler.
|
||||
*/
|
||||
export function useTrackDesignInput(): (control: string, name: string) => void {
|
||||
const { ui, section } = useContext(DesignPanelInputContext);
|
||||
return useCallback(
|
||||
(control: string, name: string) => trackDesignInput({ ui, section, control, name }),
|
||||
[ui, section],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const trackStudioEvent = vi.fn();
|
||||
vi.mock("./studioTelemetry", () => ({
|
||||
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
__resetDesignInputThrottle,
|
||||
slugifyDesignInput,
|
||||
trackDesignInput,
|
||||
} from "./designInputTracking";
|
||||
|
||||
beforeEach(() => {
|
||||
trackStudioEvent.mockReset();
|
||||
__resetDesignInputThrottle();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("trackDesignInput", () => {
|
||||
it("emits one design_input event with ui/section/control/name", () => {
|
||||
trackDesignInput({ ui: "flat", section: "Style", control: "metric", name: "Opacity" });
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith("design_input", {
|
||||
ui: "flat",
|
||||
section: "style",
|
||||
control: "metric",
|
||||
name: "opacity",
|
||||
});
|
||||
});
|
||||
|
||||
it("slugifies compound names and sections", () => {
|
||||
trackDesignInput({
|
||||
ui: "classic",
|
||||
section: "Color Grading",
|
||||
control: "slider",
|
||||
name: "Font Size",
|
||||
});
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith("design_input", {
|
||||
ui: "classic",
|
||||
section: "color-grading",
|
||||
control: "slider",
|
||||
name: "font-size",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an unresolved name as 'unnamed' (R3 coverage signal)", () => {
|
||||
trackDesignInput({ ui: "classic", section: "style", control: "button", name: "" });
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"design_input",
|
||||
expect.objectContaining({ name: "unnamed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("coalesces repeated fires of the same input within the window (R4)", () => {
|
||||
const nowSpy = vi.spyOn(performance, "now");
|
||||
// Same key, three quick fires -> 1 event.
|
||||
nowSpy.mockReturnValue(1000);
|
||||
trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
|
||||
nowSpy.mockReturnValue(1100);
|
||||
trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
|
||||
nowSpy.mockReturnValue(1500);
|
||||
trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A different input within the window is NOT collapsed.
|
||||
nowSpy.mockReturnValue(1550);
|
||||
trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "scale" });
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Same input after the window fires again.
|
||||
nowSpy.mockReturnValue(2200);
|
||||
trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
|
||||
expect(trackStudioEvent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never throws even if the underlying tracker throws", () => {
|
||||
trackStudioEvent.mockImplementation(() => {
|
||||
throw new Error("ingest down");
|
||||
});
|
||||
expect(() =>
|
||||
trackDesignInput({ ui: "flat", section: "style", control: "metric", name: "opacity" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("slugifyDesignInput", () => {
|
||||
it("lowercases, collapses non-alphanumerics, and trims dashes", () => {
|
||||
expect(slugifyDesignInput(" Border Radius (px) ")).toBe("border-radius-px");
|
||||
expect(slugifyDesignInput("X")).toBe("x");
|
||||
expect(slugifyDesignInput("---")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
|
||||
// Per-input usage telemetry for the design (inspector) panel. Both inspector UIs
|
||||
// (classic PropertyPanel, flat PropertyPanelFlat) funnel their inputs through this
|
||||
// one helper so usage can be ranked by input to find removal candidates. Emits the
|
||||
// batched `studio:design_input` event via trackStudioEvent (opt-out-aware, never-throw).
|
||||
//
|
||||
// Fire convention (kept consistent across both UIs):
|
||||
// - Discrete controls (metric/text/select/segmented/toggle/color/button) fire only
|
||||
// when a real committed value change happens (the commit site guards on
|
||||
// next !== current).
|
||||
// - Continuous controls (sliders, scrub) fire once per user interaction that
|
||||
// produced commits, even if the net value ends unchanged — net-change-at-settle
|
||||
// is unreliable for them because mid-drag commits have already advanced the value,
|
||||
// so "did the user work this control" is the honest signal. The coalescing window
|
||||
// below collapses the many mid-drag commits into that single event.
|
||||
|
||||
export type DesignInputUi = "flat" | "classic";
|
||||
|
||||
export interface DesignInputDescriptor {
|
||||
ui: DesignInputUi;
|
||||
/** Section slug the input lives under (e.g. "style", "color-grading"). */
|
||||
section: string;
|
||||
/** Control kind: "metric" | "slider" | "select" | "segmented" | "toggle" | "color" | "text" | "button" | … */
|
||||
control: string;
|
||||
/** Input identity — the field label or CSS/GSAP property. Slugified for stable ranking. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Continuous controls (sliders, scrub, wheel-nudge, live-commit text) fire many
|
||||
// commits per interaction. Collapse repeated fires of the same input within this
|
||||
// window into one event so a single drag counts once (R4).
|
||||
const COALESCE_WINDOW_MS = 600;
|
||||
|
||||
const lastFiredByKey = new Map<string, number>();
|
||||
|
||||
function now(): number {
|
||||
// performance.now() is monotonic and available in the studio runtime and jsdom.
|
||||
return typeof performance !== "undefined" && typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function slugifyDesignInput(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
/** Test seam: clear the coalescing state between cases. */
|
||||
export function __resetDesignInputThrottle(): void {
|
||||
lastFiredByKey.clear();
|
||||
}
|
||||
|
||||
export function trackDesignInput(descriptor: DesignInputDescriptor): void {
|
||||
try {
|
||||
const section = slugifyDesignInput(descriptor.section) || "unknown";
|
||||
const name = slugifyDesignInput(descriptor.name);
|
||||
// An input with no resolvable name is useless for the removal analysis (R3).
|
||||
// Emit it anyway (so a coverage test can catch it) but under an explicit marker.
|
||||
const control = descriptor.control || "unknown";
|
||||
const key = `${descriptor.ui}:${section}:${control}:${name || "unnamed"}`;
|
||||
|
||||
const t = now();
|
||||
const last = lastFiredByKey.get(key);
|
||||
if (last !== undefined && t - last < COALESCE_WINDOW_MS) return;
|
||||
lastFiredByKey.set(key, t);
|
||||
|
||||
trackStudioEvent("design_input", {
|
||||
ui: descriptor.ui,
|
||||
section,
|
||||
control,
|
||||
name: name || "unnamed",
|
||||
});
|
||||
} catch {
|
||||
// Telemetry must never break the edit path.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user