mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-06 02:01:07 +00:00
feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI - Highlight clips visible at the playhead in the primary color; others share one neutral color - Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels - Per-track eye toggle and a per-element hide button in the design panel - Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom - Sticky gutter so track controls stay visible while scrolling WYSIWYG visibility (data-hidden) - Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview - HTML stays the source of truth; hide state persists and round-trips on reload Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
This commit is contained in:
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
@@ -85,6 +86,7 @@ export function StudioPreviewArea({
|
||||
handleTimelineFileDrop,
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleToggleTrackHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
@@ -165,6 +167,7 @@ export function StudioPreviewArea({
|
||||
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
||||
// anim in the keyframe's property group, falling back to the first keyframed one.
|
||||
const resolveKeyframeTarget = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(pct: number): { animId: string; tweenPct: number } | null => {
|
||||
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
||||
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
||||
@@ -182,6 +185,7 @@ export function StudioPreviewArea({
|
||||
() => ({
|
||||
onMoveElement: handleTimelineElementMove,
|
||||
onResizeElement: handleTimelineElementResize,
|
||||
onToggleTrackHidden: handleToggleTrackHidden,
|
||||
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
||||
onSplitElement: handleTimelineElementSplit,
|
||||
onRazorSplit: handleRazorSplit,
|
||||
@@ -210,6 +214,7 @@ export function StudioPreviewArea({
|
||||
// drop past the boundary (last keyframe past the end, first before the start)
|
||||
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
||||
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
||||
// fallow-ignore-next-line complexity
|
||||
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
||||
const target = resolveKeyframeTarget(fromClipPct);
|
||||
const sel = domEditSelection;
|
||||
@@ -280,6 +285,7 @@ export function StudioPreviewArea({
|
||||
[
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleToggleTrackHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface StudioRightPanelProps {
|
||||
kind: EditHistoryKind;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -78,6 +79,7 @@ export function StudioRightPanel({
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
recordEdit,
|
||||
onToggleElementHidden,
|
||||
}: StudioRightPanelProps) {
|
||||
const {
|
||||
rightWidth,
|
||||
@@ -350,6 +352,7 @@ export function StudioRightPanel({
|
||||
multiSelectCount={domEditGroupSelections.length}
|
||||
copiedAgentPrompt={copiedAgentPrompt}
|
||||
onClearSelection={clearDomSelection}
|
||||
onToggleElementHidden={onToggleElementHidden}
|
||||
onUngroup={handleUngroupSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import {
|
||||
buildInsetClipPathSides,
|
||||
buildStrokeStyleUpdates,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
parseInsetClipPathSides,
|
||||
setCssFilterFunctionPx,
|
||||
} from "./PropertyPanel";
|
||||
import { isSelectedElementHidden } from "./propertyPanelHelpers";
|
||||
|
||||
describe("PropertyPanel style helpers", () => {
|
||||
it("normalizes bounded pixel values without accepting incompatible units", () => {
|
||||
@@ -113,3 +115,26 @@ describe("PropertyPanel style helpers", () => {
|
||||
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSelectedElementHidden", () => {
|
||||
it("reads hidden state by selected timeline id or key", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "visible", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
{ id: "hidden", tag: "div", start: 0, duration: 1, track: 0, hidden: true },
|
||||
{
|
||||
id: "keyed-hidden",
|
||||
key: "scene.html:#keyed-hidden",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isSelectedElementHidden(elements, null)).toBe(false);
|
||||
expect(isSelectedElementHidden(elements, "visible")).toBe(false);
|
||||
expect(isSelectedElementHidden(elements, "hidden")).toBe(true);
|
||||
expect(isSelectedElementHidden(elements, "scene.html:#keyed-hidden")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Move } from "../../icons/SystemIcons";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
||||
import { useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
RESPONSIVE_GRID,
|
||||
readGsapRuntimeValuesForPanel,
|
||||
readGsapBorderRadiusForPanel,
|
||||
isSelectedElementHidden,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
||||
@@ -67,6 +69,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onAskAgent: _onAskAgent,
|
||||
onToggleElementHidden,
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
onImportFonts,
|
||||
@@ -106,6 +109,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const storeTime = usePlayerStore((s) => s.currentTime);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const timelineElements = usePlayerStore((s) => s.elements);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
|
||||
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
|
||||
const liveTimeRef = useRef(storeTime);
|
||||
const [, forceRender] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -288,13 +295,32 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
||||
</div>
|
||||
<InspectorHeaderActions
|
||||
element={element}
|
||||
copied={clipboardCopied}
|
||||
onCopy={handleCopyElementInfo}
|
||||
onClear={onClearSelection}
|
||||
onUngroup={onUngroup}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedElementId && onToggleElementHidden && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visibilityToggleLabel}
|
||||
title={visibilityToggleLabel}
|
||||
onClick={() => {
|
||||
void onToggleElementHidden(selectedElementId, !selectedElementHidden);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
>
|
||||
{selectedElementHidden ? (
|
||||
<EyeSlash size={13} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={13} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<InspectorHeaderActions
|
||||
element={element}
|
||||
copied={clipboardCopied}
|
||||
onCopy={handleCopyElementInfo}
|
||||
onClear={onClearSelection}
|
||||
onUngroup={onUngroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -28,6 +28,7 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
|
||||
|
||||
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function hasVisualPresence(el: HTMLElement): boolean {
|
||||
const win = el.ownerDocument.defaultView;
|
||||
if (!win) return false;
|
||||
@@ -236,9 +237,13 @@ export function isLargeRasterDomEditSelection(
|
||||
|
||||
// ─── Element finders ──────────────────────────────────────────────────────────
|
||||
|
||||
type FindElementSelection = Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex"> & {
|
||||
sourceFile?: string;
|
||||
};
|
||||
|
||||
export function findElementForSelection(
|
||||
doc: Document,
|
||||
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
selection: FindElementSelection,
|
||||
activeCompositionPath: string | null = null,
|
||||
): HTMLElement | null {
|
||||
if (selection.hfId) {
|
||||
@@ -259,6 +264,7 @@ export function findElementForSelection(
|
||||
|
||||
if (!selection.selector) return null;
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
|
||||
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
@@ -270,6 +276,7 @@ export function findElementForSelection(
|
||||
return matches[selection.selectorIndex] ?? null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { roundRotationAngle } from "./manualEditsParsing";
|
||||
import { applyStudioMotionFromDom } from "./studioMotion";
|
||||
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
||||
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
||||
|
||||
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
||||
let studioManualEditGestureId = 0;
|
||||
@@ -162,24 +163,6 @@ export function restoreInlineDisplay(element: HTMLElement): void {
|
||||
}
|
||||
|
||||
/* ── Translate helpers ────────────────────────────────────────────── */
|
||||
function splitTopLevelWhitespace(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const char of value.trim()) {
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(char) && depth === 0) {
|
||||
if (current) parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function composeTranslateValue(element: HTMLElement, x: string, y: string): string {
|
||||
const original = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR)?.trim();
|
||||
if (!original || original === "none") return `${x} ${y}`;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function splitTopLevelWhitespace(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
// fallow-ignore-next-line code-duplication
|
||||
let current = "";
|
||||
for (const char of value.trim()) {
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(char) && depth === 0) {
|
||||
if (current) parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
|
||||
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
|
||||
export type {
|
||||
@@ -18,6 +19,16 @@ export function stripQueryAndHash(value: string): string {
|
||||
return value.slice(0, Math.min(queryIndex, hashIndex));
|
||||
}
|
||||
|
||||
export function isSelectedElementHidden(
|
||||
elements: readonly TimelineElement[],
|
||||
selectedElementId: string | null,
|
||||
): boolean {
|
||||
if (!selectedElementId) return false;
|
||||
return (
|
||||
elements.find((element) => (element.key ?? element.id) === selectedElementId)?.hidden === true
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Font types & constants (shared by font and section modules) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface PropertyPanelProps {
|
||||
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
onAskAgent: () => void;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
|
||||
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
||||
fontAssets?: ImportedFontAsset[];
|
||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||
|
||||
Reference in New Issue
Block a user