From f925bf60e52ccc48de961b22783213f80602a0b3 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 11:55:16 -0700 Subject: [PATCH] feat(studio): add FlatTimingRow for the flat Motion group --- .../propertyPanelFlatMotionSection.test.tsx | 104 ++++++++++++++++++ .../editor/propertyPanelFlatMotionSection.tsx | 81 ++++++++++++++ .../editor/propertyPanelTimingSection.tsx | 2 +- 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx new file mode 100644 index 000000000..e1d87ed3c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTimingRow } from "./propertyPanelFlatMotionSection"; +import type { DomEditSelection } from "./domEditing"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function baseElement(overrides: Partial = {}): DomEditSelection { + return { + element: document.createElement("div"), + id: "hero", + selector: "#hero", + label: "Hero", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 100, height: 100 }, + textContent: "", + dataAttributes: { start: "8", duration: "4" }, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +describe("FlatTimingRow", () => { + it("renders Start, End, and Duration from the element's data attributes", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Start"); + expect(host.textContent).toContain("End"); + expect(host.textContent).toContain("Duration"); + // Values render inside s (CommitField), not as text nodes, so they + // don't show up in textContent — assert on the rendered input values, + // in the same Start/End/Duration order the row is built in. + const inputs = host.querySelectorAll("input"); + expect(inputs[0]?.value).toBe("8.00s"); + expect(inputs[1]?.value).toBe("12.00s"); + expect(inputs[2]?.value).toBe("4.00s"); + act(() => root.unmount()); + }); + + it("shows the inferred note when duration is derived from animations, not authored", () => { + const onSetAttribute = vi.fn(); + const element = baseElement({ dataAttributes: { start: "0", duration: "0" } }); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Inferred"); + act(() => root.unmount()); + }); + + it("commits a Start edit through onSetAttribute", () => { + const onSetAttribute = vi.fn(); + const { host, root } = renderInto( + , + ); + const startInput = host.querySelectorAll("input")[0]; + if (!startInput) throw new Error("expected a Start input"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(startInput, "10s"); + startInput.dispatchEvent(new Event("input", { bubbles: true })); + startInput.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onSetAttribute).toHaveBeenCalledWith("start", "10.00"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx new file mode 100644 index 000000000..8312f1ae2 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -0,0 +1,81 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./domEditing"; +import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; +import { parseTimingValue } from "./propertyPanelTimingSection"; +import { CommitField } from "./propertyPanelPrimitives"; + +function deriveTimingFromAnimations( + animations: GsapAnimation[], +): { start: number; duration: number } | null { + let lo = Infinity; + let hi = -Infinity; + for (const a of animations) { + const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); + const d = a.duration ?? 0; + lo = Math.min(lo, s); + hi = Math.max(hi, s + d); + } + if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null; + return { start: lo, duration: hi - lo }; +} + +export function FlatTimingRow({ + element, + animations = [], + onSetAttribute, +}: { + element: DomEditSelection; + animations?: GsapAnimation[]; + onSetAttribute: (attr: string, value: string) => void | Promise; +}) { + const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; + const explicitDuration = + Number.parseFloat( + element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0", + ) || 0; + + const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations); + const start = derived ? derived.start : explicitStart; + const duration = derived ? derived.duration : explicitDuration; + const end = start + duration; + + const commitStart = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null) return; + void onSetAttribute("start", parsed.toFixed(2)); + }; + + const commitDuration = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= 0) return; + void onSetAttribute("duration", parsed.toFixed(2)); + }; + + const commitEnd = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= start) return; + void onSetAttribute("duration", (parsed - start).toFixed(2)); + }; + + const cell = (label: string, value: string, onCommit: (next: string) => void) => ( +
+ {label} + + + +
+ ); + + return ( +
+ {cell("Start", formatTimingValue(start), commitStart)} + {cell("End", formatTimingValue(end), commitEnd)} + {cell("Duration", formatTimingValue(duration), commitDuration)} + {derived && ( +

+ Inferred from this element's animation — edit to pin an explicit clip range. +

+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx index 4b6f6adc9..0b71bc551 100644 --- a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx @@ -4,7 +4,7 @@ import type { DomEditSelection } from "./domEditing"; import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { MetricField, Section } from "./propertyPanelPrimitives"; -function parseTimingValue(input: string): number | null { +export function parseTimingValue(input: string): number | null { const cleaned = input.replace(/s$/i, "").trim(); const parsed = Number.parseFloat(cleaned); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;