mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)
* feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): keyframe cache + commit hooks Add hooks for keyframe cache population (tween → clip-relative %), mutation dispatch, keyframe snapping, and audio beat detection. * feat(studio): timeline UI — dopesheet diamonds + keyboard nav Add dopesheet strip with diamond keyframe indicators, timeline property rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate (STUDIO_KEYFRAMES_ENABLED defaults to false). * feat(studio): design panel — arc controls + ease curve + stagger Add arc path controls (curviness slider, auto-rotate), motion path SVG overlay, ease curve visualization, stagger controls, and expanded animation card. Includes border-radius editor dependency from #1217. * feat(studio): gesture recording core Add gesture recording engine with RAF sampling, modifier key property mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity), Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay. * fix(studio): keyframe drag + recording bug bash 21 fixes: capture GSAP base at drag start, translate:none before gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording seek, _auto flag for 100% keyframes, overlay flash fix, block edits during recording. * feat(studio): keyframe integration wiring + docs Wire App.tsx recording orchestration, TimelineToolbar K/R buttons, PropertyPanel per-property diamonds, shortcuts panel, toast notifications, and keyframes guide documentation. All gated on STUDIO_KEYFRAMES_ENABLED (default false).
This commit is contained in:
@@ -17,6 +17,7 @@ export interface StudioHeaderProps {
|
||||
refreshCaptureFrameTime: () => void;
|
||||
inspectorButtonActive: boolean;
|
||||
inspectorPanelActive: boolean;
|
||||
onExport?: () => void;
|
||||
}
|
||||
|
||||
function HyperframesLogo() {
|
||||
@@ -147,6 +148,7 @@ export function StudioHeader({
|
||||
refreshCaptureFrameTime,
|
||||
inspectorButtonActive,
|
||||
inspectorPanelActive,
|
||||
onExport,
|
||||
}: StudioHeaderProps) {
|
||||
const { projectId, editHistory, handleUndo, handleRedo } = useStudioContext();
|
||||
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
||||
@@ -171,10 +173,10 @@ export function StudioHeader({
|
||||
void handleUndo();
|
||||
}}
|
||||
disabled={!editHistory.canUndo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
|
||||
editHistory.canUndo
|
||||
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
: "border-neutral-900 text-neutral-700"
|
||||
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
: "text-neutral-700 cursor-default"
|
||||
}`}
|
||||
title={
|
||||
editHistory.undoLabel
|
||||
@@ -192,10 +194,10 @@ export function StudioHeader({
|
||||
void handleRedo();
|
||||
}}
|
||||
disabled={!editHistory.canRedo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
|
||||
editHistory.canRedo
|
||||
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
: "border-neutral-900 text-neutral-700"
|
||||
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
: "text-neutral-700 cursor-default"
|
||||
}`}
|
||||
title={
|
||||
editHistory.redoLabel
|
||||
@@ -215,7 +217,7 @@ export function StudioHeader({
|
||||
}}
|
||||
onFocus={refreshCaptureFrameTime}
|
||||
onPointerDown={refreshCaptureFrameTime}
|
||||
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
||||
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200 hover:bg-neutral-800"
|
||||
title="Capture current frame"
|
||||
aria-label="Capture current frame"
|
||||
>
|
||||
@@ -264,6 +266,17 @@ export function StudioHeader({
|
||||
</svg>
|
||||
Inspector
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRightPanelTab("renders");
|
||||
setRightCollapsed(false);
|
||||
onExport?.();
|
||||
}}
|
||||
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface StudioPreviewAreaProps {
|
||||
setCompositionLoading: (loading: boolean) => void;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
isGestureRecording?: boolean;
|
||||
gestureOverlay?: ReactNode;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -74,7 +76,9 @@ export function StudioPreviewArea({
|
||||
setCompIdToSrc,
|
||||
setCompositionLoading,
|
||||
shouldShowSelectedDomBounds,
|
||||
isGestureRecording,
|
||||
blockPreview,
|
||||
gestureOverlay,
|
||||
}: StudioPreviewAreaProps) {
|
||||
const {
|
||||
projectId,
|
||||
@@ -241,7 +245,7 @@ export function StudioPreviewArea({
|
||||
}
|
||||
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
||||
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
||||
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
||||
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording}
|
||||
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
||||
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
||||
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
||||
@@ -256,6 +260,7 @@ export function StudioPreviewArea({
|
||||
gridSpacing={snapPrefs.gridSpacing}
|
||||
/>
|
||||
<SnapToolbar onSnapChange={setSnapPrefs} />
|
||||
{gestureOverlay}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface StudioRightPanelProps {
|
||||
compositionPath: string;
|
||||
} | null;
|
||||
onCloseBlockParams?: () => void;
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -41,6 +44,9 @@ export function StudioRightPanel({
|
||||
motionPanelActive,
|
||||
activeBlockParams,
|
||||
onCloseBlockParams,
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
}: StudioRightPanelProps) {
|
||||
const {
|
||||
rightWidth,
|
||||
@@ -92,6 +98,8 @@ export function StudioRightPanel({
|
||||
handleGsapAddFromProperty,
|
||||
handleGsapRemoveFromProperty,
|
||||
commitAnimatedProperty,
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
} = useDomEditContext();
|
||||
|
||||
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
|
||||
@@ -226,6 +234,11 @@ export function StudioRightPanel({
|
||||
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
||||
onAddGsapAnimation={handleGsapAddAnimation}
|
||||
onCommitAnimatedProperty={commitAnimatedProperty}
|
||||
onSetArcPath={handleSetArcPath}
|
||||
onUpdateArcSegment={handleUpdateArcSegment}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
) : motionPanelActive ? (
|
||||
<MotionPanel
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
interface StudioToastProps {
|
||||
message: string;
|
||||
tone?: "error" | "info";
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function StudioToast({ message, tone }: StudioToastProps) {
|
||||
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
||||
const isError = tone === "error";
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
||||
tone === "error"
|
||||
? "bg-red-900/90 border-red-700/50 text-red-200"
|
||||
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
||||
}`}
|
||||
className="absolute bottom-6 right-6 z-[91] animate-in fade-in slide-in-from-bottom-2"
|
||||
onClick={onDismiss}
|
||||
role={onDismiss ? "button" : undefined}
|
||||
style={onDismiss ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
{message}
|
||||
<div
|
||||
className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2 py-3 text-[12px]"
|
||||
style={{
|
||||
background: isError
|
||||
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
|
||||
: "linear-gradient(135deg, rgba(38,38,38,0.55), rgba(23,23,23,0.45))",
|
||||
backdropFilter: "blur(16px) saturate(1.6)",
|
||||
WebkitBackdropFilter: "blur(16px) saturate(1.6)",
|
||||
border: `1px solid ${isError ? "rgba(239,68,68,0.18)" : "rgba(255,255,255,0.08)"}`,
|
||||
boxShadow: [
|
||||
"0 8px 32px rgba(0,0,0,0.35)",
|
||||
`inset 0 1px 0 ${isError ? "rgba(239,68,68,0.12)" : "rgba(255,255,255,0.06)"}`,
|
||||
`inset 0 -1px 0 rgba(0,0,0,0.15)`,
|
||||
].join(", "),
|
||||
}}
|
||||
>
|
||||
<span className={isError ? "text-red-200" : "text-neutral-200"}>{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M2 2l6 6M8 2l-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRef } from "react";
|
||||
import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
|
||||
import {
|
||||
getNextTimelineZoomPercent,
|
||||
getTimelineZoomPercent,
|
||||
@@ -7,88 +9,12 @@ import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
||||
import { Tooltip } from "./ui";
|
||||
import { Scissors } from "../icons/SystemIcons";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
|
||||
function interpolateKeyframeProperties(
|
||||
keyframes: GsapPercentageKeyframe[],
|
||||
pct: number,
|
||||
): Record<string, number> {
|
||||
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
||||
const allProps = new Set<string>();
|
||||
for (const kf of sorted) {
|
||||
for (const p of Object.keys(kf.properties)) {
|
||||
if (typeof kf.properties[p] === "number") allProps.add(p);
|
||||
}
|
||||
}
|
||||
const result: Record<string, number> = {};
|
||||
for (const prop of allProps) {
|
||||
let prev: { pct: number; val: number } | null = null;
|
||||
let next: { pct: number; val: number } | null = null;
|
||||
for (const kf of sorted) {
|
||||
const v = kf.properties[prop];
|
||||
if (typeof v !== "number") continue;
|
||||
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
|
||||
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
|
||||
}
|
||||
if (prev && next && prev.pct !== next.pct) {
|
||||
const t = (pct - prev.pct) / (next.pct - prev.pct);
|
||||
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
|
||||
} else if (prev) {
|
||||
result[prop] = Math.round(prev.val);
|
||||
} else if (next) {
|
||||
result[prop] = Math.round(next.val);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readRuntimeKeyframeValues(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
sel: DomEditSelection,
|
||||
keyframes: GsapPercentageKeyframe[],
|
||||
): Record<string, number> {
|
||||
if (!iframe?.contentWindow) return {};
|
||||
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
|
||||
try {
|
||||
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!gsap?.getProperty) return {};
|
||||
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
||||
if (!selector) return {};
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const element = doc?.querySelector(selector);
|
||||
if (!element) return {};
|
||||
const allProps = new Set<string>();
|
||||
for (const kf of keyframes) {
|
||||
for (const p of Object.keys(kf.properties)) {
|
||||
if (typeof kf.properties[p] === "number") allProps.add(p);
|
||||
}
|
||||
}
|
||||
const result: Record<string, number> = {};
|
||||
for (const prop of allProps) {
|
||||
const val = Number(gsap.getProperty(element, prop));
|
||||
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface DomEditSessionSlice {
|
||||
interface DomEditSessionSlice extends EnableKeyframesSession {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
||||
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
|
||||
handleGsapConvertToKeyframes: (animId: string) => void;
|
||||
handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
|
||||
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
@@ -97,15 +23,20 @@ interface TimelineToolbarProps {
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const sessionRef = useRef(session);
|
||||
sessionRef.current = session;
|
||||
|
||||
const onToggle = useEnableKeyframes(
|
||||
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
|
||||
);
|
||||
|
||||
if (!session) return { state: "none" as const, onToggle: undefined };
|
||||
|
||||
const sel = session.domEditSelection;
|
||||
const anims = session.selectedGsapAnimations;
|
||||
const kfAnim = anims.find((a) => a.keyframes);
|
||||
const flatAnim = anims.find((a) => !a.keyframes);
|
||||
|
||||
let state: "active" | "inactive" | "none" = "none";
|
||||
if (kfAnim?.keyframes && sel) {
|
||||
@@ -120,48 +51,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
: "inactive";
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const onToggle = sel
|
||||
? async () => {
|
||||
const t = usePlayerStore.getState().currentTime;
|
||||
if (kfAnim?.keyframes) {
|
||||
if (kfAnim.hasUnresolvedKeyframes) {
|
||||
await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
|
||||
}
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
const pct =
|
||||
elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
const existing = kfAnim.keyframes.keyframes.find(
|
||||
(k) => Math.abs(k.percentage - pct) <= 1,
|
||||
);
|
||||
if (existing) {
|
||||
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
||||
} else {
|
||||
const runtimeValues = readRuntimeKeyframeValues(
|
||||
session.previewIframeRef?.current ?? null,
|
||||
sel,
|
||||
kfAnim.keyframes.keyframes,
|
||||
);
|
||||
const values =
|
||||
Object.keys(runtimeValues).length > 0
|
||||
? runtimeValues
|
||||
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
|
||||
for (const [prop, val] of Object.entries(values)) {
|
||||
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
|
||||
}
|
||||
}
|
||||
} else if (flatAnim) {
|
||||
session.handleGsapConvertToKeyframes(flatAnim.id);
|
||||
} else {
|
||||
session.handleGsapAddAnimation("to");
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { state, onToggle };
|
||||
return { state, onToggle: sel ? onToggle : undefined };
|
||||
}
|
||||
|
||||
export function TimelineToolbar({
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
} from "./gsapAnimationConstants";
|
||||
import { buildTweenSummary } from "./gsapAnimationHelpers";
|
||||
import { EaseCurveSection } from "./EaseCurveSection";
|
||||
import { ArcPathControls } from "./ArcPathControls";
|
||||
import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
|
||||
import { P } from "./panelTokens";
|
||||
const BOOLEAN_PROPS = new Set(["visibility"]);
|
||||
const STRING_PROPS = new Set(["filter", "clipPath"]);
|
||||
|
||||
@@ -97,11 +100,18 @@ function PropertyRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
|
||||
className={`flex-shrink-0 w-7 h-4 rounded-full transition-colors relative ${isVisible ? "bg-emerald-500/30" : "bg-neutral-700"}`}
|
||||
className={`flex-shrink-0 rounded-full transition-all duration-150 relative`}
|
||||
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
|
||||
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full transition-transform ${isVisible ? "bg-emerald-400 translate-x-3.5" : "bg-neutral-500 translate-x-0.5"}`}
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
background: isVisible ? P.white : P.textMuted,
|
||||
transform: isVisible ? "translateX(14px)" : "translateX(2px)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -241,6 +251,15 @@ interface AnimationCardProps {
|
||||
onRemoveFromProperty?: (animationId: string, property: string) => void;
|
||||
onLivePreview?: (property: string, value: number | string) => void;
|
||||
onLivePreviewEnd?: () => void;
|
||||
onSetArcPath?: (
|
||||
animationId: string,
|
||||
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
|
||||
) => void;
|
||||
onUpdateArcSegment?: (
|
||||
animationId: string,
|
||||
segmentIndex: number,
|
||||
update: Partial<ArcPathSegment>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -257,6 +276,8 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
onRemoveFromProperty,
|
||||
onLivePreview,
|
||||
onLivePreviewEnd,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
}: AnimationCardProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [addingProp, setAddingProp] = useState(false);
|
||||
@@ -329,7 +350,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
|
||||
const easeName = animation.ease ?? "none";
|
||||
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
|
||||
const easeLabel = easeName.startsWith("custom(")
|
||||
? "Custom curve"
|
||||
: (EASE_LABELS[easeName] ?? easeName);
|
||||
@@ -348,7 +369,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
className="flex w-full items-center gap-2 py-1.5"
|
||||
>
|
||||
<span
|
||||
className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400"
|
||||
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
|
||||
title={METHOD_TOOLTIPS[animation.method]}
|
||||
>
|
||||
{methodLabel}
|
||||
@@ -420,13 +441,13 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
<>
|
||||
<SelectField
|
||||
label="Speed"
|
||||
value={
|
||||
animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none")
|
||||
}
|
||||
value={easeName.startsWith("custom(") ? "custom" : easeName}
|
||||
options={[...SUPPORTED_EASES, "custom"]}
|
||||
onChange={(next) => {
|
||||
if (next === "custom") {
|
||||
const points = controlPointsForGsapEase(animation.ease ?? "power2.out");
|
||||
const points = controlPointsForGsapEase(
|
||||
easeName !== "none" ? easeName : "power2.out",
|
||||
);
|
||||
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
|
||||
onUpdateMeta(animation.id, { ease: `custom(${path})` });
|
||||
} else {
|
||||
@@ -435,7 +456,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
}}
|
||||
/>
|
||||
<EaseCurveSection
|
||||
ease={animation.ease ?? "none"}
|
||||
ease={easeName}
|
||||
duration={animation.duration}
|
||||
onCustomEaseCommit={(customEase) =>
|
||||
onUpdateMeta(animation.id, { ease: customEase })
|
||||
@@ -477,7 +498,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
)}
|
||||
|
||||
{animation.method === "fromTo" && Object.keys(animation.properties).length > 0 && (
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wider text-emerald-400/70">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wider text-panel-accent/70">
|
||||
To
|
||||
</p>
|
||||
)}
|
||||
@@ -500,6 +521,39 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSetArcPath &&
|
||||
(animation.properties.x != null ||
|
||||
animation.properties.y != null ||
|
||||
animation.keyframes) && (
|
||||
<div className="border-t border-neutral-800 pt-3">
|
||||
<ArcPathControls
|
||||
arcPath={
|
||||
animation.arcPath ?? { enabled: false, autoRotate: false, segments: [] }
|
||||
}
|
||||
segmentCount={Math.max(
|
||||
animation.properties.x != null || animation.properties.y != null ? 1 : 0,
|
||||
(animation.keyframes?.keyframes?.length ?? 0) - 1,
|
||||
)}
|
||||
onToggle={(enabled) =>
|
||||
onSetArcPath(animation.id, {
|
||||
enabled,
|
||||
segments: animation.arcPath?.segments,
|
||||
})
|
||||
}
|
||||
onUpdateSegment={(index, update) =>
|
||||
onUpdateArcSegment?.(animation.id, index, update)
|
||||
}
|
||||
onToggleAutoRotate={(autoRotate) =>
|
||||
onSetArcPath(animation.id, {
|
||||
enabled: true,
|
||||
autoRotate,
|
||||
segments: animation.arcPath?.segments,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<AddPropertyTrigger
|
||||
adding={addingProp}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import type { ArcPathConfig, ArcPathSegment } from "@hyperframes/core/gsap-parser";
|
||||
import { SliderControl } from "./propertyPanelPrimitives";
|
||||
import { LABEL } from "./propertyPanelHelpers";
|
||||
import { P } from "./panelTokens";
|
||||
|
||||
interface ArcPathControlsProps {
|
||||
arcPath: ArcPathConfig;
|
||||
segmentCount: number;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdateSegment: (index: number, update: Partial<ArcPathSegment>) => void;
|
||||
onToggleAutoRotate: (autoRotate: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ArcPathControls = memo(function ArcPathControls({
|
||||
arcPath,
|
||||
segmentCount,
|
||||
onToggle,
|
||||
onUpdateSegment,
|
||||
onToggleAutoRotate,
|
||||
disabled,
|
||||
}: ArcPathControlsProps) {
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggle(!arcPath.enabled);
|
||||
}, [arcPath.enabled, onToggle]);
|
||||
|
||||
const handleAutoRotate = useCallback(() => {
|
||||
onToggleAutoRotate(!arcPath.autoRotate);
|
||||
}, [arcPath.autoRotate, onToggleAutoRotate]);
|
||||
|
||||
if (segmentCount < 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-neutral-800 bg-neutral-900/50 px-3 py-2">
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Add at least 2 position keyframes to enable arc motion.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={LABEL}>Arc Motion</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
disabled={disabled}
|
||||
className="relative rounded-full transition-all duration-150"
|
||||
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
|
||||
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
background: arcPath.enabled ? P.white : P.textMuted,
|
||||
transform: arcPath.enabled ? "translateX(14px)" : "translateX(2px)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{arcPath.enabled && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={LABEL}>Auto-Rotate</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoRotate}
|
||||
disabled={disabled}
|
||||
className="relative rounded-full transition-all duration-150"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 16,
|
||||
background: arcPath.autoRotate ? P.accent : "#27272A",
|
||||
}}
|
||||
title={
|
||||
arcPath.autoRotate
|
||||
? "Disable auto-rotate along path"
|
||||
: "Rotate element to follow path tangent"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
background: arcPath.autoRotate ? P.white : P.textMuted,
|
||||
transform: arcPath.autoRotate ? "translateX(14px)" : "translateX(2px)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{arcPath.segments.map((seg, i) => (
|
||||
<div key={i} className="grid min-w-0 gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={LABEL}>
|
||||
{segmentCount === 1 ? "Curviness" : `Segment ${i + 1}`}
|
||||
</span>
|
||||
{seg.cp1 && seg.cp2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateSegment(i, { cp1: undefined, cp2: undefined })}
|
||||
className="text-[9px] font-medium text-neutral-500 transition-colors hover:text-neutral-300"
|
||||
title="Reset to auto-generated control points"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SliderControl
|
||||
value={seg.curviness}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
disabled={disabled}
|
||||
displayValue={seg.curviness.toFixed(1)}
|
||||
formatDisplayValue={(v) => v.toFixed(1)}
|
||||
onCommit={(v) => onUpdateSegment(i, { curviness: v })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { MetricField } from "./propertyPanelPrimitives";
|
||||
import { formatNumericValue, parseNumericValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
|
||||
type Corner = "tl" | "tr" | "br" | "bl";
|
||||
|
||||
interface BorderRadiusEditorProps {
|
||||
tl: number;
|
||||
tr: number;
|
||||
br: number;
|
||||
bl: number;
|
||||
disabled?: boolean;
|
||||
onCommit: (corner: Corner | "all", value: number) => void;
|
||||
}
|
||||
|
||||
const PREVIEW_W = 72;
|
||||
const PREVIEW_H = 52;
|
||||
const MAX_RADIUS = 26;
|
||||
|
||||
function clampRadius(v: number): number {
|
||||
return Math.max(0, Math.min(MAX_RADIUS, v));
|
||||
}
|
||||
|
||||
function scaleRadius(v: number, maxPx: number): number {
|
||||
if (maxPx <= 0) return 0;
|
||||
return clampRadius(Math.round((v / Math.max(maxPx, 1)) * MAX_RADIUS));
|
||||
}
|
||||
|
||||
export function BorderRadiusEditor({
|
||||
tl,
|
||||
tr,
|
||||
br,
|
||||
bl,
|
||||
disabled,
|
||||
onCommit,
|
||||
}: BorderRadiusEditorProps) {
|
||||
const uniform = tl === tr && tr === br && br === bl;
|
||||
const [linked, setLinked] = useState(uniform);
|
||||
|
||||
const maxVal = Math.max(tl, tr, br, bl, 1);
|
||||
const sTL = scaleRadius(tl, maxVal);
|
||||
const sTR = scaleRadius(tr, maxVal);
|
||||
const sBR = scaleRadius(br, maxVal);
|
||||
const sBL = scaleRadius(bl, maxVal);
|
||||
|
||||
const handleCornerCommit = useCallback(
|
||||
(corner: Corner, raw: string) => {
|
||||
const v = parseNumericValue(raw) ?? 0;
|
||||
if (linked) {
|
||||
onCommit("all", v);
|
||||
} else {
|
||||
onCommit(corner, v);
|
||||
}
|
||||
},
|
||||
[linked, onCommit],
|
||||
);
|
||||
|
||||
const handleToggleLinked = useCallback(() => {
|
||||
if (!linked && !uniform) {
|
||||
onCommit("all", tl);
|
||||
}
|
||||
setLinked((l) => !l);
|
||||
}, [linked, uniform, tl, onCommit]);
|
||||
|
||||
const path = buildRoundedRectPath(PREVIEW_W, PREVIEW_H, sTL, sTR, sBR, sBL);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
width={PREVIEW_W}
|
||||
height={PREVIEW_H}
|
||||
viewBox={`0 0 ${PREVIEW_W} ${PREVIEW_H}`}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d={path}
|
||||
fill="rgba(255,255,255,0.06)"
|
||||
stroke="rgba(255,255,255,0.24)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<circle
|
||||
cx={sTL}
|
||||
cy={sTL}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<circle
|
||||
cx={PREVIEW_W - sTR}
|
||||
cy={sTR}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<circle
|
||||
cx={PREVIEW_W - sBR}
|
||||
cy={PREVIEW_H - sBR}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<circle
|
||||
cx={sBL}
|
||||
cy={PREVIEW_H - sBL}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
onClick={handleToggleLinked}
|
||||
disabled={disabled}
|
||||
title={linked ? "Unlink corners" : "Link all corners"}
|
||||
>
|
||||
{linked ? (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2M5 8h6" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{linked ? (
|
||||
<MetricField
|
||||
label="All"
|
||||
value={formatNumericValue(tl)}
|
||||
disabled={disabled}
|
||||
liveCommit
|
||||
onCommit={(next) => handleCornerCommit("tl", next)}
|
||||
/>
|
||||
) : (
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<MetricField
|
||||
label="TL"
|
||||
value={formatNumericValue(tl)}
|
||||
disabled={disabled}
|
||||
liveCommit
|
||||
onCommit={(next) => handleCornerCommit("tl", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="TR"
|
||||
value={formatNumericValue(tr)}
|
||||
disabled={disabled}
|
||||
liveCommit
|
||||
onCommit={(next) => handleCornerCommit("tr", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="BL"
|
||||
value={formatNumericValue(bl)}
|
||||
disabled={disabled}
|
||||
liveCommit
|
||||
onCommit={(next) => handleCornerCommit("bl", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="BR"
|
||||
value={formatNumericValue(br)}
|
||||
disabled={disabled}
|
||||
liveCommit
|
||||
onCommit={(next) => handleCornerCommit("br", next)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRoundedRectPath(
|
||||
w: number,
|
||||
h: number,
|
||||
tl: number,
|
||||
tr: number,
|
||||
br: number,
|
||||
bl: number,
|
||||
): string {
|
||||
return [
|
||||
`M ${tl} 0`,
|
||||
`L ${w - tr} 0`,
|
||||
`Q ${w} 0 ${w} ${tr}`,
|
||||
`L ${w} ${h - br}`,
|
||||
`Q ${w} ${h} ${w - br} ${h}`,
|
||||
`L ${bl} ${h}`,
|
||||
`Q 0 ${h} 0 ${h - bl}`,
|
||||
`L 0 ${tl}`,
|
||||
`Q 0 0 ${tl} 0`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
@@ -90,6 +90,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}: DomEditOverlayProps) {
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectionShapeStyles = (() => {
|
||||
const fallback = {
|
||||
borderRadius: 4 as string | number,
|
||||
clipPath: undefined as string | undefined,
|
||||
};
|
||||
if (!selection?.element) return fallback;
|
||||
try {
|
||||
const tag = selection.element.tagName.toLowerCase();
|
||||
if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
|
||||
const win = selection.element.ownerDocument.defaultView;
|
||||
if (!win) return fallback;
|
||||
const cs = win.getComputedStyle(selection.element);
|
||||
const br = cs.borderRadius;
|
||||
const cp = cs.clipPath;
|
||||
return {
|
||||
borderRadius: br && br !== "0px" ? br : 4,
|
||||
clipPath: cp && cp !== "none" ? cp : undefined,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
})();
|
||||
const gestureRef = useRef<GestureState | null>(null);
|
||||
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
||||
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
||||
@@ -134,6 +157,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
groupOverlayItems,
|
||||
groupOverlayItemsRef,
|
||||
setGroupOverlayItems,
|
||||
childRects,
|
||||
} = useDomEditOverlayRects({
|
||||
iframeRef,
|
||||
overlayRef,
|
||||
@@ -228,6 +252,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
|
||||
|
||||
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!allowCanvasMovement) return;
|
||||
if (suppressNextOverlayMouseDownRef.current) {
|
||||
suppressNextOverlayMouseDownRef.current = false;
|
||||
suppressNextBoxMouseDownRef.current = false;
|
||||
@@ -288,6 +313,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
};
|
||||
|
||||
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!allowCanvasMovement) return;
|
||||
if (gestureRef.current || groupGestureRef.current) return;
|
||||
if (suppressNextBoxClickRef.current) {
|
||||
suppressNextBoxClickRef.current = false;
|
||||
@@ -320,20 +346,37 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
onPointerUp={gestures.onPointerUp}
|
||||
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
|
||||
>
|
||||
{hoverSelection && hoverRect && (
|
||||
{hoverSelection && hoverRect && compRect.width > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-dom-edit-hover-box="true"
|
||||
className="pointer-events-none absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
style={{
|
||||
left: hoverRect.left,
|
||||
top: hoverRect.top,
|
||||
width: hoverRect.width,
|
||||
height: hoverRect.height,
|
||||
}}
|
||||
className="pointer-events-none absolute border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
style={(() => {
|
||||
let br: string | number = 4;
|
||||
let cp: string | undefined;
|
||||
try {
|
||||
const el = hoverSelection.element;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
|
||||
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
||||
if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
|
||||
if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin guard */
|
||||
}
|
||||
return {
|
||||
left: hoverRect.left,
|
||||
top: hoverRect.top,
|
||||
width: hoverRect.width,
|
||||
height: hoverRect.height,
|
||||
borderRadius: br,
|
||||
clipPath: cp,
|
||||
};
|
||||
})()}
|
||||
/>
|
||||
)}
|
||||
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
|
||||
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
|
||||
<>
|
||||
{groupOverlayItems.map((item) => (
|
||||
<div
|
||||
@@ -367,7 +410,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!hasGroupSelection && selection && overlayRect && (
|
||||
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
|
||||
<>
|
||||
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
|
||||
<div
|
||||
@@ -398,12 +441,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
key={selectionKey}
|
||||
ref={boxRef}
|
||||
data-dom-edit-selection-box="true"
|
||||
className="pointer-events-auto absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
className={`pointer-events-auto absolute ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
|
||||
style={{
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width,
|
||||
height: overlayRect.height,
|
||||
borderRadius: selectionShapeStyles.borderRadius,
|
||||
clipPath: selectionShapeStyles.clipPath,
|
||||
cursor:
|
||||
allowCanvasMovement && selection.capabilities.canApplyManualOffset
|
||||
? "move"
|
||||
@@ -441,6 +486,20 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{childRects.length > 0 &&
|
||||
compRect.width > 0 &&
|
||||
childRects.map((cr, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
|
||||
style={{
|
||||
left: cr.left,
|
||||
top: cr.top,
|
||||
width: cr.width,
|
||||
height: cr.height,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<GridOverlay
|
||||
visible={gridVisible}
|
||||
spacing={gridSpacing}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
|
||||
interface DopesheetKeyframe {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
|
||||
interface DopesheetStripProps {
|
||||
keyframes: DopesheetKeyframe[];
|
||||
selectedPercentage: number | null;
|
||||
currentPercentage: number;
|
||||
accentColor?: string;
|
||||
onSelectKeyframe: (percentage: number) => void;
|
||||
onDragKeyframe?: (fromPct: number, toPct: number) => void;
|
||||
}
|
||||
|
||||
const DIAMOND_SIZE = 8;
|
||||
const HALF = DIAMOND_SIZE / 2;
|
||||
const STRIP_HEIGHT = 20;
|
||||
const PADDING_X = 8;
|
||||
|
||||
export const DopesheetStrip = memo(function DopesheetStrip({
|
||||
keyframes,
|
||||
selectedPercentage,
|
||||
currentPercentage,
|
||||
accentColor = "#3CE6AC",
|
||||
onSelectKeyframe,
|
||||
onDragKeyframe,
|
||||
}: DopesheetStripProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
|
||||
|
||||
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent, pct: number) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
|
||||
const handleMove = (me: PointerEvent) => {
|
||||
if (Math.abs(me.clientX - startX) > 4) {
|
||||
dragRef.current = { startX, startPct: pct };
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = (ue: PointerEvent) => {
|
||||
document.removeEventListener("pointermove", handleMove);
|
||||
document.removeEventListener("pointerup", handleUp);
|
||||
if (dragRef.current && containerRef.current && onDragKeyframe) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const usableWidth = rect.width - PADDING_X * 2;
|
||||
const dx = ue.clientX - dragRef.current.startX;
|
||||
const dpct = (dx / usableWidth) * 100;
|
||||
const newPct = Math.max(0, Math.min(100, Math.round((pct + dpct) * 10) / 10));
|
||||
if (newPct !== pct) onDragKeyframe(pct, newPct);
|
||||
} else {
|
||||
onSelectKeyframe(pct);
|
||||
}
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", handleMove);
|
||||
document.addEventListener("pointerup", handleUp);
|
||||
},
|
||||
[onSelectKeyframe, onDragKeyframe],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full rounded-md bg-neutral-900/60 border border-neutral-800/50"
|
||||
style={{ height: STRIP_HEIGHT }}
|
||||
>
|
||||
{/* Playhead indicator */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-white/30"
|
||||
style={{
|
||||
left: `${PADDING_X + (currentPercentage / 100) * (100 - PADDING_X * 2)}%`,
|
||||
marginLeft: -0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Diamond markers */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full"
|
||||
style={{ height: STRIP_HEIGHT }}
|
||||
viewBox={`0 0 100 ${STRIP_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{sorted.map((kf) => {
|
||||
const x = PADDING_X + (kf.percentage / 100) * (100 - PADDING_X * 2);
|
||||
const y = STRIP_HEIGHT / 2;
|
||||
const isSelected =
|
||||
selectedPercentage !== null && Math.abs(kf.percentage - selectedPercentage) < 0.5;
|
||||
const isHold = kf.ease === "steps(1)";
|
||||
const fillColor = isSelected ? accentColor : "#737373";
|
||||
|
||||
return (
|
||||
<g
|
||||
key={kf.percentage}
|
||||
onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{isHold ? (
|
||||
<rect
|
||||
x={x - HALF}
|
||||
y={y - HALF}
|
||||
width={DIAMOND_SIZE}
|
||||
height={DIAMOND_SIZE}
|
||||
fill={fillColor}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={x - HALF}
|
||||
y={y - HALF}
|
||||
width={DIAMOND_SIZE}
|
||||
height={DIAMOND_SIZE}
|
||||
fill={fillColor}
|
||||
transform={`rotate(45, ${x}, ${y})`}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Time labels */}
|
||||
{sorted.length > 0 && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[8px] text-neutral-600 pointer-events-none"
|
||||
style={{ lineHeight: "10px" }}
|
||||
>
|
||||
<span>{sorted[0].percentage}%</span>
|
||||
{sorted.length > 1 && <span>{sorted[sorted.length - 1].percentage}%</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,80 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
|
||||
|
||||
const PRESET_GRID_EASES = [
|
||||
"none",
|
||||
"power2.out",
|
||||
"power2.in",
|
||||
"power2.inOut",
|
||||
"power3.out",
|
||||
"back.out",
|
||||
"expo.out",
|
||||
"elastic.out",
|
||||
] as const;
|
||||
|
||||
function MiniCurveSvg({
|
||||
curve,
|
||||
active,
|
||||
}: {
|
||||
curve: [number, number, number, number];
|
||||
active: boolean;
|
||||
}) {
|
||||
const [x1, y1, x2, y2] = curve;
|
||||
const s = 24;
|
||||
const p = 3;
|
||||
const g = s - p * 2;
|
||||
const sx = (px: number) => p + g * px;
|
||||
const sy = (py: number) => s - p - g * py;
|
||||
const d = `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
|
||||
return (
|
||||
<svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={active ? "#3CE6AC" : "#737373"}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const EasePresetGrid = memo(function EasePresetGrid({
|
||||
currentEase,
|
||||
onSelect,
|
||||
}: {
|
||||
currentEase: string;
|
||||
onSelect: (ease: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-1 mb-2">
|
||||
{PRESET_GRID_EASES.map((name) => {
|
||||
const curve = EASE_CURVES[name];
|
||||
if (!curve) return null;
|
||||
const isActive = currentEase === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => onSelect(name)}
|
||||
className={`flex flex-col items-center gap-0.5 rounded-md p-1 transition-colors ${
|
||||
isActive ? "bg-panel-accent/10 ring-1 ring-panel-accent/30" : "hover:bg-neutral-800"
|
||||
}`}
|
||||
title={EASE_LABELS[name] ?? name}
|
||||
>
|
||||
<MiniCurveSvg curve={curve} active={isActive} />
|
||||
<span
|
||||
className={`text-[8px] leading-none ${isActive ? "text-panel-accent" : "text-neutral-500"}`}
|
||||
>
|
||||
{(EASE_LABELS[name] ?? name).split(" ").slice(0, 2).join(" ")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
@@ -108,12 +182,13 @@ export function EaseCurveSection({
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-neutral-900/50 p-2">
|
||||
<EasePresetGrid currentEase={ease} onSelect={(name) => onCustomEaseCommit(name)} />
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-neutral-500">Speed curve</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={play}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 transition-colors hover:bg-emerald-500/10"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-panel-accent transition-colors hover:bg-panel-accent/10"
|
||||
>
|
||||
{progress !== null ? "Playing…" : "Preview"}
|
||||
</button>
|
||||
@@ -165,17 +240,17 @@ export function EaseCurveSection({
|
||||
y1={end.y}
|
||||
x2={p2.x}
|
||||
y2={p2.y}
|
||||
stroke="rgba(52,211,153,0.25)"
|
||||
stroke="rgba(45,212,191,0.25)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path d={curvePath} fill="none" stroke="#34d399" strokeWidth="2" strokeLinecap="round" />
|
||||
{progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#34d399" />}
|
||||
<path d={curvePath} fill="none" stroke="#3CE6AC" strokeWidth="2" strokeLinecap="round" />
|
||||
{progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#3CE6AC" />}
|
||||
<circle
|
||||
cx={p1.x}
|
||||
cy={p1.y}
|
||||
r="5"
|
||||
fill="#0a0a1a"
|
||||
stroke="#34d399"
|
||||
stroke="#3CE6AC"
|
||||
strokeWidth="2"
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={(e) => handlePointerDown("p1", e)}
|
||||
@@ -185,7 +260,7 @@ export function EaseCurveSection({
|
||||
cy={p2.y}
|
||||
r="5"
|
||||
fill="#0a0a1a"
|
||||
stroke="#34d399"
|
||||
stroke="#3CE6AC"
|
||||
strokeWidth="2"
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={(e) => handlePointerDown("p2", e)}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import type { GestureSample } from "../../hooks/useGestureRecording";
|
||||
|
||||
interface GestureTrailOverlayProps {
|
||||
samples: GestureSample[];
|
||||
sampleCount?: number;
|
||||
trail?: Array<{ x: number; y: number }>;
|
||||
simplifiedPoints?: Map<number, Record<string, number>>;
|
||||
canvasRect: { left: number; top: number; width: number; height: number };
|
||||
compositionSize?: { width: number; height: number };
|
||||
mode: "recording" | "preview";
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export const GestureTrailOverlay = memo(function GestureTrailOverlay({
|
||||
samples,
|
||||
sampleCount,
|
||||
trail,
|
||||
simplifiedPoints,
|
||||
canvasRect,
|
||||
compositionSize,
|
||||
mode,
|
||||
accentColor = "#3CE6AC",
|
||||
}: GestureTrailOverlayProps) {
|
||||
const trailPoints = useMemo(() => {
|
||||
if (trail && trail.length > 1) {
|
||||
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
|
||||
}
|
||||
if (samples.length === 0) return "";
|
||||
return samples
|
||||
.filter((s) => s.properties.x != null && s.properties.y != null)
|
||||
.map((s) => `${s.properties.x},${s.properties.y}`)
|
||||
.join(" ");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]);
|
||||
|
||||
const simplifiedPath = useMemo(() => {
|
||||
if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
|
||||
const pts: Array<{ x: number; y: number; pct: number }> = [];
|
||||
for (const [pct, props] of simplifiedPoints) {
|
||||
if (props.x != null && props.y != null) {
|
||||
pts.push({ x: props.x, y: props.y, pct });
|
||||
}
|
||||
}
|
||||
pts.sort((a, b) => a.pct - b.pct);
|
||||
if (pts.length === 0) return "";
|
||||
return pts.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
}, [simplifiedPoints]);
|
||||
|
||||
const diamondPositions = useMemo(() => {
|
||||
if (!simplifiedPoints || simplifiedPoints.size === 0) return [];
|
||||
const pts: Array<{ x: number; y: number; pct: number }> = [];
|
||||
for (const [pct, props] of simplifiedPoints) {
|
||||
if (props.x != null && props.y != null) {
|
||||
pts.push({ x: props.x, y: props.y, pct });
|
||||
}
|
||||
}
|
||||
return pts.sort((a, b) => a.pct - b.pct);
|
||||
}, [simplifiedPoints]);
|
||||
|
||||
if (samples.length < 2 && !simplifiedPoints) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none fixed z-50"
|
||||
style={{
|
||||
left: canvasRect.left,
|
||||
top: canvasRect.top,
|
||||
width: canvasRect.width,
|
||||
height: canvasRect.height,
|
||||
}}
|
||||
viewBox={
|
||||
trail && trail.length > 1
|
||||
? `0 0 ${canvasRect.width} ${canvasRect.height}`
|
||||
: `0 0 ${compositionSize?.width ?? canvasRect.width} ${compositionSize?.height ?? canvasRect.height}`
|
||||
}
|
||||
>
|
||||
{mode === "recording" && trailPoints && (
|
||||
<polyline
|
||||
points={trailPoints}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "preview" && (
|
||||
<>
|
||||
{trailPoints && (
|
||||
<polyline
|
||||
points={trailPoints}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
strokeDasharray="4 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{simplifiedPath && (
|
||||
<polyline
|
||||
points={simplifiedPath}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
{diamondPositions.map((pt) => (
|
||||
<g key={pt.pct} transform={`translate(${pt.x}, ${pt.y})`}>
|
||||
<rect
|
||||
x="-4"
|
||||
y="-4"
|
||||
width="8"
|
||||
height="8"
|
||||
rx="1"
|
||||
transform="rotate(45)"
|
||||
fill={accentColor}
|
||||
fillOpacity="0.9"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { ArcPathSegment, GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { Film } from "../../icons/SystemIcons";
|
||||
import { Section } from "./propertyPanelPrimitives";
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
@@ -23,6 +23,15 @@ interface GsapAnimationSectionProps {
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
onLivePreview?: (property: string, value: number | string) => void;
|
||||
onLivePreviewEnd?: () => void;
|
||||
onSetArcPath?: (
|
||||
animationId: string,
|
||||
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
|
||||
) => void;
|
||||
onUpdateArcSegment?: (
|
||||
animationId: string,
|
||||
segmentIndex: number,
|
||||
update: Partial<ArcPathSegment>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
@@ -40,6 +49,8 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
onAddAnimation,
|
||||
onLivePreview,
|
||||
onLivePreviewEnd,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
}: GsapAnimationSectionProps) {
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
|
||||
@@ -75,6 +86,8 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
onRemoveFromProperty={onRemoveFromProperty}
|
||||
onLivePreview={onLivePreview}
|
||||
onLivePreviewEnd={onLivePreviewEnd}
|
||||
onSetArcPath={onSetArcPath}
|
||||
onUpdateArcSegment={onUpdateArcSegment}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ interface KeyframeDiamondProps {
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
size?: number;
|
||||
isHold?: boolean;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -15,10 +16,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
|
||||
onClick,
|
||||
title,
|
||||
size = 10,
|
||||
isHold = false,
|
||||
}: KeyframeDiamondProps) {
|
||||
const isFilled = state === "active";
|
||||
const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1;
|
||||
const color = state === "active" ? "#3b82f6" : "#a3a3a3";
|
||||
const color = state === "active" ? "#3CE6AC" : "#a3a3a3";
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -32,17 +34,30 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
|
||||
title={title}
|
||||
>
|
||||
<svg width={size} height={size} viewBox="0 0 10 10">
|
||||
<rect
|
||||
x="5"
|
||||
y="0.7"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="1"
|
||||
transform="rotate(45 5 0.7)"
|
||||
fill={isFilled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
{isHold ? (
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="0.5"
|
||||
fill={isFilled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x="5"
|
||||
y="0.7"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="1"
|
||||
transform="rotate(45 5 0.7)"
|
||||
fill={isFilled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -60,9 +60,9 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
refreshKey,
|
||||
compositionLoading,
|
||||
timelineElements,
|
||||
currentTime,
|
||||
showToast,
|
||||
} = useStudioContext();
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const {
|
||||
domEditSelection,
|
||||
applyDomSelection,
|
||||
@@ -239,9 +239,9 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
|
||||
if (layers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
|
||||
<Layers size={18} className="mb-3 text-neutral-600" />
|
||||
<p className="text-sm font-medium text-neutral-200">No layers</p>
|
||||
<div className="flex h-full flex-col items-center justify-center bg-panel-bg px-6 text-center">
|
||||
<Layers size={18} className="mb-3 text-panel-text-5" />
|
||||
<p className="text-sm font-medium text-panel-text-1">No layers</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">Load a composition to see its element tree</p>
|
||||
</div>
|
||||
);
|
||||
@@ -249,10 +249,10 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900"
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg"
|
||||
onPointerLeave={() => handleLayerHover(null)}
|
||||
>
|
||||
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
|
||||
<div className="border-b border-panel-border px-3 py-2 text-[11px] text-panel-text-3">
|
||||
{layers.length} layer{layers.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div
|
||||
@@ -289,8 +289,8 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
isDragged
|
||||
? "opacity-40"
|
||||
: selected
|
||||
? "bg-studio-accent/14 text-studio-accent"
|
||||
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
|
||||
? "bg-panel-accent/14 text-panel-accent"
|
||||
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
|
||||
} ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
style={{ paddingLeft: 8 + layer.depth * 16 }}
|
||||
>
|
||||
@@ -316,17 +316,19 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
<span
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
|
||||
selected
|
||||
? "bg-studio-accent/18 text-studio-accent"
|
||||
? "bg-panel-accent/18 text-panel-accent"
|
||||
: isCompHost
|
||||
? "bg-blue-900/40 text-blue-400"
|
||||
: "bg-neutral-800 text-neutral-500"
|
||||
? "bg-panel-accent/40 text-panel-accent"
|
||||
: "bg-panel-hover text-panel-text-4"
|
||||
}`}
|
||||
>
|
||||
{getTagBadge(layer.tagName)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
|
||||
{hasChildren && (
|
||||
<span className="text-[9px] tabular-nums text-neutral-600">{layer.childCount}</span>
|
||||
<span className="text-[9px] tabular-nums text-panel-text-5">
|
||||
{layer.childCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { memo, useMemo, type RefObject } from "react";
|
||||
import type { ArcPathConfig } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
interface MotionPathOverlayProps {
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
arcPath: ArcPathConfig | null;
|
||||
waypoints: Array<{ x: number; y: number }> | null;
|
||||
elementBaseRect: { left: number; top: number; scaleX: number; scaleY: number } | null;
|
||||
}
|
||||
|
||||
function buildSvgPath(
|
||||
waypoints: Array<{ x: number; y: number }>,
|
||||
segments: ArcPathConfig["segments"],
|
||||
base: { left: number; top: number; scaleX: number; scaleY: number },
|
||||
): string {
|
||||
if (waypoints.length < 2) return "";
|
||||
|
||||
const toPixel = (wp: { x: number; y: number }) => ({
|
||||
x: base.left + wp.x * base.scaleX,
|
||||
y: base.top + wp.y * base.scaleY,
|
||||
});
|
||||
|
||||
const first = toPixel(waypoints[0]!);
|
||||
const parts = [`M ${first.x} ${first.y}`];
|
||||
|
||||
for (let i = 0; i < segments.length && i < waypoints.length - 1; i++) {
|
||||
const seg = segments[i]!;
|
||||
const end = toPixel(waypoints[i + 1]!);
|
||||
|
||||
if (seg.cp1 && seg.cp2) {
|
||||
const c1 = toPixel(seg.cp1);
|
||||
const c2 = toPixel(seg.cp2);
|
||||
parts.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`);
|
||||
} else {
|
||||
const start = toPixel(waypoints[i]!);
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const c = seg.curviness ?? 1;
|
||||
const offset = c * Math.abs(dx) * 0.25;
|
||||
const c1x = start.x + dx * 0.33;
|
||||
const c1y = start.y + dy * 0.33 - offset;
|
||||
const c2x = start.x + dx * 0.66;
|
||||
const c2y = start.y + dy * 0.66 - offset;
|
||||
parts.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${end.x} ${end.y}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
arcPath,
|
||||
waypoints,
|
||||
elementBaseRect,
|
||||
}: MotionPathOverlayProps) {
|
||||
const pathD = useMemo(() => {
|
||||
if (!arcPath?.enabled || !waypoints || waypoints.length < 2 || !elementBaseRect) return "";
|
||||
return buildSvgPath(waypoints, arcPath.segments, elementBaseRect);
|
||||
}, [arcPath, waypoints, elementBaseRect]);
|
||||
|
||||
const anchorPoints = useMemo(() => {
|
||||
if (!waypoints || !elementBaseRect) return [];
|
||||
return waypoints.map((wp) => ({
|
||||
x: elementBaseRect.left + wp.x * elementBaseRect.scaleX,
|
||||
y: elementBaseRect.top + wp.y * elementBaseRect.scaleY,
|
||||
}));
|
||||
}, [waypoints, elementBaseRect]);
|
||||
|
||||
const controlPoints = useMemo(() => {
|
||||
if (!arcPath?.enabled || !elementBaseRect) return [];
|
||||
const points: Array<{
|
||||
segIndex: number;
|
||||
type: "cp1" | "cp2";
|
||||
x: number;
|
||||
y: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
}> = [];
|
||||
for (let i = 0; i < arcPath.segments.length; i++) {
|
||||
const seg = arcPath.segments[i]!;
|
||||
if (seg.cp1 && seg.cp2 && waypoints) {
|
||||
const anchor1 = waypoints[i]!;
|
||||
const anchor2 = waypoints[i + 1]!;
|
||||
points.push({
|
||||
segIndex: i,
|
||||
type: "cp1",
|
||||
x: elementBaseRect.left + seg.cp1.x * elementBaseRect.scaleX,
|
||||
y: elementBaseRect.top + seg.cp1.y * elementBaseRect.scaleY,
|
||||
anchorX: elementBaseRect.left + anchor1.x * elementBaseRect.scaleX,
|
||||
anchorY: elementBaseRect.top + anchor1.y * elementBaseRect.scaleY,
|
||||
});
|
||||
points.push({
|
||||
segIndex: i,
|
||||
type: "cp2",
|
||||
x: elementBaseRect.left + seg.cp2.x * elementBaseRect.scaleX,
|
||||
y: elementBaseRect.top + seg.cp2.y * elementBaseRect.scaleY,
|
||||
anchorX: elementBaseRect.left + anchor2.x * elementBaseRect.scaleX,
|
||||
anchorY: elementBaseRect.top + anchor2.y * elementBaseRect.scaleY,
|
||||
});
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}, [arcPath, waypoints, elementBaseRect]);
|
||||
|
||||
if (!pathD) return null;
|
||||
|
||||
return (
|
||||
<svg className="absolute inset-0 pointer-events-none z-20 overflow-visible">
|
||||
<path d={pathD} fill="none" stroke="rgba(45, 212, 191, 0.4)" strokeWidth={2} />
|
||||
|
||||
{controlPoints.map((cp) => (
|
||||
<g key={`${cp.segIndex}-${cp.type}`}>
|
||||
<line
|
||||
x1={cp.anchorX}
|
||||
y1={cp.anchorY}
|
||||
x2={cp.x}
|
||||
y2={cp.y}
|
||||
stroke="rgba(167, 139, 250, 0.3)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
<circle
|
||||
cx={cp.x}
|
||||
cy={cp.y}
|
||||
r={4}
|
||||
fill="#a78bfa"
|
||||
className="pointer-events-auto cursor-grab"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{anchorPoints.map((pt, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={pt.x}
|
||||
cy={pt.y}
|
||||
r={5}
|
||||
fill="#3CE6AC"
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
strokeWidth={1}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo } from "react";
|
||||
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
|
||||
import { useStudioContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
import {
|
||||
EMPTY_STYLES,
|
||||
formatPxMetricValue,
|
||||
LABEL,
|
||||
parsePxMetricValue,
|
||||
RESPONSIVE_GRID,
|
||||
} from "./propertyPanelHelpers";
|
||||
@@ -16,7 +16,7 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { computeFitToChildrenSize, type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
|
||||
// Re-export helpers that external consumers import from this module
|
||||
export {
|
||||
@@ -41,7 +41,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
assets,
|
||||
element,
|
||||
multiSelectCount = 0,
|
||||
copiedAgentPrompt,
|
||||
copiedAgentPrompt: _copiedAgentPrompt,
|
||||
onClearSelection,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
@@ -53,7 +53,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetTextFieldStyle,
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onAskAgent,
|
||||
onAskAgent: _onAskAgent,
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
onImportFonts,
|
||||
@@ -70,13 +70,22 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onAddGsapFromProperty,
|
||||
onRemoveGsapFromProperty,
|
||||
onAddGsapAnimation,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
onAddKeyframe,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onCommitAnimatedProperty,
|
||||
onSeekToTime,
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
}: PropertyPanelProps) {
|
||||
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
||||
const { showToast } = useStudioContext();
|
||||
const [clipboardCopied, setClipboardCopied] = useState(false);
|
||||
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
|
||||
if (!element) {
|
||||
return (
|
||||
@@ -170,10 +179,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetManualRotation(element, { angle: parsed });
|
||||
};
|
||||
|
||||
// Keyframe navigation state
|
||||
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
|
||||
|
||||
const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
|
||||
@@ -217,6 +224,34 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
}
|
||||
})();
|
||||
|
||||
const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => {
|
||||
if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
|
||||
const hasBRProp = gsapAnimations.some(
|
||||
(a) =>
|
||||
"borderRadius" in a.properties ||
|
||||
a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
|
||||
);
|
||||
if (!hasBRProp) return null;
|
||||
}
|
||||
const iframe = previewIframeRef?.current;
|
||||
const selector = element.id ? `#${element.id}` : element.selector;
|
||||
if (!iframe?.contentDocument || !selector) return null;
|
||||
try {
|
||||
const el = iframe.contentDocument.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const cs = iframe.contentWindow!.getComputedStyle(el);
|
||||
const parse = (v: string) => Number.parseFloat(v) || 0;
|
||||
return {
|
||||
tl: parse(cs.borderTopLeftRadius),
|
||||
tr: parse(cs.borderTopRightRadius),
|
||||
br: parse(cs.borderBottomRightRadius),
|
||||
bl: parse(cs.borderBottomLeftRadius),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
|
||||
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
|
||||
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
|
||||
@@ -224,34 +259,100 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
|
||||
<div className="border-b border-neutral-800 px-4 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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={LABEL}>Document</div>
|
||||
<div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
|
||||
<div className="truncate text-[13px] font-semibold text-neutral-100">
|
||||
{element.label}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const file = element.sourceFile ?? "index.html";
|
||||
let lineNum: number | null = null;
|
||||
try {
|
||||
const src =
|
||||
previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
|
||||
if (src && element.id) {
|
||||
const idx = src.indexOf(`id="${element.id}"`);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
if (!lineNum && element.selector) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const cls = element.selector.startsWith(".")
|
||||
? element.selector.slice(1).split(".")[0]
|
||||
: null;
|
||||
const search = cls ? `class="${cls}` : `<${tag}`;
|
||||
const idx = src.indexOf(search);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
} catch {}
|
||||
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
||||
const lines = [
|
||||
`Element: ${element.label} (${sourceLabel})`,
|
||||
`File: ${fileLoc}`,
|
||||
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
||||
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
||||
`Tag: <${element.tagName}>`,
|
||||
];
|
||||
if (
|
||||
element.computedStyles["z-index"] &&
|
||||
element.computedStyles["z-index"] !== "auto"
|
||||
) {
|
||||
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
||||
}
|
||||
if (gsapAnimations.length > 0) {
|
||||
const anim = gsapAnimations[0];
|
||||
lines.push(
|
||||
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
||||
);
|
||||
const props = Object.entries(anim.properties)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(", ");
|
||||
if (props) lines.push(`Properties: ${props}`);
|
||||
}
|
||||
const text = lines.join("\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
showToast(
|
||||
`Copied element info for ${element.label} — paste into any AI agent`,
|
||||
"info",
|
||||
);
|
||||
setClipboardCopied(true);
|
||||
clearTimeout(clipboardTimerRef.current);
|
||||
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
||||
}}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
|
||||
clipboardCopied
|
||||
? "text-studio-accent"
|
||||
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
|
||||
}`}
|
||||
title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
onClick={onClearSelection}
|
||||
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} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
onClick={onClearSelection}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-neutral-700 bg-neutral-950 text-neutral-500 shadow-[0_1px_2px_rgba(0,0,0,0.2)] transition-colors hover:border-neutral-600 hover:text-neutral-200"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAskAgent}
|
||||
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
<span>{copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -384,29 +485,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{element.capabilities.canApplyManualSize && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
title="Fit to children"
|
||||
onClick={() => {
|
||||
const size = computeFitToChildrenSize(element);
|
||||
if (size) onSetManualSize(element, size);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
>
|
||||
<rect x="2" y="2" width="10" height="10" strokeDasharray="2 1.5" rx="1" />
|
||||
<path d="M2 4.5h1m-1 5h1m8-5h1m-1 5h1M4.5 2v1m5-1v1M4.5 11v1m5-1v1" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
@@ -467,17 +545,40 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MetricField
|
||||
label="Scale"
|
||||
value={String(gsapRuntimeValues.scale ?? 1)}
|
||||
scrub
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next);
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "scale", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="Scale"
|
||||
value={String(gsapRuntimeValues.scale ?? 1)}
|
||||
scrub
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next);
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "scale", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
|
||||
<KeyframeNavigation
|
||||
property="scale"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={() => {
|
||||
if (onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(
|
||||
element,
|
||||
"scale",
|
||||
gsapRuntimeValues?.scale ?? 1,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MetricField
|
||||
label="RotX"
|
||||
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
|
||||
@@ -533,9 +634,37 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onAddFromProperty={onAddGsapFromProperty}
|
||||
onRemoveFromProperty={onRemoveGsapFromProperty}
|
||||
onAddAnimation={onAddGsapAnimation}
|
||||
onSetArcPath={onSetArcPath}
|
||||
onUpdateArcSegment={onUpdateArcSegment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{onToggleRecording && (
|
||||
<div className="px-4 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onToggleRecording}
|
||||
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
|
||||
recordingState === "recording"
|
||||
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
|
||||
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
|
||||
}`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
{recordingState === "recording" ? (
|
||||
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
|
||||
) : (
|
||||
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
{recordingState === "recording"
|
||||
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
|
||||
: "Record gesture (R) — move pointer to capture motion"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditableSections && (
|
||||
<StyleSections
|
||||
projectId={projectId}
|
||||
@@ -544,6 +673,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
assets={assets}
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,6 @@ export const SourceEditor = memo(function SourceEditor({
|
||||
selection: { anchor: pos },
|
||||
effects: EditorView.scrollIntoView(pos, { y: "center" }),
|
||||
});
|
||||
view.focus();
|
||||
}, [revealOffset]);
|
||||
|
||||
return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo, useState } from "react";
|
||||
import { MetricField } from "./propertyPanelPrimitives";
|
||||
|
||||
export type StaggerOrder = "dom" | "reverse" | "center" | "edges" | "random";
|
||||
|
||||
interface StaggerControlsProps {
|
||||
elementCount: number;
|
||||
onApplyStagger: (offsetMs: number, order: StaggerOrder) => void;
|
||||
}
|
||||
|
||||
const ORDER_OPTIONS: StaggerOrder[] = ["dom", "reverse", "center", "edges", "random"];
|
||||
const ORDER_LABELS: Record<StaggerOrder, string> = {
|
||||
dom: "DOM order",
|
||||
reverse: "Reverse",
|
||||
center: "Center out",
|
||||
edges: "Edges in",
|
||||
random: "Random",
|
||||
};
|
||||
|
||||
export const StaggerControls = memo(function StaggerControls({
|
||||
elementCount,
|
||||
onApplyStagger,
|
||||
}: StaggerControlsProps) {
|
||||
const [offsetMs, setOffsetMs] = useState(80);
|
||||
const [order, setOrder] = useState<StaggerOrder>("dom");
|
||||
|
||||
if (elementCount < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-neutral-800 bg-neutral-900/50 px-2 py-1.5">
|
||||
<span className="text-[10px] font-medium text-neutral-500">Stagger</span>
|
||||
<MetricField
|
||||
label="Offset"
|
||||
value={String(offsetMs)}
|
||||
suffix="ms"
|
||||
onCommit={(raw) => {
|
||||
const v = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(v) && v >= 0) setOffsetMs(v);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value as StaggerOrder)}
|
||||
className="rounded-md border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-[10px] text-neutral-200 outline-none"
|
||||
>
|
||||
{ORDER_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{ORDER_LABELS[o]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApplyStagger(offsetMs, order)}
|
||||
className="rounded-md bg-panel-accent/10 px-2 py-1 text-[10px] font-semibold text-panel-accent transition-colors hover:bg-panel-accent/20"
|
||||
>
|
||||
Apply ({elementCount})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -37,9 +37,26 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
|
||||
|
||||
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
|
||||
|
||||
function hasVisualPresence(el: HTMLElement): boolean {
|
||||
const win = el.ownerDocument.defaultView;
|
||||
if (!win) return false;
|
||||
const cs = win.getComputedStyle(el);
|
||||
if (cs.backgroundImage !== "none") return true;
|
||||
if (
|
||||
cs.backgroundColor &&
|
||||
cs.backgroundColor !== "transparent" &&
|
||||
cs.backgroundColor !== "rgba(0, 0, 0, 0)"
|
||||
)
|
||||
return true;
|
||||
if (cs.borderWidth && parseFloat(cs.borderWidth) > 0 && cs.borderStyle !== "none") return true;
|
||||
if (cs.boxShadow && cs.boxShadow !== "none") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isEmptyVisualContainer(el: HTMLElement): boolean {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (VISUAL_LEAF_TAGS.has(tag)) return false;
|
||||
if (hasVisualPresence(el)) return false;
|
||||
|
||||
const { children } = el;
|
||||
if (children.length === 0) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
||||
|
||||
@@ -240,6 +240,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
|
||||
"renderSeek",
|
||||
);
|
||||
const wrappedTimelineSeek = wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "seek");
|
||||
wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "totalTime");
|
||||
const wrappedPlayerPlay = wrapPlayReapplyFunction(studioWin, studioWin.__player, "play");
|
||||
const wrappedTimelinePlay = wrapPlayReapplyFunction(studioWin, studioWin.__timeline, "play");
|
||||
const wrappedPlayerPause = wrapApplyAfterFunction(studioWin, studioWin.__player, "pause");
|
||||
@@ -250,6 +251,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
|
||||
for (const timeline of Object.values(studioWin.__timelines ?? {})) {
|
||||
wrappedNamedTimelineSeek =
|
||||
wrapSeekReapplyFunction(studioWin, timeline, "seek") || wrappedNamedTimelineSeek;
|
||||
wrapSeekReapplyFunction(studioWin, timeline, "totalTime");
|
||||
wrappedNamedTimelinePlay =
|
||||
wrapPlayReapplyFunction(studioWin, timeline, "play") || wrappedNamedTimelinePlay;
|
||||
wrappedNamedTimelinePause =
|
||||
@@ -268,6 +270,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const tl = value as Record<string, unknown>;
|
||||
wrapSeekReapplyFunction(studioWin, tl, "seek");
|
||||
wrapSeekReapplyFunction(studioWin, tl, "totalTime");
|
||||
wrapPlayReapplyFunction(studioWin, tl, "play");
|
||||
wrapApplyAfterFunction(studioWin, tl, "pause");
|
||||
studioWin.__hfStudioManualEditsApply?.();
|
||||
|
||||
@@ -273,11 +273,25 @@ export function applyStudioPathOffsetDraft(
|
||||
): void {
|
||||
promoteInlineForTransform(element);
|
||||
writeStudioPathOffsetVars(element, offset, { updateBase: false });
|
||||
element.style.setProperty(
|
||||
"translate",
|
||||
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
|
||||
);
|
||||
stripGsapTranslateFromTransform(element);
|
||||
|
||||
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
|
||||
if (isGsapAnimated) {
|
||||
// For GSAP-animated elements: use gsap.set for positioning (the timeline
|
||||
// is paused during drag). Set translate:none explicitly to prevent
|
||||
// double-counting with the transform.
|
||||
element.style.setProperty("translate", "none");
|
||||
const win = element.ownerDocument.defaultView as
|
||||
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
|
||||
| null;
|
||||
win?.gsap?.set(element, { x: offset.x, y: offset.y });
|
||||
} else {
|
||||
// Non-GSAP elements: use CSS translate as before.
|
||||
element.style.setProperty(
|
||||
"translate",
|
||||
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
|
||||
);
|
||||
stripGsapTranslateFromTransform(element);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Box size apply ───────────────────────────────────────────────── */
|
||||
@@ -505,6 +519,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
||||
|
||||
function reapplyPathOffsets(doc: Document): void {
|
||||
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
|
||||
// Skip elements where GSAP actively animates position — GSAP bakes the
|
||||
// CSS translate into its transform and sets translate: none every tick.
|
||||
// Stripping/restoring would oscillate against GSAP's rendering.
|
||||
if (gsapAnimatesProperty(el, "x", "y")) continue;
|
||||
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
||||
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
||||
if (x || y) {
|
||||
|
||||
@@ -232,6 +232,41 @@ export function createManualOffsetDragMember(input: {
|
||||
rect: ManualOffsetDragRect;
|
||||
}): ManualOffsetDragMemberResult {
|
||||
const initialOffset = readStudioPathOffset(input.element);
|
||||
input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
||||
input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
|
||||
|
||||
// Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path
|
||||
// needs the original (uncorrupted) GSAP position to compute the new keyframe value.
|
||||
const win = input.element.ownerDocument.defaultView as
|
||||
| (Window & {
|
||||
gsap?: { getProperty?: (el: Element, prop: string) => number };
|
||||
__timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
|
||||
})
|
||||
| null;
|
||||
const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
|
||||
const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
|
||||
input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
|
||||
input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
|
||||
|
||||
// Pause GSAP timelines during drag to prevent the tween from overwriting
|
||||
// the draft's gsap.set on every tick. Track which we paused to resume later.
|
||||
if (win?.__timelines) {
|
||||
const paused: string[] = [];
|
||||
for (const [id, tl] of Object.entries(win.__timelines)) {
|
||||
try {
|
||||
if (tl?.pause && !tl.paused?.()) {
|
||||
tl.pause();
|
||||
paused.push(id);
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin guard */
|
||||
}
|
||||
}
|
||||
if (paused.length > 0) {
|
||||
input.element.setAttribute("data-hf-drag-paused-timelines", paused.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
const initialPathOffset = captureStudioPathOffset(input.element);
|
||||
const gestureToken = beginStudioManualEditGesture(input.element);
|
||||
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
|
||||
@@ -313,11 +348,35 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
||||
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
||||
for (const member of members) {
|
||||
restoreManualOffsetDragMember(member);
|
||||
resumeGsapTimelines(member.element);
|
||||
}
|
||||
}
|
||||
|
||||
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
||||
for (const member of members) {
|
||||
endStudioManualEditGesture(member.element, member.gestureToken);
|
||||
member.element.removeAttribute("data-hf-drag-initial-offset-x");
|
||||
member.element.removeAttribute("data-hf-drag-initial-offset-y");
|
||||
member.element.removeAttribute("data-hf-drag-gsap-base-x");
|
||||
member.element.removeAttribute("data-hf-drag-gsap-base-y");
|
||||
resumeGsapTimelines(member.element);
|
||||
}
|
||||
}
|
||||
|
||||
function resumeGsapTimelines(element: HTMLElement): void {
|
||||
const ids = element.getAttribute("data-hf-drag-paused-timelines");
|
||||
element.removeAttribute("data-hf-drag-paused-timelines");
|
||||
if (!ids) return;
|
||||
const win = element.ownerDocument.defaultView as
|
||||
| (Window & {
|
||||
__timelines?: Record<string, { pause?: () => void }>;
|
||||
__player?: { seek?: (t: number) => void; getTime?: () => number };
|
||||
})
|
||||
| null;
|
||||
if (!win) return;
|
||||
// Re-seek to the current time to restore the paused timeline's render state.
|
||||
// play() would start playback; pause() already stops. Seek re-renders at the
|
||||
// current position without starting playback.
|
||||
const t = win.__player?.getTime?.() ?? 0;
|
||||
win.__player?.seek?.(t);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// ── Design Panel Tokens (for inline style={{}} usage) ──────────────────
|
||||
// Tailwind classes use `panel-*` from tailwind.config.js theme.extend.colors.
|
||||
// This file provides the same values for inline styles where Tailwind can't reach.
|
||||
|
||||
export const P = {
|
||||
accent: "#3CE6AC",
|
||||
borderInput: "#27272A",
|
||||
textMuted: "#52525B",
|
||||
white: "#FAFAFA",
|
||||
} as const;
|
||||
@@ -71,7 +71,7 @@ function ColorSlider({
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-disabled={disabled}
|
||||
className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-[#f5a400] focus:ring-2 focus:ring-[#f5a400]/40 ${
|
||||
className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-panel-accent focus:ring-2 focus:ring-panel-accent/40 ${
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
|
||||
}`}
|
||||
style={{ background }}
|
||||
@@ -294,7 +294,7 @@ export function ColorField({
|
||||
<div className="truncate text-[11px] font-medium text-neutral-100">
|
||||
{currentColor}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
|
||||
<div className="mt-0.5 text-[9px] text-neutral-600">
|
||||
S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -278,7 +278,7 @@ export function GradientField({
|
||||
checked={parsed.repeating}
|
||||
disabled={disabled}
|
||||
onChange={(e) => patch({ repeating: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
|
||||
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-panel-accent focus:ring-panel-accent"
|
||||
/>
|
||||
Repeat
|
||||
</label>
|
||||
|
||||
@@ -41,6 +41,19 @@ export interface PropertyPanelProps {
|
||||
onAddGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
onSetArcPath?: (
|
||||
animId: string,
|
||||
config: {
|
||||
enabled: boolean;
|
||||
autoRotate?: boolean | number;
|
||||
segments?: import("@hyperframes/core/gsap-parser").ArcPathSegment[];
|
||||
},
|
||||
) => void;
|
||||
onUpdateArcSegment?: (
|
||||
animId: string,
|
||||
segmentIndex: number,
|
||||
update: Partial<import("@hyperframes/core/gsap-parser").ArcPathSegment>,
|
||||
) => void;
|
||||
onAddKeyframe?: (
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
@@ -55,6 +68,9 @@ export interface PropertyPanelProps {
|
||||
value: number | string,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -184,8 +200,8 @@ function fontSourceRank(source: FontSource): number {
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const FIELD =
|
||||
"min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
|
||||
export const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
|
||||
"min-w-0 rounded-md bg-panel-input px-3 py-[7px] text-panel-text-1 transition-colors focus-within:ring-1 focus-within:ring-panel-accent/30";
|
||||
export const LABEL = "text-[11px] font-medium text-panel-text-3";
|
||||
export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
||||
export const EMPTY_STYLES: Record<string, string> = {};
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function MediaSection({
|
||||
{srcAttr && (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">Source</div>
|
||||
<div className="text-[11px] font-medium text-neutral-500">Source</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -257,9 +257,9 @@ export function SliderControl({
|
||||
onMouseUp={() => commitDraft(draft)}
|
||||
onTouchEnd={() => commitDraft(draft)}
|
||||
onBlur={() => commitDraft(draft)}
|
||||
className="h-2 min-w-0 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-[#3ce6ac] disabled:cursor-not-allowed"
|
||||
className="h-4 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
|
||||
/>
|
||||
<div className="min-w-[52px] rounded-xl border border-neutral-800 bg-neutral-900 px-2 py-2 text-right text-[11px] font-medium text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||||
<div className="min-w-[44px] rounded-md bg-panel-input px-2 py-1.5 text-right text-[11px] font-medium text-panel-text-1 tabular-nums">
|
||||
{formatDisplayValue?.(draft) ?? displayValue}
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,7 +279,7 @@ export function SegmentedControl({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="grid min-w-0 gap-1 rounded-xl bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
||||
className="grid min-w-0 gap-[2px] rounded-md bg-panel-input p-[2px]"
|
||||
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{options.map((option) => (
|
||||
@@ -288,10 +288,10 @@ export function SegmentedControl({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`min-w-0 truncate rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
||||
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
||||
option.value === value
|
||||
? "bg-neutral-800 text-white shadow-[0_1px_3px_rgba(0,0,0,0.28)]"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
? "bg-panel-hover text-white"
|
||||
: "text-panel-text-4 hover:text-panel-text-2"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
@@ -336,7 +336,7 @@ export function SelectField({
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
icon,
|
||||
icon: _icon,
|
||||
children,
|
||||
accessory,
|
||||
defaultCollapsed = false,
|
||||
@@ -350,32 +350,45 @@ export function Section({
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
|
||||
return (
|
||||
<section className="min-w-0 border-t border-neutral-800/80">
|
||||
<section className="min-w-0 border-t border-panel-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-3"
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2.5"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="flex-shrink-0 text-neutral-500">{icon}</span>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{accessory}
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
className={`flex-shrink-0 text-neutral-500 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||
>
|
||||
<path d="M2 3l3 4 3-4z" />
|
||||
</svg>
|
||||
{collapsed && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className="flex-shrink-0 text-panel-text-5"
|
||||
>
|
||||
<path
|
||||
d="M6 2.5v7M2.5 6h7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
className="flex-shrink-0 text-panel-text-5"
|
||||
>
|
||||
<path d="M2 3l3 4 3-4z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!collapsed && <div className="px-4 pb-4">{children}</div>}
|
||||
{!collapsed && <div className="px-4 pb-3">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,15 +262,13 @@ function TextFieldEditor({
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className={showRemove ? "flex min-w-0 items-center justify-between gap-2" : "min-w-0"}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[11px] font-medium text-neutral-100">
|
||||
{formatTextFieldPreview(field.value) || "Text"}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
|
||||
{field.tagName}
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-500">{field.tagName}</div>
|
||||
</div>
|
||||
{showRemove && (
|
||||
<button
|
||||
@@ -368,7 +366,7 @@ export function TextSection({
|
||||
|
||||
if (textFields.length === 1) {
|
||||
return (
|
||||
<Section title="Text" icon={<Type size={15} />}>
|
||||
<Section title="Text" icon={<Type size={15} />} defaultCollapsed>
|
||||
<TextFieldEditor
|
||||
field={activeField}
|
||||
styles={styles}
|
||||
@@ -426,7 +424,7 @@ export function TextSection({
|
||||
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-neutral-500">
|
||||
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
|
||||
{field.tagName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./propertyPanelPrimitives";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { GradientField, ImageFillField } from "./propertyPanelFill";
|
||||
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
||||
|
||||
export function StyleSections({
|
||||
projectId,
|
||||
@@ -41,6 +42,7 @@ export function StyleSections({
|
||||
assets,
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
gsapBorderRadius,
|
||||
}: {
|
||||
projectId: string;
|
||||
element: DomEditSelection;
|
||||
@@ -48,10 +50,19 @@ export function StyleSections({
|
||||
assets: string[];
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
}) {
|
||||
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
||||
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
||||
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
||||
const radiusTL =
|
||||
gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue;
|
||||
const radiusTR =
|
||||
gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue;
|
||||
const radiusBR =
|
||||
gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
|
||||
const radiusBL =
|
||||
gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
|
||||
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
|
||||
const borderWidthValue =
|
||||
parsePxMetricValue(styles["border-width"] ?? "") ??
|
||||
@@ -155,15 +166,26 @@ export function StyleSections({
|
||||
|
||||
{hasVisualBackground && (
|
||||
<Section title="Radius" icon={<Settings size={15} />} defaultCollapsed>
|
||||
<SliderControl
|
||||
value={radiusValue}
|
||||
min={0}
|
||||
max={Math.max(240, Math.ceil(radiusValue))}
|
||||
step={1}
|
||||
<BorderRadiusEditor
|
||||
tl={radiusTL}
|
||||
tr={radiusTR}
|
||||
br={radiusBR}
|
||||
bl={radiusBL}
|
||||
disabled={styleEditingDisabled}
|
||||
displayValue={`${formatNumericValue(radiusValue)}px`}
|
||||
formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
|
||||
onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
|
||||
onCommit={(corner, value) => {
|
||||
const px = `${formatNumericValue(value)}px`;
|
||||
if (corner === "all") {
|
||||
onSetStyle("border-radius", px);
|
||||
} else {
|
||||
const prop = {
|
||||
tl: "border-top-left-radius",
|
||||
tr: "border-top-right-radius",
|
||||
br: "border-bottom-right-radius",
|
||||
bl: "border-bottom-left-radius",
|
||||
}[corner];
|
||||
onSetStyle(prop, px);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,14 @@ import {
|
||||
toOverlayRect,
|
||||
} from "./domEditOverlayGeometry";
|
||||
|
||||
function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!rectsEqual(a[i]!, b[i]!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface UseDomEditOverlayRectsOptions {
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
overlayRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -37,6 +45,7 @@ interface UseDomEditOverlayRectsResult {
|
||||
groupOverlayItems: GroupOverlayItem[];
|
||||
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
|
||||
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
|
||||
childRects: OverlayRect[];
|
||||
}
|
||||
|
||||
export function useDomEditOverlayRects({
|
||||
@@ -51,6 +60,7 @@ export function useDomEditOverlayRects({
|
||||
const [overlayRect, setOverlayRectState] = useState<OverlayRect | null>(null);
|
||||
const [hoverRect, setHoverRectState] = useState<OverlayRect | null>(null);
|
||||
const [groupOverlayItems, setGroupOverlayItemsState] = useState<GroupOverlayItem[]>([]);
|
||||
const [childRects, setChildRectsState] = useState<OverlayRect[]>([]);
|
||||
|
||||
const overlayRectRef = useRef<OverlayRect | null>(null);
|
||||
const hoverRectRef = useRef<OverlayRect | null>(null);
|
||||
@@ -58,6 +68,7 @@ export function useDomEditOverlayRects({
|
||||
const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
|
||||
const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
|
||||
const resolvedGroupElementRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const childRectsRef = useRef<OverlayRect[]>([]);
|
||||
|
||||
const setOverlayRect = (next: OverlayRect | null) => {
|
||||
if (rectsEqual(overlayRectRef.current, next)) return;
|
||||
@@ -102,7 +113,13 @@ export function useDomEditOverlayRects({
|
||||
|
||||
const update = () => {
|
||||
frame = requestAnimationFrame(update);
|
||||
if (rafPausedRef.current) return;
|
||||
if (rafPausedRef.current) {
|
||||
if (childRectsRef.current.length > 0) {
|
||||
childRectsRef.current = [];
|
||||
setChildRectsState([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = selectionRef.current;
|
||||
const iframe = iframeRef.current;
|
||||
@@ -132,13 +149,39 @@ export function useDomEditOverlayRects({
|
||||
resolvedElementRef as ResolvedElementRef,
|
||||
);
|
||||
if (el && isElementVisibleForOverlay(el)) {
|
||||
setOverlayRect(toOverlayRect(overlayEl, iframe, el));
|
||||
const nextRect = toOverlayRect(overlayEl, iframe, el);
|
||||
setOverlayRect(nextRect);
|
||||
const descendants = el.querySelectorAll("*");
|
||||
if (descendants.length > 0 && descendants.length <= 60) {
|
||||
const nextChildRects: OverlayRect[] = [];
|
||||
for (let i = 0; i < descendants.length; i++) {
|
||||
const child = descendants[i] as HTMLElement;
|
||||
if (!child.getBoundingClientRect) continue;
|
||||
const r = toOverlayRect(overlayEl, iframe, child);
|
||||
if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
|
||||
}
|
||||
if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
|
||||
childRectsRef.current = nextChildRects;
|
||||
setChildRectsState(nextChildRects);
|
||||
}
|
||||
} else if (childRectsRef.current.length > 0) {
|
||||
childRectsRef.current = [];
|
||||
setChildRectsState([]);
|
||||
}
|
||||
} else {
|
||||
setOverlayRect(null);
|
||||
if (childRectsRef.current.length > 0) {
|
||||
childRectsRef.current = [];
|
||||
setChildRectsState([]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resolvedElementRef.current = null;
|
||||
setOverlayRect(null);
|
||||
if (childRectsRef.current.length > 0) {
|
||||
childRectsRef.current = [];
|
||||
setChildRectsState([]);
|
||||
}
|
||||
}
|
||||
|
||||
const group = groupSelectionsRef.current;
|
||||
@@ -203,5 +246,6 @@ export function useDomEditOverlayRects({
|
||||
groupOverlayItems,
|
||||
groupOverlayItemsRef,
|
||||
setGroupOverlayItems,
|
||||
childRects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,15 +57,15 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
onClick={isComplete ? handleOpen : undefined}
|
||||
className={[
|
||||
"px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
|
||||
isComplete ? "cursor-pointer hover:bg-neutral-800/30" : "",
|
||||
"px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
|
||||
isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Thumbnail — static frame; swaps to live video on hover */}
|
||||
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
|
||||
{isComplete && (
|
||||
<>
|
||||
{/* Live video — visible on hover */}
|
||||
@@ -90,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
{job.status === "rendering" && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
|
||||
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "failed" && (
|
||||
@@ -108,11 +108,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate">
|
||||
<span className="text-[11px] font-medium text-panel-text-2 truncate">
|
||||
{job.filename}
|
||||
</span>
|
||||
{job.durationMs && (
|
||||
<span className="text-[9px] text-neutral-600 flex-shrink-0">
|
||||
<span className="text-[9px] text-panel-text-5 flex-shrink-0">
|
||||
{formatDuration(job.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
@@ -121,12 +121,12 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
{job.status === "rendering" && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
|
||||
<span className="text-[9px] font-mono text-studio-accent">{job.progress}%</span>
|
||||
<span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
|
||||
<span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-studio-accent rounded-full transition-all duration-300"
|
||||
className="h-full bg-panel-accent rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -138,57 +138,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
|
||||
{job.status !== "rendering" && (
|
||||
<span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
|
||||
<span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{hovered && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isComplete && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
{/* Actions — always visible to prevent layout shifts */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={isComplete ? handleDownload : undefined}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isComplete
|
||||
? "text-panel-text-5 hover:text-panel-accent"
|
||||
: "text-panel-text-5/30 pointer-events-none"
|
||||
}`}
|
||||
title={isComplete ? "Download" : "Rendering..."}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user