mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-06 02:01:07 +00:00
* feat(studio): add Layers panel as new inspector tab Adds a dedicated Layers tab alongside Design and Renders in the right panel inspector. The panel shows the full composition element tree with collapsible hierarchy — clicking a layer selects it without navigating away from the tree view. Closes #783 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(studio): add visual element previews to Layers panel Each layer row now shows a small color/content preview thumbnail: - Text elements show a snippet of their content in the actual font color - Container elements show their background color as a colored swatch - Image elements show a tiny thumbnail of the image - Media elements show an icon indicator Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(studio): add hover-to-highlight and auto-seek to Layers panel Replace tiny preview thumbnails with hover highlighting — hovering a layer row highlights the element in the preview canvas. Clicking a layer auto-seeks the playhead to that element's start time in the timeline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): layers panel tab order, autoseek, and renders toolbar overflow - Reorder inspector tabs to Design → Layers → Renders - Fix autoseek: walk DOM ancestors when selected element has no direct timeline match, so clicking a child like S2 Heading correctly seeks to the start of its parent scene - Fix Renders toolbar overflow: add flex-wrap to the export controls header so selects and the Export button wrap instead of clipping * fix(studio): seek to midpoint of element duration in layers panel autoseek * fix(studio): layers panel seek now drives adapter.seek via requestSeek signal setCurrentTime() only updated the store — adapter.seek() and liveTime.notify() were never called so the iframe never moved. Add requestedSeekTime to the player store; useTimelinePlayer subscribes and calls the real seek() path when it fires. * feat(studio): hover over a layer auto-seeks to element midpoint (300ms debounce) * feat(studio): add collapsible sections to Design panel Section component now supports collapse/expand with a chevron toggle. Text, Layout, and Fill sections stay expanded by default. Less-used sections (Flex, Radius, Stroke, Effects, Clip, Transparency) start collapsed to reduce scrolling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): remove stale selectedTimelineElement usages dropped in rebase * fix(studio): stabilize resetErrors in useConsoleErrorCapture to break render loop resetErrors was a new function object on every render. handlePreviewIframeRef had it as a dep, so it also changed every render. NLELayout's useEffect watching onIframeRef would re-fire, calling setPreviewIframe again, which re-ran useConsoleErrorCapture with the new iframe — infinite loop. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
|
|
|
|
export function CommitField({
|
|
value,
|
|
disabled,
|
|
liveCommit,
|
|
onCommit,
|
|
}: {
|
|
value: string;
|
|
disabled?: boolean;
|
|
liveCommit?: boolean;
|
|
onCommit: (nextValue: string) => void;
|
|
}) {
|
|
const [draft, setDraft] = useState(value);
|
|
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const valueRef = useRef(value);
|
|
const draftRef = useRef(draft);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
valueRef.current = value;
|
|
draftRef.current = draft;
|
|
|
|
useEffect(() => {
|
|
setDraft(value);
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
const el = inputRef.current;
|
|
if (!el) return;
|
|
const handler = (e: WheelEvent) => {
|
|
if (disabled) return;
|
|
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
|
|
if (delta === 0) return;
|
|
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
|
|
if (!nextDraft) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDraft(nextDraft);
|
|
scheduleCommitRef.current(nextDraft);
|
|
};
|
|
el.addEventListener("wheel", handler, { passive: false });
|
|
return () => el.removeEventListener("wheel", handler);
|
|
}, [disabled]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const commitDraft = (nextDraft: string) => {
|
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
};
|
|
|
|
const scheduleCommit = (nextDraft: string) => {
|
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
commitTimerRef.current = setTimeout(() => {
|
|
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
}, 120);
|
|
};
|
|
const scheduleCommitRef = useRef(scheduleCommit);
|
|
scheduleCommitRef.current = scheduleCommit;
|
|
|
|
return (
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={draft}
|
|
disabled={disabled}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (liveCommit) scheduleCommit(e.target.value);
|
|
}}
|
|
onBlur={() => commitDraft(draft)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
(e.target as HTMLInputElement).blur();
|
|
return;
|
|
}
|
|
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
|
|
if (!nextDraft) return;
|
|
e.preventDefault();
|
|
setDraft(nextDraft);
|
|
scheduleCommit(nextDraft);
|
|
}}
|
|
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
|
|
className="min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
/>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* MetricField */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function MetricField({
|
|
label,
|
|
value,
|
|
disabled,
|
|
liveCommit,
|
|
scrub,
|
|
onCommit,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
disabled?: boolean;
|
|
liveCommit?: boolean;
|
|
scrub?: boolean;
|
|
onCommit: (nextValue: string) => void;
|
|
}) {
|
|
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
|
|
|
|
const handleScrubPointerDown = useCallback(
|
|
(e: React.PointerEvent<HTMLSpanElement>) => {
|
|
if (disabled || !scrub) return;
|
|
const parsed = parseFloat(value);
|
|
if (!Number.isFinite(parsed)) return;
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
scrubRef.current = { startX: e.clientX, startValue: parsed, pointerId: e.pointerId };
|
|
},
|
|
[disabled, scrub, value],
|
|
);
|
|
|
|
const handleScrubPointerMove = useCallback(
|
|
(e: React.PointerEvent<HTMLSpanElement>) => {
|
|
const state = scrubRef.current;
|
|
if (!state) return;
|
|
const delta = e.clientX - state.startX;
|
|
onCommit(String(Math.round(state.startValue + delta)));
|
|
},
|
|
[onCommit],
|
|
);
|
|
|
|
const handleScrubPointerUp = useCallback(() => {
|
|
scrubRef.current = null;
|
|
}, []);
|
|
|
|
const scrubProps =
|
|
scrub && !disabled
|
|
? ({
|
|
className:
|
|
"flex-shrink-0 text-[11px] font-medium text-neutral-500 cursor-ew-resize select-none",
|
|
onPointerDown: handleScrubPointerDown,
|
|
onPointerMove: handleScrubPointerMove,
|
|
onPointerUp: handleScrubPointerUp,
|
|
} as const)
|
|
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
|
|
|
|
return (
|
|
<div className={FIELD}>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span {...scrubProps}>{label}</span>
|
|
<CommitField
|
|
value={value}
|
|
disabled={disabled}
|
|
liveCommit={liveCommit}
|
|
onCommit={onCommit}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Simple field components */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function DetailField({
|
|
label,
|
|
value,
|
|
disabled,
|
|
onCommit,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
disabled?: boolean;
|
|
onCommit: (nextValue: string) => void;
|
|
}) {
|
|
return (
|
|
<label className="grid min-w-0 gap-1.5">
|
|
<span className={LABEL}>{label}</span>
|
|
<div className={FIELD}>
|
|
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
|
|
</div>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function SliderControl({
|
|
value,
|
|
min,
|
|
max,
|
|
step,
|
|
displayValue,
|
|
formatDisplayValue,
|
|
disabled,
|
|
onCommit,
|
|
}: {
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
displayValue: string;
|
|
formatDisplayValue?: (nextValue: number) => string;
|
|
disabled?: boolean;
|
|
onCommit: (nextValue: number) => void;
|
|
}) {
|
|
const [draft, setDraft] = useState(value);
|
|
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const valueRef = useRef(value);
|
|
valueRef.current = value;
|
|
|
|
useEffect(() => {
|
|
setDraft(value);
|
|
}, [value]);
|
|
useEffect(
|
|
() => () => {
|
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const commitDraft = (nextDraft: number) => {
|
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
};
|
|
const scheduleCommit = (nextDraft: number) => {
|
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
commitTimerRef.current = setTimeout(() => {
|
|
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
}, 40);
|
|
};
|
|
|
|
return (
|
|
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={draft}
|
|
disabled={disabled}
|
|
onChange={(e) => {
|
|
const n = Number(e.target.value);
|
|
setDraft(n);
|
|
scheduleCommit(n);
|
|
}}
|
|
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"
|
|
/>
|
|
<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)]">
|
|
{formatDisplayValue?.(draft) ?? displayValue}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SegmentedControl({
|
|
options,
|
|
value,
|
|
disabled,
|
|
onChange,
|
|
}: {
|
|
options: Array<{ label: string; value: string }>;
|
|
value: string;
|
|
disabled?: boolean;
|
|
onChange: (nextValue: string) => void;
|
|
}) {
|
|
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)]"
|
|
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
|
>
|
|
{options.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
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 ${
|
|
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"
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SelectField({
|
|
label,
|
|
value,
|
|
disabled,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
disabled?: boolean;
|
|
options: string[];
|
|
onChange: (nextValue: string) => void;
|
|
}) {
|
|
const renderedOptions = value && !options.includes(value) ? [value, ...options] : options;
|
|
return (
|
|
<label className={`${FIELD} flex items-center gap-3`}>
|
|
<span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">{label}</span>
|
|
<select
|
|
value={value}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
>
|
|
{renderedOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function Section({
|
|
title,
|
|
icon,
|
|
children,
|
|
accessory,
|
|
defaultCollapsed = false,
|
|
}: {
|
|
title: string;
|
|
icon: ReactNode;
|
|
children: ReactNode;
|
|
accessory?: ReactNode;
|
|
defaultCollapsed?: boolean;
|
|
}) {
|
|
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
|
|
return (
|
|
<section className="min-w-0 border-t border-neutral-800/80">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCollapsed((v) => !v)}
|
|
className="flex w-full items-center justify-between gap-2 px-4 py-3"
|
|
>
|
|
<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>
|
|
<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>
|
|
</div>
|
|
</button>
|
|
{!collapsed && <div className="px-4 pb-4">{children}</div>}
|
|
</section>
|
|
);
|
|
}
|