diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7c85694fc3..35bd7fd563 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -313,6 +313,28 @@ "file": "packages/core/src/figma/manifest.ts", "exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"], }, + // STUDIO_FLAT_INSPECTOR_ENABLED: exported for use by downstream studio + // inspector redesign tasks; consumed by components in later PRs. + { + "file": "packages/studio/src/components/editor/manualEditingAvailability.ts", + "exports": ["STUDIO_FLAT_INSPECTOR_ENABLED"], + }, + // TextAreaField: newly exported for FlatTextSection (flat inspector + // redesign, Task 8), which lands in a later commit on this branch. + { + "file": "packages/studio/src/components/editor/propertyPanelSections.tsx", + "exports": ["TextAreaField"], + }, + // Link: its only consumer was FlatRadiusRow's uniform-only fallback row in + // propertyPanelFlatStyleSections.tsx, deleted by the Style parity fix + // (p8-task-style-parity) — that row was unreachable from a uniform radius + // and is now replaced by BorderRadiusEditor's own unlink toggle. Kept in + // the icon set for future reuse rather than deleted from a file outside + // this fix's scope. + { + "file": "packages/studio/src/icons/SystemIcons.tsx", + "exports": ["Link"], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, @@ -483,6 +505,18 @@ // scopeRootSelectors handling shifts lines and re-flags both. "packages/core/src/compiler/inlineSubCompositions.ts", "packages/core/src/compiler/compositionScoping.test.ts", + // Studio flat-inspector redesign (Plans 2-4): each Flat*Section test file + // repeats the same renderInto/pointerdown-drag/reset-click scaffold as its + // sibling group's tests, added task-by-task across separate PRs on this + // branch. Pre-existing relative to Grade group (Plan 5) work; consistent + // with the norm above of leaving parallel arrange/act/assert test cases + // unabstracted where each case verifies a distinct control's behavior. + "packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx", + "packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx", + "packages/studio/src/components/editor/propertyPanelMediaSection.tsx", + "packages/studio/src/components/editor/PropertyPanel.test.tsx", + "packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx", + "packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx", ], }, "health": { @@ -660,6 +694,10 @@ "packages/core/src/compiler/inlineSubCompositions.ts", "packages/core/src/compiler/htmlBundler.ts", "packages/core/src/runtime/compositionLoader.ts", + // TextFieldEditor: pre-existing complexity from earlier Text-inspector + // work on this same branch (commits 444639d75, b57b31beb, 6f2e9848c, + // eba8a0fa2), unrelated to the Grade group (Plan 5) currently landing. + "packages/studio/src/components/editor/propertyPanelSections.tsx", ], }, } diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 652876c2bb..651b5e3c7e 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -38,6 +38,7 @@ import { type ColorGradingScope, } from "./studioColorGradingScope"; import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes"; +import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers"; const MIN_INSPECTOR_SPLIT_PERCENT = 20; const MAX_INSPECTOR_SPLIT_PERCENT = 75; @@ -63,7 +64,7 @@ export interface StudioRightPanelProps { kind: EditHistoryKind; files: Record; }) => Promise; - onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void; + onToggleElementHidden?: ToggleHiddenHandler; } // fallow-ignore-next-line complexity @@ -109,6 +110,7 @@ export function StudioRightPanel({ copiedAgentPrompt, clearDomSelection, handleUngroupSelection, + handleGroupSelection, handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, @@ -342,6 +344,11 @@ export function StudioRightPanel({ [projectId, refreshFileTree, showToast], ); + const handleHideAllSelected = () => { + const { elements } = usePlayerStore.getState(); + const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath); + if (keys.length > 0) void onToggleElementHidden?.(keys, true); + }; const propertyPanel = ( 1 ? null : domEditSelection} @@ -359,6 +366,9 @@ export function StudioRightPanel({ assets={assets} element={domEditGroupSelections.length > 1 ? null : domEditSelection} multiSelectCount={domEditGroupSelections.length} + multiSelectedElements={domEditGroupSelections} + onGroupSelection={handleGroupSelection} + onHideAllSelected={handleHideAllSelected} copiedAgentPrompt={copiedAgentPrompt} onClearSelection={clearDomSelection} onToggleElementHidden={onToggleElementHidden} @@ -446,8 +456,6 @@ export function StudioRightPanel({ return ( <> - {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via - the absolutely-positioned inner hit area. */}
- {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
- {/* Visible hairline */}

Inspector is unavailable right now — select the Design or Layers pane above, or diff --git a/packages/studio/src/components/editor/AnimationCard.test.tsx b/packages/studio/src/components/editor/AnimationCard.test.tsx new file mode 100644 index 0000000000..63562fe734 --- /dev/null +++ b/packages/studio/src/components/editor/AnimationCard.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AnimationCard } from "./AnimationCard"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function baseAnimation(overrides: Partial = {}): GsapAnimation { + return { + id: "anim-1", + method: "to", + position: 0.8, + duration: 1.2, + ease: "power2.out", + properties: { opacity: 1 }, + ...overrides, + } as GsapAnimation; +} + +const noop = () => {}; + +describe("AnimationCard flat branch", () => { + it("renders a mint border-left and panel-token colors when flat", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const card = host.querySelector('[data-flat-effect-card="true"]'); + expect(card).not.toBeNull(); + expect(card?.className).toContain("border-panel-accent"); + act(() => root.unmount()); + }); + + it("still renders the legacy (non-flat) appearance when flat is omitted", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull(); + expect(host.textContent).toContain("power2.out"); + act(() => root.unmount()); + }); + + it("toggles expanded state when the collapsed header button is clicked, in both modes", () => { + for (const flat of [false, true]) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).not.toContain("Remove"); + const button = host.querySelector("button"); + expect(button).not.toBeNull(); + act(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(host.textContent).toContain("Remove"); + act(() => root.unmount()); + } + }); + + it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => { + const onDeleteAnimation = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const buttons = Array.from(host.querySelectorAll("button")); + const removeButton = buttons.find((b) => b.textContent === "Remove"); + expect(removeButton).not.toBeUndefined(); + act(() => { + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onDeleteAnimation).toHaveBeenCalledWith("anim-1"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 0a0a327e7a..97f8efa404 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -21,12 +21,14 @@ import { interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; + flat?: boolean; } // fallow-ignore-next-line complexity export const AnimationCard = memo(function AnimationCard({ animation, defaultExpanded, + flat, onUpdateProperty, onUpdateMeta, onDeleteAnimation, @@ -150,7 +152,14 @@ export const AnimationCard = memo(function AnimationCard({ ); return ( -

+
+
+
+ {multiSelectedElements.map((element) => { + const { glyph, className } = elementKindGlyph(element); + return ( + + + {glyph} + + + {element.label} + + + {element.id ? `#${element.id}` : element.selector} + + + ); + })} +
+
+ + +
+ + Select a single element to edit its properties + +
+ ); +} + +export function PropertyPanelEmptyState({ + multiSelectCount, + flat, + multiSelectedElements, + onGroupSelection, + onHideAllSelected, + onClearSelection, +}: { + multiSelectCount: number; + flat?: boolean; + multiSelectedElements?: DomEditSelection[]; + onGroupSelection?: () => void; + onHideAllSelected?: () => void; + onClearSelection?: () => void; +}) { + if (flat) { + return multiSelectCount > 1 ? ( + + ) : ( + + ); + } -export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) { return (
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx new file mode 100644 index 0000000000..90d7ed8698 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -0,0 +1,565 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { resolveEditingSections } from "@hyperframes/core/editing"; +import type { DomEditSelection } from "./domEditing"; +import { isTextEditableSelection } from "./domEditing"; +import type { PropertyPanelProps } from "./propertyPanelHelpers"; +import { formatPxMetricValue } from "./propertyPanelHelpers"; +import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; +import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; +import { FlatGroupHeader } from "./propertyPanelFlatPrimitives"; +import { FlatTextSection } from "./propertyPanelFlatTextSection"; +import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; +import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; +import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; +import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; +import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; +import { createGsapLivePreview } from "./gsapLivePreview"; +import { formatTextFieldPreview } from "./propertyPanelSections"; +import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; +import { useColorGradingController } from "./useColorGradingController"; +import { + FlatColorGradingAccessory, + FlatColorGradingSection, +} from "./propertyPanelFlatColorGradingSection"; + +type EditingSections = ReturnType; + +type FlatGroupDescriptor = { + id: string; + title: string; + summary?: string; + accessory?: ReactNode; + content: ReactNode; +}; + +// Type-only fallback for the Motion effect-card callbacks. Used solely to +// satisfy FlatMotionSection's required-callback shape when the effect list is +// gated off (showEffects === false, so none of these are ever invoked). Keeps +// the gated-off path free of `!` non-null assertions — the real, narrowed +// handlers flow through only when the double-gate below passes. +const EMPTY_GSAP_EFFECT_HANDLERS = { + onAddAnimation: () => {}, + onUpdateProperty: () => {}, + onUpdateMeta: () => {}, + onDeleteAnimation: () => {}, + onAddProperty: () => {}, + onRemoveProperty: () => {}, +}; + +/** + * The flat "Ledger" inspector shell (design_handoff_studio_inspector). + * + * Extracted from PropertyPanel so that file stays under the 600-LOC gate + * (same one-directional-import precedent as FlatTextSection). Rendered only + * when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state. + * + * The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion. + */ +// fallow-ignore-next-line complexity +export function PropertyPanelFlat({ + element, + styles, + sections, + sourceLabel, + gsapAnimations = [], + gsapBorderRadius, + fontAssets = [], + showEditableSections, + selectedElementHidden, + selectedElementId, + clipboardCopied, + onCopyElementInfo, + projectId, + projectDir, + assets, + previewIframeRef, + onClearSelection, + onUngroup, + onSetStyle, + onSetAttribute, + onSetAttributeLive, + onApplyColorGradingScope, + onSetHtmlAttribute, + onRemoveBackground, + onSetText, + onSetTextFieldStyle, + onAddTextField, + onRemoveTextField, + onAskAgent, + onToggleElementHidden, + onImportAssets, + onImportFonts, + recordingState, + recordingDuration, + onToggleRecording, + displayX, + displayY, + displayW, + displayH, + displayR, + manualOffsetEditingDisabled, + manualSizeEditingDisabled, + manualRotationEditingDisabled, + commitManualOffset, + commitManualSize, + commitManualRotation, + gsapAnimId, + navKeyframes, + currentTime, + animIdForProp, + gsapRuntimeValues, + // Renamed: PropertyPanel.tsx still computes/passes these for its own legacy + // (non-flat) panel, but the flat path recomputes its own basis below via + // deriveElementTiming so it agrees with Motion's Timing row — ignore the + // parent's naive `elDuration ?? 1` fallback. + elStart: _elStart, + elDuration: _elDuration, + onCommitAnimatedProperty, + onCommitAnimatedProperties, + onSeekToTime, + onRemoveKeyframe, + onConvertToKeyframes, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, + onUpdateGsapProperty, + onUpdateGsapMeta, + onDeleteGsapAnimation, + onAddGsapProperty, + onRemoveGsapProperty, + onUpdateGsapFromProperty, + onAddGsapFromProperty, + onRemoveGsapFromProperty, + onAddGsapAnimation, + onSetArcPath, + onUpdateArcSegment, + onUnroll, + onUpdateKeyframeEase, + onSetAllKeyframeEases, +}: Pick< + PropertyPanelProps, + | "projectId" + | "projectDir" + | "assets" + | "previewIframeRef" + | "onClearSelection" + | "onUngroup" + | "onSetStyle" + | "onSetAttribute" + | "onSetAttributeLive" + | "onApplyColorGradingScope" + | "onSetHtmlAttribute" + | "onRemoveBackground" + | "onSetText" + | "onSetTextFieldStyle" + | "onAddTextField" + | "onRemoveTextField" + | "onAskAgent" + | "onToggleElementHidden" + | "onImportAssets" + | "onImportFonts" + | "fontAssets" + | "gsapAnimations" + | "gsapMultipleTimelines" + | "gsapUnsupportedTimelinePattern" + | "onUpdateGsapProperty" + | "onUpdateGsapMeta" + | "onDeleteGsapAnimation" + | "onAddGsapProperty" + | "onRemoveGsapProperty" + | "onUpdateGsapFromProperty" + | "onAddGsapFromProperty" + | "onRemoveGsapFromProperty" + | "onAddGsapAnimation" + | "onSetArcPath" + | "onUpdateArcSegment" + | "onUnroll" + | "onUpdateKeyframeEase" + | "onSetAllKeyframeEases" + | "recordingState" + | "recordingDuration" + | "onToggleRecording" +> & + // Layout-group values (Plan 3a Task 5). All are derived locals or handlers in + // PropertyPanel; compose their exact shapes from FlatLayoutSection's own props + // via Pick so a signature change there propagates here instead of drifting. + Pick< + Parameters[0], + | "displayX" + | "displayY" + | "displayW" + | "displayH" + | "displayR" + | "manualOffsetEditingDisabled" + | "manualSizeEditingDisabled" + | "manualRotationEditingDisabled" + | "commitManualOffset" + | "commitManualSize" + | "commitManualRotation" + | "gsapAnimId" + | "navKeyframes" + | "animIdForProp" + | "gsapRuntimeValues" + | "elStart" + | "elDuration" + | "onCommitAnimatedProperty" + | "onCommitAnimatedProperties" + | "onSeekToTime" + | "onRemoveKeyframe" + | "onConvertToKeyframes" + > & { + element: DomEditSelection; + styles: Record; + sections: EditingSections; + sourceLabel: string; + gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null; + showEditableSections: boolean; + selectedElementHidden: boolean; + selectedElementId: string | null; + clipboardCopied: boolean; + onCopyElementInfo: () => void; + currentTime: number; + }) { + // Lazy initializer: pick whichever group actually renders for this element + // (Text if text-editable, else Style if style-editable, else none open) so a + // style-only element doesn't start with everything collapsed. Only runs on + // mount — PropertyPanel.tsx keys by element identity so + // switching the selection re-mounts this component and re-derives the + // default instead of preserving stale state across unrelated elements. + const [openGroupId, setOpenGroupId] = useState(() => + isTextEditableSelection(element) + ? "text" + : showEditableSections + ? "style" + : sections.media + ? "media" + : "layout", + ); + + // Tracks which group(s) are actively transitioning this toggle cycle, so + // their header/body gets the fast entrance animation (hf-flat-group-enter) + // and no one else's does. Deliberately NOT derived from remounting alone: + // FlatGroupHeader instances are keyed by group id and React normally + // preserves them across re-renders, but toggling a non-adjacent group still + // shifts the untouched collapsed siblings between the before/after-open + // slices below, and Chromium restarts a CSS animation on that kind of + // position shift even though nothing about the sibling actually changed. + // Gating on these ids (cleared shortly after the 120ms CSS animation + // finishes) keeps the animation scoped to only the groups that actually + // just toggled. Two ids, not one: the clicked (newly-opening/closing) group + // AND whichever group was open immediately before the click and got + // implicitly closed by it — both freshly-mounted headers need to animate. + const [justToggledIds, setJustToggledIds] = useState([]); + const justToggledTimeoutRef = useRef | null>(null); + useEffect(() => { + return () => { + if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current); + }; + }, []); + + // Grade group state. Called unconditionally (React rules-of-hooks) even when + // sections.colorGrading is false — unlike the legacy ColorGradingSection, + // which is only mounted when the section is active, PropertyPanelFlat is not + // remounted per-section so the hook must run every render. Shares one state + // object between the group's header accessory (compare/status/reset) and its + // body (the FlatColorGradingSection controls). + const colorGradingController = useColorGradingController({ + projectId, + element, + previewIframeRef, + onSetAttributeLive, + onApplyScope: onApplyColorGradingScope, + }); + + const isTextEditable = isTextEditableSelection(element); + const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; + const toggleOpen = (groupId: string) => { + // Capture what was open BEFORE this click (this render's closure over + // openGroupId), so the group that's about to be implicitly closed can be + // tracked too — not just the one the user clicked. + const previousOpenGroupId = openGroupId; + setOpenGroupId((current) => (current === groupId ? "" : groupId)); + const implicitlyClosedId = + previousOpenGroupId && previousOpenGroupId !== groupId ? previousOpenGroupId : null; + setJustToggledIds(implicitlyClosedId ? [groupId, implicitlyClosedId] : [groupId]); + if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current); + justToggledTimeoutRef.current = setTimeout(() => setJustToggledIds([]), 200); + }; + // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) — + // must agree with Motion's Timing row (FlatTimingRow), which infers the + // range from animations when there's no explicit data-duration. Computed + // here (not threaded from PropertyPanel) both to keep that file under its + // 600-LOC gate and because element/gsapAnimations are already in scope. + const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations); + // Trivial percentage→time seek, derived here rather than threaded from + // PropertyPanel (keeps that file under its 600-LOC gate). + const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration); + // Playhead position within the SAME corrected elStart/elDuration basis as + // seekFromKfPct above — recomputed here (not threaded as `currentPct` from + // PropertyPanel, which still derives it against its own naive basis for the + // legacy panel) so KeyframeNavigation's diamond active-state and prev/next + // arrow targeting agree with where a keyframe click actually seeks to + // (follow-up fix to 684ec4e87, which corrected the seek basis but left this + // one still naive). + const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; + + // Motion group double-gate — reproduces the legacy PropertyPanel gate exactly: + // • Timing (sections.timing) shows via resolveEditingSections, same as today. + // • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND + // all five edit handlers are present (identical to PropertyPanel's legacy + // `` guard). + // Computing the narrowed handler bundle inside the `&&`-guarded ternary lets + // TypeScript prove each handler non-undefined without a `!` assertion; the + // noop bundle only fills the type when the gate is off (never invoked, since + // FlatMotionSection guards every call behind showEffects). + const showMotionTiming = Boolean(sections.timing); + const gsapEffectHandlers = + STUDIO_GSAP_PANEL_ENABLED && + onUpdateGsapProperty && + onUpdateGsapMeta && + onDeleteGsapAnimation && + onAddGsapProperty && + onAddGsapAnimation + ? { + onAddAnimation: onAddGsapAnimation, + onUpdateProperty: onUpdateGsapProperty, + onUpdateMeta: onUpdateGsapMeta, + onDeleteAnimation: onDeleteGsapAnimation, + onAddProperty: onAddGsapProperty, + onRemoveProperty: onRemoveGsapProperty ?? (() => {}), + onUpdateFromProperty: onUpdateGsapFromProperty, + onAddFromProperty: onAddGsapFromProperty, + onRemoveFromProperty: onRemoveGsapFromProperty, + onSetArcPath, + onUpdateArcSegment, + onUnroll, + onUpdateKeyframeEase, + onSetAllKeyframeEases, + } + : null; + const showMotionEffects = gsapEffectHandlers !== null; + const showMotionGroup = showMotionTiming || showMotionEffects; + + // Ordered group descriptors — one per FlatGroup this panel renders, gated by + // the same conditions the inline JSX used. Split below into before-open/ + // open/after-open regions for the one-open accordion. + const groups: FlatGroupDescriptor[] = []; + if (isTextEditable) { + groups.push({ + id: "text", + title: "Text", + summary: formatTextFieldPreview(element.textFields[0]?.value ?? ""), + content: ( + + ), + }); + } + if (showEditableSections) { + // Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an + // invisible element must summarize as 0%, not 100%. + const opacityValue = parseFloat(styles.opacity ?? "1"); + const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100); + groups.push({ + id: "style", + title: "Style", + summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${opacityPct}%`, + content: ( + + ), + }); + } + groups.push({ + id: "layout", + title: "Layout", + // No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing + // (wheel/arrow keys only) — advertising "drag values to scrub" here lies. + summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`, + content: ( + + ), + }); + if (showMotionGroup) { + groups.push({ + id: "motion", + title: "Motion", + summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`, + content: ( + + ), + }); + } + if (sections.colorGrading) { + groups.push({ + id: "grade", + title: "Grade", + accessory: , + summary: `${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`, + content: ( + void colorGradingController.applyToScope()} + onApplyScopeAvailable={Boolean(onApplyColorGradingScope)} + mediaMetadata={colorGradingController.mediaMetadata} + /> + ), + }); + } + if (sections.media) { + groups.push({ + id: "media", + title: "Media", + summary: element.tagName, + content: ( + + ), + }); + } + + // Fixed-headers + scrollable-open-section layout (design_handoff + // scrollable-open-section, replaces the prior sticky-stacking mechanism): + // collapsed headers before/after the open group render in normal document + // flow and never move. Only the open group's own body content scrolls, in + // a dedicated region between the two fixed header stacks. When no group is + // open, every group is just a collapsed header — there's no scrollable + // middle region at all, since nothing is expanded. + const openIndex = groups.findIndex((g) => g.id === openGroupId); + const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex); + const openGroup = openIndex === -1 ? null : groups[openIndex]; + const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1); + + return ( +
+
+ ); +} diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx new file mode 100644 index 0000000000..6b9170e518 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderFooter(overrides: Partial[0]> = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + return { host, root }; +} + +describe("PropertyPanelFlatFooter", () => { + it("renders the ask-agent affordance and fires onAskAgent on click", () => { + const onAskAgent = vi.fn(); + const { host, root } = renderFooter({ onAskAgent }); + expect(host.textContent).toContain("Ask agent about this element"); + const askButton = host.querySelector('[data-flat-footer-ask="true"]'); + act(() => askButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onAskAgent).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("shows the idle record affordance and toggles recording on click", () => { + const onToggleRecording = vi.fn(); + const { host, root } = renderFooter({ recordingState: "idle", onToggleRecording }); + const recordButton = host.querySelector('[data-flat-footer-record="true"]'); + expect(recordButton?.title).toBe("Record gesture (R)"); + act(() => recordButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onToggleRecording).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("shows the recording duration while recording", () => { + const { host, root } = renderFooter({ + recordingState: "recording", + recordingDuration: 2.4, + onToggleRecording: vi.fn(), + }); + const recordButton = host.querySelector('[data-flat-footer-record="true"]'); + expect(recordButton?.title).toBe("Stop recording 2.4s"); + act(() => root.unmount()); + }); + + // Plan 10 (sticky-footer-gap): the root must carry an opaque background — + // it previously had none at all, letting scrolled panel content show + // through. Regression coverage for the definite fix from the brief. + it("has an opaque bg-panel-bg background on its root element", () => { + const { host, root } = renderFooter({ onAskAgent: vi.fn() }); + const footerRoot = host.firstElementChild as HTMLElement; + expect(footerRoot.className).toContain("bg-panel-bg"); + act(() => root.unmount()); + }); + + // Plan 11 (scrollable-open-section): the prior sticky-stacking mechanism — + // and the Plan 10 hairline-sealing hack it required at this exact boundary + // (an absolutely-positioned overlay patching a Chromium sticky-offset + // rounding gap) — is gone now that nothing above the footer is + // `position: sticky`. Live browser verification (p11 report) confirmed the + // boundary renders as a single clean hairline without it: whatever + // immediately precedes the footer (a collapsed FlatGroupHeader, or the open + // group's scrollable body wrapper) already draws its own border-b in normal + // document flow, so the footer needs no border or seal of its own. + it("renders no seal overlay and no border of its own — the boundary line comes from whatever precedes it", () => { + const { host, root } = renderFooter({ onAskAgent: vi.fn() }); + const footerRoot = host.firstElementChild as HTMLElement; + expect(footerRoot.className).not.toContain("border-t"); + expect(footerRoot.className).not.toContain("border-b"); + expect(host.querySelector('[data-flat-footer-seal="true"]')).toBeNull(); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx new file mode 100644 index 0000000000..07b2a28701 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx @@ -0,0 +1,64 @@ +export function PropertyPanelFlatFooter({ + onAskAgent, + recordingState, + recordingDuration, + onToggleRecording, +}: { + onAskAgent?: () => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; +}) { + const recording = recordingState === "recording"; + const recordTitle = recording + ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s` + : "Record gesture (R)"; + + return ( + // No border-t here: every possible element immediately above this footer + // in the new fixed-headers + scrollable-open-section layout (a collapsed + // FlatGroupHeader, or the open group's scrollable body wrapper) already + // draws its own border-b in normal document flow — nothing here is + // `position: sticky` anymore, so there's no rounding seam to seal (see + // p11-scrollable-open-section-report.md). +
+ + {onToggleRecording && ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx new file mode 100644 index 0000000000..2d467db82b --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderHeader(overrides: Partial[0]> = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const props = { + name: "Mono Label", + meta: ".mono-label · div", + elementKind: "text" as const, + hidden: false, + copied: false, + onCopy: vi.fn(), + onClear: vi.fn(), + showUngroup: false, + ...overrides, + }; + act(() => { + root.render(); + }); + return { host, root, props }; +} + +describe("PropertyPanelFlatHeader", () => { + it("renders name, meta, and the mint text-type icon", () => { + const { host, root } = renderHeader(); + expect(host.textContent).toContain("Mono Label"); + expect(host.textContent).toContain(".mono-label · div"); + const icon = host.querySelector('[data-flat-header-icon="true"]'); + expect(icon?.className).toContain("text-panel-accent"); + act(() => root.unmount()); + }); + + it("colors the media icon cyan and the other icon amber", () => { + const { host: mediaHost, root: mediaRoot } = renderHeader({ elementKind: "media" }); + expect(mediaHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain( + "text-panel-media", + ); + act(() => mediaRoot.unmount()); + + const { host: otherHost, root: otherRoot } = renderHeader({ elementKind: "other" }); + expect(otherHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain( + "text-panel-container", + ); + act(() => otherRoot.unmount()); + }); + + it("fires onCopy and onClear from their action buttons", () => { + const { host, root, props } = renderHeader(); + const copy = host.querySelector( + '[aria-label="Copy element info to clipboard"]', + ); + const clear = host.querySelector('[aria-label="Clear selection"]'); + act(() => copy?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(props.onCopy).toHaveBeenCalledTimes(1); + expect(props.onClear).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("only renders Ungroup when showUngroup is true", () => { + const { host: without } = renderHeader({ showUngroup: false }); + expect(without.querySelector('[aria-label="Ungroup"]')).toBeNull(); + + const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() }); + expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx new file mode 100644 index 0000000000..d56468eb58 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx @@ -0,0 +1,89 @@ +import { Eye, EyeSlash } from "@phosphor-icons/react"; +import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons"; + +const ICON_BY_KIND = { text: Type, media: Film, other: Square } as const; +const ICON_COLOR_BY_KIND = { + text: "text-panel-accent", + media: "text-panel-media", + other: "text-panel-container", +} as const; + +export function PropertyPanelFlatHeader({ + name, + meta, + elementKind, + hidden, + onToggleHidden, + copied, + onCopy, + onClear, + onUngroup, + showUngroup, +}: { + name: string; + meta: string; + elementKind: "text" | "media" | "other"; + hidden: boolean; + onToggleHidden?: () => void; + copied: boolean; + onCopy: () => void; + onClear: () => void; + onUngroup?: () => void; + showUngroup: boolean; +}) { + const Icon = ICON_BY_KIND[elementKind]; + const visibilityLabel = hidden ? "Show element" : "Hide element"; + + return ( +
+ +
+ {name} + {meta} +
+
+ {showUngroup && ( + + )} + {onToggleHidden && ( + + )} + + +
+
+ ); +} diff --git a/packages/studio/src/components/editor/gsapLivePreview.ts b/packages/studio/src/components/editor/gsapLivePreview.ts new file mode 100644 index 0000000000..ead64721a1 --- /dev/null +++ b/packages/studio/src/components/editor/gsapLivePreview.ts @@ -0,0 +1,34 @@ +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * Build the "live preview" callback the 3D-transform sub-view fires while a + * value is being dragged: apply a gsap.set() to the matching node inside the + * preview iframe so the edit is reflected immediately, before it's committed. + * + * Extracted so the identical closure exists once — shared by the legacy + * PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat). + */ +// Resolve by id when unique, otherwise by selector + selectorIndex — a bare +// querySelector(selector) always hits the FIRST match, so dragging on the +// second of two same-selector siblings would animate the wrong element. +function resolvePreviewNode( + doc: Document | null | undefined, + el: DomEditSelection, +): Element | null { + if (!doc) return null; + if (el.id) return doc.querySelector(`#${el.id}`); + if (!el.selector) return null; + return doc.querySelectorAll(el.selector)[el.selectorIndex ?? 0] ?? null; +} + +export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) { + return (el: DomEditSelection, props: Record) => { + const iframe = iframeRef.current; + const win = iframe?.contentWindow as + | { gsap?: { set: (t: Element, v: Record) => void } } + | null + | undefined; + const node = resolvePreviewNode(iframe?.contentDocument, el); + if (win?.gsap && node) win.gsap.set(node, props); + }; +} diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index acd1f06940..5ac2081d07 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -105,4 +105,14 @@ describe("manual editing availability", () => { expect(resolveStudioBooleanEnvFlag({ EMPTY: "" }, ["EMPTY"], true)).toBe(true); expect(resolveStudioBooleanEnvFlag({ UNKNOWN: "maybe" }, ["UNKNOWN"], false)).toBe(false); }); + + it("defaults the flat inspector flag to off and supports an explicit opt-in", async () => { + const legacy = await loadAvailabilityWithEnv({}); + expect(legacy.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false); + + const enabled = await loadAvailabilityWithEnv({ + VITE_STUDIO_FLAT_INSPECTOR_ENABLED: "true", + }); + expect(enabled.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(true); + }); }); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index b49a0523ca..2cc5887032 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -97,4 +97,14 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( true, ); +// Studio inspector redesign ("Ledger, flat" — design_handoff_studio_inspector): +// Keep the legacy panel as the default while the redesign is rolled out. +// Set VITE_STUDIO_FLAT_INSPECTOR_ENABLED=true to select the flat inspector +// without a rebuild. +export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_FLAT_INSPECTOR", "VITE_STUDIO_FLAT_INSPECTOR_ENABLED"], + false, +); + export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/components/editor/propertyPanelColor.test.tsx b/packages/studio/src/components/editor/propertyPanelColor.test.tsx new file mode 100644 index 0000000000..f49c22386c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColor.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ColorField } from "./propertyPanelColor"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("ColorField flat trigger", () => { + it("renders label and value inline with a small swatch, no boxed border", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + const trigger = host.querySelector('[data-flat-color-trigger="true"]'); + expect(trigger).not.toBeNull(); + expect(trigger?.className).not.toContain("border-neutral-800"); + expect(host.textContent).toContain("Color"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index 962f0e9172..ea6dbb6008 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -121,11 +121,13 @@ export function ColorField({ label, value, disabled, + flat, onCommit, }: { label: string; value: string; disabled?: boolean; + flat?: boolean; onCommit: (nextValue: string) => void; }) { const buttonRef = useRef(null); @@ -349,6 +351,30 @@ export function ColorField({ } }; + if (flat) { + return ( +
+ {label} + + {picker} +
+ ); + } + return (
{label} diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx index 72553582e1..d37425181e 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -1,159 +1,14 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type PointerEvent as ReactPointerEvent, - type RefObject, -} from "react"; -import { - HF_COLOR_GRADING_ATTR, - isHfColorGradingActive, - normalizeHfColorGrading, - serializeHfColorGrading, - type HfColorGradingTarget, - type NormalizedHfColorGrading, -} from "@hyperframes/core/color-grading"; +import { type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { isHfColorGradingActive } from "@hyperframes/core/color-grading"; import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons"; -import { - addStudioPendingEditFlushListener, - trackStudioPendingEdit, -} from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import { ColorGradingControls } from "./propertyPanelColorGradingControls"; -import { stripQueryAndHash } from "./propertyPanelHelpers"; import { Section } from "./propertyPanelPrimitives"; import { - acceptStudioRuntimeMessage, - postRuntimeControlMessage, -} from "../../player/lib/runtimeProtocol"; - -const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, ""); -const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const; -const MEDIA_METADATA_CACHE = new Map(); - -interface RuntimeColorGradingStatus { - state: "missing" | "inactive" | "pending" | "active" | "unavailable"; - message: string; -} - -interface MediaMetadata { - kind: "video" | "image" | "audio" | "unknown"; - color: { - dynamicRange: "hdr" | "sdr" | "unknown"; - hdrTransfer: "pq" | "hlg" | "unknown" | null; - label: string; - isHdr: boolean; - codecName?: string; - profile?: string; - pixelFormat?: string; - colorSpace?: string; - colorTransfer?: string; - colorPrimaries?: string; - }; - probeError?: string; -} - -interface MediaMetadataResponse { - path: string; - metadata: MediaMetadata; -} - -function stripPreviewAssetPath(src: string, projectId: string): string | null { - let pathname = src; - try { - pathname = new URL(src, window.location.href).pathname; - } catch { - return null; - } - const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`; - const genericMarker = "/preview/"; - const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker; - const index = pathname.indexOf(marker); - if (index < 0) return null; - const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, ""); - if (!assetPath || assetPath.startsWith("comp/")) return null; - return assetPath; -} - -// fallow-ignore-next-line complexity -function resolveProjectAssetPath( - sourceFile: string, - src: string, - projectId: string, -): string | null { - const trimmed = stripQueryAndHash(src.trim()); - if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null; - if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId); - if (trimmed.startsWith("/")) { - return stripPreviewAssetPath(trimmed, projectId); - } - - const sourceDir = sourceFile.includes("/") - ? sourceFile.slice(0, sourceFile.lastIndexOf("/")) - : ""; - const parts = `${sourceDir}/${trimmed}`.split("/"); - const normalized: string[] = []; - for (const part of parts) { - if (!part || part === ".") continue; - if (part === "..") { - normalized.pop(); - continue; - } - normalized.push(part); - } - return normalized.join("/") || null; -} - -function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null { - if (element.tagName !== "video" && element.tagName !== "img") return null; - const media = element.element as HTMLImageElement | HTMLVideoElement; - const src = media.getAttribute("src") || media.currentSrc || ""; - return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId); -} - -function defaultColorGrading(): NormalizedHfColorGrading { - const grading = normalizeHfColorGrading("neutral"); - if (!grading) throw new Error("Missing neutral color grading preset"); - return grading; -} - -function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading { - return ( - normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading() - ); -} - -function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown { - if (!isHfColorGradingActive(grading)) return null; - const { enabled: _enabled, ...bridgeGrading } = grading; - return bridgeGrading; -} - -function readRuntimeColorGradingStatus( - iframe: HTMLIFrameElement | null | undefined, - target: HfColorGradingTarget, -): RuntimeColorGradingStatus { - try { - const win = iframe?.contentWindow as - | (Window & { - __hf?: { - colorGrading?: { - getStatus?: ( - target: HfColorGradingTarget | string | null | undefined, - ) => RuntimeColorGradingStatus; - }; - }; - }) - | null - | undefined; - const status = win?.__hf?.colorGrading?.getStatus?.(target); - return status ?? { state: "pending", message: "Waiting for runtime" }; - } catch { - return { state: "unavailable", message: "Preview unavailable" }; - } -} + useColorGradingController, + type MediaMetadata, + type RuntimeColorGradingStatus, +} from "./useColorGradingController"; function StatusPill({ status }: { status: RuntimeColorGradingStatus }) { const dotClass = @@ -289,216 +144,25 @@ export function ColorGradingSection({ value: string | null, ) => Promise<{ changedFiles: number; changedElements: number }>; }) { - const [grading, setGrading] = useState(() => readColorGradingFromElement(element)); - const [compareEnabled, setCompareEnabled] = useState(false); - const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file"); - const [applyBusy, setApplyBusy] = useState(false); - const [runtimeStatus, setRuntimeStatus] = useState(() => ({ - state: "pending", - message: "Waiting for runtime", - })); - const selectedAssetPath = useMemo( - () => selectedMediaAssetPath(element, projectId), - [element, projectId], - ); - const [mediaMetadata, setMediaMetadata] = useState(null); - const persistTimerRef = useRef | null>(null); - const pendingPersistValueRef = useRef(undefined); - const statusTimersRef = useRef([]); - const onSetAttributeLiveRef = useRef(onSetAttributeLive); - const latestGradingRef = useRef(grading); - const compareEnabledRef = useRef(compareEnabled); - onSetAttributeLiveRef.current = onSetAttributeLive; - latestGradingRef.current = grading; - compareEnabledRef.current = compareEnabled; - const target = useMemo( - (): HfColorGradingTarget => ({ - id: element.id ?? null, - hfId: element.hfId ?? null, - selector: element.selector ?? null, - selectorIndex: element.selectorIndex ?? null, - }), - [element.hfId, element.id, element.selector, element.selectorIndex], - ); - - const refreshRuntimeStatus = useCallback(() => { - setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target)); - }, [previewIframeRef, target]); - - useEffect(() => { - setMediaMetadata(null); - if (!selectedAssetPath) return; - const cacheKey = `${projectId}:${selectedAssetPath}`; - if (MEDIA_METADATA_CACHE.has(cacheKey)) { - setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null); - return; - } - const controller = new AbortController(); - fetch( - `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent( - selectedAssetPath, - )}`, - { signal: controller.signal }, - ) - .then((response) => (response.ok ? response.json() : null)) - .then((data: MediaMetadataResponse | null) => { - if (controller.signal.aborted) return; - const metadata = data?.metadata ?? null; - MEDIA_METADATA_CACHE.set(cacheKey, metadata); - setMediaMetadata(metadata); - }) - .catch(() => { - if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null); - }); - return () => controller.abort(); - }, [projectId, selectedAssetPath]); - - const clearStatusTimers = useCallback(() => { - for (const timer of statusTimersRef.current) clearTimeout(timer); - statusTimersRef.current = []; - }, []); - - const scheduleRuntimeStatusRefresh = useCallback(() => { - clearStatusTimers(); - statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) => - window.setTimeout(refreshRuntimeStatus, delay), - ); - }, [clearStatusTimers, refreshRuntimeStatus]); - - useEffect(() => { - refreshRuntimeStatus(); - }, [refreshRuntimeStatus]); - - const persistColorGradingValue = useCallback((value: string | null) => { - return trackStudioPendingEdit( - onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null), - ); - }, []); - - const flushPendingPersist = useCallback(() => { - if (persistTimerRef.current) { - clearTimeout(persistTimerRef.current); - persistTimerRef.current = null; - } - if (pendingPersistValueRef.current === undefined) return undefined; - const value = pendingPersistValueRef.current; - pendingPersistValueRef.current = undefined; - return persistColorGradingValue(value); - }, [persistColorGradingValue]); - - useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]); - - useEffect(() => { - return () => { - clearStatusTimers(); - void flushPendingPersist(); - }; - }, [clearStatusTimers, flushPendingPersist]); - - const postColorGrading = useCallback( - (nextGrading: NormalizedHfColorGrading) => { - postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", { - target, - grading: toBridgeColorGrading(nextGrading), - }); - }, - [previewIframeRef, target], - ); - - const postCompare = useCallback( - (enabled: boolean) => { - postRuntimeControlMessage( - previewIframeRef?.current?.contentWindow, - "set-color-grading-compare", - { - target, - compare: { enabled, position: 1, lineWidth: 0 }, - }, - ); - }, - [previewIframeRef, target], - ); - - useEffect(() => { - const iframe = previewIframeRef?.current; - if (!iframe) return; - const refreshAndReplay = () => { - const nextGrading = latestGradingRef.current; - const active = isHfColorGradingActive(nextGrading); - if (active) postColorGrading(nextGrading); - postCompare(compareEnabledRef.current && active); - scheduleRuntimeStatusRefresh(); - }; - const onMessage = (event: MessageEvent) => { - if (event.source !== iframe.contentWindow) return; - const data = event.data as { source?: unknown; type?: unknown } | null; - if (data?.source !== "hf-preview" || data.type !== "ready") return; - if (!acceptStudioRuntimeMessage(data)) return; - refreshAndReplay(); - }; - iframe.addEventListener("load", refreshAndReplay); - window.addEventListener("message", onMessage); - const timer = window.setTimeout(refreshAndReplay, 80); - return () => { - iframe.removeEventListener("load", refreshAndReplay); - window.removeEventListener("message", onMessage); - window.clearTimeout(timer); - }; - }, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]); - - useEffect( - () => () => { - postCompare(false); - }, - [postCompare], - ); - - const commitColorGrading = useCallback( - (nextGrading: NormalizedHfColorGrading) => { - setGrading(nextGrading); - setRuntimeStatus({ state: "pending", message: "Updating shader" }); - postColorGrading(nextGrading); - const active = isHfColorGradingActive(nextGrading); - if (compareEnabledRef.current) { - postCompare(active); - if (!active) setCompareEnabled(false); - } - scheduleRuntimeStatusRefresh(); - if (persistTimerRef.current) clearTimeout(persistTimerRef.current); - pendingPersistValueRef.current = isHfColorGradingActive(nextGrading) - ? serializeHfColorGrading(nextGrading) - : null; - persistTimerRef.current = setTimeout(() => { - const value = pendingPersistValueRef.current; - pendingPersistValueRef.current = undefined; - persistTimerRef.current = null; - void persistColorGradingValue(value ?? null); - }, 350); - }, - [persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], - ); - - const commitCompare = useCallback( - (enabled: boolean) => { - const nextEnabled = enabled && isHfColorGradingActive(grading); - setCompareEnabled(nextEnabled); - if (nextEnabled) postColorGrading(grading); - postCompare(nextEnabled); - scheduleRuntimeStatusRefresh(); - }, - [grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], - ); - - const applyToScope = useCallback(async () => { - if (!onApplyScope || applyBusy) return; - setApplyBusy(true); - try { - const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null; - await onApplyScope(applyScope, value); - } finally { - setApplyBusy(false); - } - }, [applyBusy, applyScope, grading, onApplyScope]); + const { + grading, + compareEnabled, + applyScope, + applyBusy, + runtimeStatus, + mediaMetadata, + commitColorGrading, + commitCompare, + setApplyScope, + applyToScope, + resetGrading, + } = useColorGradingController({ + projectId, + element, + previewIframeRef, + onSetAttributeLive, + onApplyScope, + }); return (
{ event.stopPropagation(); - commitColorGrading(defaultColorGrading()); + resetGrading(); }} className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1" title="Reset color grading" diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx new file mode 100644 index 0000000000..837df16882 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -0,0 +1,635 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FlatColorGradingAccessory, + FlatColorGradingSection, +} from "./propertyPanelFlatColorGradingSection"; +import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +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 }; +} + +function neutralGrading() { + const grading = normalizeHfColorGrading("neutral"); + if (!grading) throw new Error("expected a neutral grading"); + return grading; +} + +function findRowByText( + host: HTMLElement, + selector: string, + text: string, + match: "includes" | "startsWith" = "includes", +) { + const row = Array.from(host.querySelectorAll(selector)).find((el) => + el.textContent?.[match](text), + ); + if (!row) throw new Error(`expected a ${text} row`); + return row; +} + +function dragSliderTrack(row: Element, clientX: number, trackWidth: number) { + const track = row.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a slider track"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: trackWidth, top: 0, height: 2, right: trackWidth, bottom: 2 }), + }); + act(() => { + track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX })); + track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX })); + }); +} + +function clickSliderReset(row: Element) { + const resetButton = row.querySelector('[data-flat-slider-reset="true"]'); + expect(resetButton).not.toBeNull(); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); +} + +describe("FlatColorGradingAccessory", () => { + it("shows a 5px status dot colored by runtime status, with the message as its title", () => { + const { host, root } = renderInto( + , + ); + const dot = host.querySelector('[data-flat-grade-status-dot="true"]'); + expect(dot).not.toBeNull(); + expect(dot?.getAttribute("title")).toBe("Shader active"); + expect(dot?.className).toContain("bg-emerald-400"); + act(() => root.unmount()); + }); + + it("disables the compare hold button when grading is inactive, and fires resetGrading on click", () => { + const resetGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + expect(compareButton?.disabled).toBe(true); + const resetButton = host.querySelector('[data-flat-grade-reset="true"]'); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(resetGrading).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("shows the runtime status message as visible text next to the dot, not only as a title", () => { + const { host, root } = renderInto( + , + ); + const messageEl = host.querySelector('[data-flat-grade-status-message="true"]'); + expect(messageEl).not.toBeNull(); + expect(messageEl?.textContent).toBe("Waiting for shader"); + expect(host.textContent).toContain("Waiting for shader"); + act(() => root.unmount()); + }); + + function activeGrading() { + const grading = neutralGrading(); + return { ...grading, adjust: { ...grading.adjust, contrast: 0.2 } }; + } + + it("activates hold-to-compare on pointerdown and releases on window pointerup", () => { + const commitCompare = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + if (!compareButton) throw new Error("expected a compare button"); + act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + expect(commitCompare).toHaveBeenNthCalledWith(1, true); + act(() => window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }))); + expect(commitCompare).toHaveBeenNthCalledWith(2, false); + act(() => root.unmount()); + }); + + it("activates hold-to-compare via keyboard Space and releases on keyup", () => { + const commitCompare = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + if (!compareButton) throw new Error("expected a compare button"); + act(() => + compareButton.dispatchEvent( + new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }), + ), + ); + expect(commitCompare).toHaveBeenNthCalledWith(1, true); + act(() => + compareButton.dispatchEvent( + new KeyboardEvent("keyup", { key: " ", bubbles: true, cancelable: true }), + ), + ); + expect(commitCompare).toHaveBeenNthCalledWith(2, false); + act(() => root.unmount()); + }); + + it("releases an active hold when the window loses focus mid-hold", () => { + const commitCompare = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + if (!compareButton) throw new Error("expected a compare button"); + act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + expect(commitCompare).toHaveBeenNthCalledWith(1, true); + act(() => window.dispatchEvent(new Event("blur"))); + expect(commitCompare).toHaveBeenNthCalledWith(2, false); + act(() => root.unmount()); + }); + + it("releases an active hold and removes global listeners when unmounted", () => { + const commitCompare = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + if (!compareButton) throw new Error("expected a compare button"); + act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + act(() => root.unmount()); + expect(commitCompare).toHaveBeenLastCalledWith(false); + const callsAfterUnmount = commitCompare.mock.calls.length; + act(() => window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }))); + expect(commitCompare).toHaveBeenCalledTimes(callsAfterUnmount); + }); +}); + +function neutralPropsBase() { + return { + grading: neutralGrading(), + assets: [] as string[], + onCommitColorGrading: vi.fn(), + applyScope: "source-file" as const, + applyBusy: false, + onSetApplyScope: vi.fn(), + onApplyToScope: vi.fn(), + onApplyScopeAvailable: true, + mediaMetadata: null, + }; +} + +describe("FlatColorGradingSection — Preset + LUT", () => { + it("renders the Preset dropdown with id/label pairs and fires onCommitColorGrading on change", () => { + const onCommitColorGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const presetSelect = host.querySelector( + '[data-flat-grade-preset="true"] select', + ); + if (!presetSelect) throw new Error("expected a preset select"); + expect(presetSelect.value).toBe("neutral"); + act(() => { + presetSelect.value = "fresh-pop"; + presetSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("fresh-pop"); + act(() => root.unmount()); + }); + + it("shows the Custom LUT row collapsed by default, expanding to reveal the strength slider when a LUT is set", () => { + const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.8 } }; + const { host, root } = renderInto( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + expect(lutToggle).not.toBeNull(); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.textContent).toContain("warm.cube"); + act(() => root.unmount()); + }); + + it("commits the selected catalog LUT via the select control, resetting intensity to 1 when switching LUTs", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.5 } }; + const { host, root } = renderInto( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const lutSelect = host.querySelector('[data-flat-grade-lut-select="true"]'); + if (!lutSelect) throw new Error("expected a LUT catalog select"); + act(() => { + lutSelect.value = "assets/luts/cool.cube"; + lutSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({ + src: "assets/luts/cool.cube", + intensity: 1, + }); + act(() => root.unmount()); + }); + + it("imports a LUT via the hidden file input and commits the resolved asset", async () => { + const onCommitColorGrading = vi.fn(); + const onImportAssets = vi.fn().mockResolvedValue(["assets/luts/x.cube"]); + const { host, root } = renderInto( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const fileInput = host.querySelector('input[type="file"]'); + if (!fileInput) throw new Error("expected a hidden file input"); + const file = new File(["cube data"], "x.cube"); + Object.defineProperty(fileInput, "files", { value: [file], configurable: true }); + await act(async () => { + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onImportAssets).toHaveBeenCalledTimes(1); + expect(onImportAssets.mock.calls[0][0]).toEqual([file]); + expect(onImportAssets.mock.calls[0][1]).toBe("assets/luts"); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({ + src: "assets/luts/x.cube", + intensity: 1, + }); + act(() => root.unmount()); + }); +}); + +describe("FlatColorGradingSection — Adjust sliders", () => { + it("renders all 10 adjust rows with a center tick, formatting exposure distinctly from percentage sliders", () => { + const { host, root } = renderInto(); + const adjustRows = host.querySelectorAll('[data-flat-grade-adjust="true"]'); + expect(adjustRows).toHaveLength(10); + for (const row of Array.from(adjustRows)) { + expect(row.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull(); + } + expect(host.textContent).toContain("+0.00"); + act(() => root.unmount()); + }); + + it("commits an adjust change scaled correctly and shows a reset when non-neutral", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), adjust: { ...neutralGrading().adjust, contrast: 0.12 } }; + const { host, root } = renderInto( + , + ); + const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast"); + clickSliderReset(contrastRow); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0); + act(() => root.unmount()); + }); + + it("commits a dragged contrast value on slider track pointerdown, scaled from percent back to the internal -1..1 range", () => { + const onCommitColorGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast"); + // min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 50/100 = 0.5 + dragSliderTrack(contrastRow, 75, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5); + act(() => root.unmount()); + }); + + it("commits a dragged exposure value scaled into stops, keeping other adjust keys untouched", () => { + const onCommitColorGrading = vi.fn(); + const grading = { + ...neutralGrading(), + adjust: { ...neutralGrading().adjust, saturation: 0.2 }, + }; + const { host, root } = renderInto( + , + ); + const exposureRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Exposure"); + // min=-200, max=200, step=5, ratio=1.0 -> raw=200 -> commit(200) -> adjust.exposure = 200/100 = 2 + dragSliderTrack(exposureRow, 200, 200); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].adjust.exposure).toBe(2); + expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2); + act(() => root.unmount()); + }); + + it("revives a grade parked at 0% strength back to 100% when an Adjust slider is committed", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), intensity: 0 }; + const { host, root } = renderInto( + , + ); + const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast"); + // min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 0.5 + dragSliderTrack(contrastRow, 75, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(1); + expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5); + act(() => root.unmount()); + }); + + it("does NOT force intensity to revive when the Strength slider itself is dragged — it writes the value directly", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), intensity: 0 }; + const { host, root } = renderInto( + , + ); + const strengthRow = findRowByText(host, "div", "Strength", "startsWith"); + // min=0, max=100, step=1, ratio=0.4 -> raw=40 -> commit(40) -> intensity = 40/100 = 0.4 + dragSliderTrack(strengthRow, 40, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(0.4); + act(() => root.unmount()); + }); +}); + +describe("FlatColorGradingSection — Vignette and Grain", () => { + it("renders Vignette and Grain amount rows with a settings gear, expanding tuned sliders on click", () => { + const { host, root } = renderInto(); + const vignetteGear = host.querySelector( + '[data-flat-grade-settings="vignette"]', + ); + expect(vignetteGear).not.toBeNull(); + act(() => vignetteGear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.textContent).toContain("Midpoint"); + expect(host.textContent).toContain("Feather"); + act(() => root.unmount()); + }); + + it("shows tuned Midpoint at its 50% default with no reset until moved from default", () => { + const { host, root } = renderInto(); + const gear = host.querySelector('[data-flat-grade-settings="vignette"]'); + act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const midpointRow = findRowByText(host, "div", "Midpoint", "startsWith"); + expect(midpointRow.querySelector('[data-flat-slider-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("commits a dragged Roundness value on slider track pointerdown, scaled from percent back into the -1..1 detail range", () => { + const onCommitColorGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const gear = host.querySelector('[data-flat-grade-settings="vignette"]'); + act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith"); + // min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> details.vignetteRoundness = 50/100 = 0.5 + dragSliderTrack(roundnessRow, 75, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0.5); + act(() => root.unmount()); + }); + + it("resets a non-default Roundness back to its 0 default via the tuned slider's reset button", () => { + const onCommitColorGrading = vi.fn(); + const grading = { + ...neutralGrading(), + details: { ...neutralGrading().details, vignetteRoundness: 0.4 }, + }; + const { host, root } = renderInto( + , + ); + const gear = host.querySelector('[data-flat-grade-settings="vignette"]'); + act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith"); + clickSliderReset(roundnessRow); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0); + act(() => root.unmount()); + }); +}); + +describe("FlatColorGradingSection — Effects", () => { + it("renders Blur and Pixelate sliders under an Effects micro-label", () => { + const { host, root } = renderInto(); + expect(host.textContent).toContain("Effects"); + const rows = host.querySelectorAll('[data-flat-grade-effect="true"]'); + expect(rows).toHaveLength(2); + act(() => root.unmount()); + }); + + it("commits a dragged Pixelate value on slider track pointerdown, scaled from percent to the 0..1 effect range", () => { + const onCommitColorGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const pixelateRow = findRowByText(host, '[data-flat-grade-effect="true"]', "Pixelate"); + // min=0, max=100, step=1, ratio=0.75 -> raw=75 -> commit(75) -> effects.pixelate = 75/100 = 0.75 + dragSliderTrack(pixelateRow, 75, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].effects.pixelate).toBe(0.75); + act(() => root.unmount()); + }); +}); + +describe("FlatColorGradingSection — HDR banner and Apply scope", () => { + it("shows the HDR banner only when mediaMetadata reports an HDR source", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("SDR preview"); + act(() => root.unmount()); + }); + + it("shows a codec/profile/pixel-format/color detail line in the HDR banner when metadata provides it", () => { + const { host, root } = renderInto( + , + ); + const detail = host.querySelector('[data-flat-grade-hdr-detail="true"]'); + expect(detail).not.toBeNull(); + expect(detail?.textContent).toBe("hevc · Main10 · yuv420p10le · bt2020 · smpte2084"); + act(() => root.unmount()); + }); + + it("omits the HDR detail line entirely when no detail fields are populated", () => { + const { host, root } = renderInto( + , + ); + expect(host.querySelector('[data-flat-grade-hdr-detail="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("omits the HDR banner for SDR media", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).not.toContain("SDR preview"); + act(() => root.unmount()); + }); + + it("fires onApplyToScope from the Apply button, respecting applyBusy", () => { + const onApplyToScope = vi.fn(); + const { host, root } = renderInto( + , + ); + const applyButton = host.querySelector('[data-flat-grade-apply="true"]'); + expect(applyButton?.disabled).toBe(true); + act(() => root.unmount()); + }); + + it("fires onApplyToScope exactly once when the Apply button is clicked while not busy", () => { + const onApplyToScope = vi.fn(); + const { host, root } = renderInto( + , + ); + const applyButton = host.querySelector('[data-flat-grade-apply="true"]'); + expect(applyButton?.disabled).toBe(false); + act(() => applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onApplyToScope).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx new file mode 100644 index 0000000000..f30f45da9d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx @@ -0,0 +1,548 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + HF_COLOR_GRADING_PRESETS, + isHfColorGradingActive, + normalizeHfColorGrading, + type HfColorGradingAdjustKey, + type HfColorGradingDetailKey, + type HfColorGradingEffectKey, + type NormalizedHfColorGrading, +} from "@hyperframes/core/color-grading"; +import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons"; +import { LUT_EXT } from "../../utils/mediaTypes"; +import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives"; +import { resolveValueTier } from "./propertyPanelValueTier"; +import type { ColorGradingControllerState, MediaMetadata } from "./useColorGradingController"; + +const STATUS_DOT_CLASS: Record = { + active: "bg-emerald-400", + pending: "bg-amber-300", + unavailable: "bg-red-400", + missing: "bg-panel-text-5", + inactive: "bg-panel-text-5", +}; + +export function FlatColorGradingAccessory({ + state, +}: { + state: Pick< + ColorGradingControllerState, + "grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading" + >; +}) { + const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state; + const gradingActive = isHfColorGradingActive(grading); + const releaseCompareRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => releaseCompareRef.current?.(); + }, []); + + return ( + + + + + + {runtimeStatus.message} + + + + + ); +} + +const PRESET_OPTIONS = HF_COLOR_GRADING_PRESETS.map((p) => ({ value: p.id, label: p.label })); + +const ADJUST_SLIDERS: Array<{ + key: HfColorGradingAdjustKey; + label: string; + min: number; + max: number; + step: number; +}> = [ + { key: "exposure", label: "Exposure", min: -200, max: 200, step: 5 }, + { key: "contrast", label: "Contrast", min: -100, max: 100, step: 1 }, + { key: "highlights", label: "Highlights", min: -100, max: 100, step: 1 }, + { key: "shadows", label: "Shadows", min: -100, max: 100, step: 1 }, + { key: "whites", label: "White Point", min: -100, max: 100, step: 1 }, + { key: "blacks", label: "Black Point", min: -100, max: 100, step: 1 }, + { key: "temperature", label: "Warmth", min: -100, max: 100, step: 1 }, + { key: "tint", label: "Tint", min: -100, max: 100, step: 1 }, + { key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1 }, + { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 }, +]; + +function visibleIntensity(grading: NormalizedHfColorGrading): number { + // Earlier drafts could persist 0% strength; the next manual edit should revive visible grading. + return grading.intensity === 0 ? 1 : grading.intensity; +} + +function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string { + if (key === "exposure") { + const stops = rawPercent / 100; + return `${stops >= 0 ? "+" : ""}${stops.toFixed(2)}`; + } + return `${Math.round(rawPercent)}%`; +} + +const DETAIL_SLIDERS: Array<{ + key: HfColorGradingDetailKey; + label: string; + defaultValue: number; +}> = [ + { key: "vignette", label: "Vignette", defaultValue: 0 }, + { key: "vignetteMidpoint", label: "Midpoint", defaultValue: 0.5 }, + { key: "vignetteRoundness", label: "Roundness", defaultValue: 0 }, + { key: "vignetteFeather", label: "Feather", defaultValue: 0.65 }, + { key: "grain", label: "Grain", defaultValue: 0 }, + { key: "grainSize", label: "Grain Size", defaultValue: 0.25 }, + { key: "grainRoughness", label: "Roughness", defaultValue: 0.5 }, +]; +const detailByKey = (key: HfColorGradingDetailKey) => { + const spec = DETAIL_SLIDERS.find((d) => d.key === key); + if (!spec) throw new Error(`Unknown color grading detail key: ${key}`); + return spec; +}; +const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [ + "vignetteMidpoint", + "vignetteRoundness", + "vignetteFeather", +]; +const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"]; + +const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [ + { key: "blur", label: "Blur" }, + { key: "pixelate", label: "Pixelate" }, +]; + +function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) { + if (metadata?.color.dynamicRange !== "hdr") return null; + const details = [ + metadata.color.codecName, + metadata.color.profile, + metadata.color.pixelFormat, + metadata.color.colorPrimaries, + metadata.color.colorTransfer, + ] + .filter(Boolean) + .join(" · "); + return ( +
+
+ {metadata.color.label} source + + SDR preview + +
+

+ These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this + is not true HDR color grading yet. +

+ {details && ( +

+ {details} +

+ )} +
+ ); +} + +// fallow-ignore-next-line complexity +export function FlatColorGradingSection({ + grading, + assets, + onImportAssets, + onCommitColorGrading, + applyScope, + applyBusy, + onSetApplyScope, + onApplyToScope, + onApplyScopeAvailable, + mediaMetadata, +}: { + grading: NormalizedHfColorGrading; + assets: string[]; + onImportAssets?: (files: FileList, dir?: string) => Promise; + onCommitColorGrading: (next: NormalizedHfColorGrading) => void; + applyScope: "source-file" | "project"; + applyBusy: boolean; + onSetApplyScope: (scope: "source-file" | "project") => void; + onApplyToScope: () => void; + onApplyScopeAvailable: boolean; + mediaMetadata: MediaMetadata | null; +}) { + const lutInputRef = useRef(null); + const [lutOpen, setLutOpen] = useState(false); + const [detailSettingsOpen, setDetailSettingsOpen] = useState<"vignette" | "grain" | null>(null); + const lutAssets = useMemo( + () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)), + [assets], + ); + const lut = grading.lut; + const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null; + + const applyPreset = (presetId: string) => { + const next = normalizeHfColorGrading({ preset: presetId, intensity: 1, lut: grading.lut }); + if (next) onCommitColorGrading(next); + }; + const updateIntensity = (value: number) => { + onCommitColorGrading({ ...grading, intensity: value / 100 }); + }; + const applyLut = (src: string | null, intensity = 1) => { + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + lut: src ? { src, intensity } : null, + }); + }; + const importLuts = async (files: FileList | null) => { + if (!files?.length || !onImportAssets) return; + const uploaded = await onImportAssets(files, "assets/luts"); + const firstLut = uploaded.find((asset) => LUT_EXT.test(asset)); + if (firstLut) applyLut(firstLut, 1); + }; + + const renderDetailSlider = (key: HfColorGradingDetailKey) => { + const spec = detailByKey(key); + const value = grading.details[key]; + const isSet = Math.abs(value - spec.defaultValue) > 1e-4; + return ( + + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + details: { ...grading.details, [key]: next / 100 }, + }) + } + onReset={() => + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + details: { ...grading.details, [key]: spec.defaultValue }, + }) + } + /> + ); + }; + + return ( +
+ +
+ Preset + +
+ updateIntensity(100)} + /> + +
+ + {lutOpen && ( +
+
+ + {selectedLutName ?? "None"} + + + + { + void importLuts(e.currentTarget.files); + e.currentTarget.value = ""; + }} + /> +
+ {lut && ( + applyLut(lut.src, v / 100)} + onReset={() => applyLut(lut.src, 1)} + /> + )} +
+ )} +
+ +
+
+ Adjust +
+ {ADJUST_SLIDERS.map((slider) => { + const rawPercent = grading.adjust[slider.key] * 100; + const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6; + return ( +
+ + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + adjust: { ...grading.adjust, [slider.key]: next / 100 }, + }) + } + onReset={() => + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + adjust: { ...grading.adjust, [slider.key]: 0 }, + }) + } + /> +
+ ); + })} +
+ +
+
+ Finishing +
+
+
{renderDetailSlider("vignette")}
+ +
+
+
{renderDetailSlider("grain")}
+ +
+ {detailSettingsOpen && ( +
+ {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map( + renderDetailSlider, + )} +
+ )} +
+ +
+
+ Effects +
+ {EFFECT_SLIDERS.map((slider) => { + const value = grading.effects[slider.key]; + const isSet = value > 1e-6; + return ( +
+ + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + effects: { ...grading.effects, [slider.key]: next / 100 }, + }) + } + onReset={() => + onCommitColorGrading({ + ...grading, + intensity: visibleIntensity(grading), + effects: { ...grading.effects, [slider.key]: 0 }, + }) + } + /> +
+ ); + })} +
+ + {onApplyScopeAvailable && ( +
+ + Copy grade to + + + +
+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx new file mode 100644 index 0000000000..ece488c962 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx @@ -0,0 +1,293 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FlatLayoutSection, + LayoutFlexBlock, + LayoutGeometryRows, + LayoutTransform3DBlock, + LayoutZIndexRow, +} from "./propertyPanelFlatLayoutSection"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +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 }; +} + +function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement { + const rows = Array.from(host.querySelectorAll(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + const input = row?.querySelector("input"); + if (!input) throw new Error(`expected an input for row "${label}"`); + return input; +} + +function baseGeometryProps(overrides: Partial[0]> = {}) { + return { + element: {} as never, + displayX: 0, + displayY: -24, + displayW: 257.4, + displayH: 29, + displayR: 0, + manualOffsetEditingDisabled: false, + manualSizeEditingDisabled: false, + manualRotationEditingDisabled: false, + commitManualOffset: vi.fn(), + commitManualSize: vi.fn(), + commitManualRotation: vi.fn(), + gsapAnimId: null, + navKeyframes: null, + currentPct: 0, + seekFromKfPct: vi.fn(), + animIdForProp: (prop: string) => prop, + onCommitAnimatedProperty: vi.fn(), + onRemoveKeyframe: vi.fn(), + onConvertToKeyframes: vi.fn(), + ...overrides, + }; +} + +describe("LayoutGeometryRows", () => { + it("renders X, Y, W, H, Angle labels and formatted values", () => { + const { host, root } = renderInto(); + expect(host.textContent).toContain("X"); + expect(host.textContent).toContain("Y"); + expect(host.textContent).toContain("W"); + expect(host.textContent).toContain("H"); + expect(host.textContent).toContain("Angle"); + expect(getFlatRowInput(host, "W").value).toBe("257.4px"); + expect(getFlatRowInput(host, "Y").value).toBe("-24px"); + act(() => root.unmount()); + }); + + it("commits an X edit through commitManualOffset", () => { + const commitManualOffset = vi.fn(); + const { host, root } = renderInto( + , + ); + const input = host.querySelectorAll("input")[0]; + if (!input) throw new Error("expected an X input"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, "40px"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(commitManualOffset).toHaveBeenCalledWith("x", "40px"); + act(() => root.unmount()); + }); + + it("wraps the keyframe gutter cluster at 30% opacity when the property has no keyframes", () => { + const { host, root } = renderInto( + , + ); + const dimmed = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 0.3"]'); + expect(dimmed.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); + + it("does not dim the gutter cluster when the property has keyframes", () => { + const { host, root } = renderInto( + , + ); + const full = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 1"]'); + expect(full.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); + + it("passes the real element/selection (not null) to onCommitAnimatedProperty when adding a keyframe", () => { + const onCommitAnimatedProperty = vi.fn(); + const element = { id: "el-1" } as unknown as Parameters< + typeof LayoutGeometryRows + >[0]["element"]; + const { host, root } = renderInto( + , + ); + const addButton = host.querySelector('[title="Add x keyframe"]'); + if (!addButton) throw new Error("expected an Add x keyframe button"); + act(() => { + (addButton as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onCommitAnimatedProperty).toHaveBeenCalledWith(element, "x", 0); + expect(onCommitAnimatedProperty).not.toHaveBeenCalledWith(null, "x", 0); + act(() => root.unmount()); + }); +}); + +describe("LayoutZIndexRow", () => { + it("renders the current z-index at the default tier and commits edits", () => { + const onSetStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Z-index"); + const input = host.querySelector("input"); + if (!input) throw new Error("expected an input"); + expect(input.value).toBe("3"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, "5"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onSetStyle).toHaveBeenCalledWith("z-index", "5"); + act(() => root.unmount()); + }); +}); + +describe("LayoutFlexBlock", () => { + it("renders nothing when the element is not flex", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toBe(""); + act(() => root.unmount()); + }); + + it("renders direction/justify/align/gap and commits a direction change", () => { + const onSetStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Flex"); + const columnOption = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find( + (el) => el.textContent === "Column", + ); + if (!columnOption) throw new Error("expected a Column segment option"); + act(() => + (columnOption as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + expect(onSetStyle).toHaveBeenCalledWith("flex-direction", "column"); + act(() => root.unmount()); + }); +}); + +describe("LayoutTransform3DBlock", () => { + it("renders the nested 3D transform sub-view", () => { + const { host, root } = renderInto( + , + ); + // PropertyPanel3dTransform's own internals aren't this task's concern (it's + // reused unmodified) — just confirm the wrapper mounted something. + expect(host.children.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); +}); + +describe("FlatLayoutSection", () => { + it("renders geometry rows, z-index, flex (when applicable), and the 3D transform block in order", () => { + const { host, root } = renderInto( + p} + gsapRuntimeValues={{}} + gsapKeyframes={null} + elStart={0} + elDuration={0} + onSeekToTime={vi.fn()} + />, + ); + const text = host.textContent ?? ""; + expect(text).toContain("X"); + expect(text).toContain("Z-index"); + expect(text).toContain("Flex"); + expect(text).toContain("3D Transform"); + act(() => root.unmount()); + }); + + it("omits the Flex block for a non-flex element", () => { + const { host, root } = renderInto( + p} + gsapRuntimeValues={{}} + gsapKeyframes={null} + elStart={0} + elDuration={0} + onSeekToTime={vi.fn()} + />, + ); + expect(host.textContent).not.toContain("Flex"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx new file mode 100644 index 0000000000..0c7b0a2e4d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -0,0 +1,371 @@ +import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives"; +import { KeyframeNavigation } from "./KeyframeNavigation"; +import { formatPxMetricValue } from "./propertyPanelHelpers"; +import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; +import { resolveValueTier } from "./propertyPanelValueTier"; +import { PropertyPanel3dTransform } from "./propertyPanel3dTransform"; +import type { DomEditSelection } from "./domEditingTypes"; + +type KeyframeEntry = Array<{ + percentage: number; + tweenPercentage?: number; + properties: Record; + ease?: string; +}> | null; + +interface GeometryRowsProps { + element: DomEditSelection; + displayX: number; + displayY: number; + displayW: number; + displayH: number; + displayR: number; + manualOffsetEditingDisabled: boolean; + manualSizeEditingDisabled: boolean; + manualRotationEditingDisabled: boolean; + commitManualOffset: (axis: "x" | "y", value: string) => void; + commitManualSize: (dimension: "width" | "height", value: string) => void; + commitManualRotation: (value: string) => void; + gsapAnimId: string | null; + navKeyframes: KeyframeEntry; + currentPct: number; + seekFromKfPct: (pct: number) => void; + animIdForProp: (prop: string) => string; + onCommitAnimatedProperty?: ( + element: DomEditSelection, + property: string, + value: number, + ) => Promise; + onRemoveKeyframe?: (animId: string, pct: number) => void; + onConvertToKeyframes?: (animId: string) => void; +} + +function KeyframeGutter({ + element, + property, + displayValue, + gsapAnimId, + navKeyframes, + currentPct, + seekFromKfPct, + animIdForProp, + onCommitAnimatedProperty, + onRemoveKeyframe, + onConvertToKeyframes, +}: { + property: string; + displayValue: number; +} & Pick< + GeometryRowsProps, + | "element" + | "gsapAnimId" + | "navKeyframes" + | "currentPct" + | "seekFromKfPct" + | "animIdForProp" + | "onCommitAnimatedProperty" + | "onRemoveKeyframe" + | "onConvertToKeyframes" +>) { + if (!STUDIO_KEYFRAMES_ENABLED || !gsapAnimId) return null; + const hasKeyframesOnProp = Boolean(navKeyframes?.some((kf) => property in kf.properties)); + return ( + + + onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))} + /> + + ); +} + +export function LayoutGeometryRows({ + element, + displayX, + displayY, + displayW, + displayH, + displayR, + manualOffsetEditingDisabled, + manualSizeEditingDisabled, + manualRotationEditingDisabled, + commitManualOffset, + commitManualSize, + commitManualRotation, + gsapAnimId, + navKeyframes, + currentPct, + seekFromKfPct, + animIdForProp, + onCommitAnimatedProperty, + onRemoveKeyframe, + onConvertToKeyframes, +}: GeometryRowsProps) { + const gutterProps = { + element, + gsapAnimId, + navKeyframes, + currentPct, + seekFromKfPct, + animIdForProp, + onCommitAnimatedProperty, + onRemoveKeyframe, + onConvertToKeyframes, + }; + return ( + <> + commitManualOffset("x", next)} + suffix={} + /> + commitManualOffset("y", next)} + suffix={} + /> + commitManualSize("width", next)} + suffix={} + /> + commitManualSize("height", next)} + suffix={} + /> + commitManualRotation(next.replace("°", ""))} + suffix={} + /> + + ); +} + +export function LayoutZIndexRow({ + styles, + onSetStyle, +}: { + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0); + return ( + void onSetStyle("z-index", next)} + /> + ); +} + +export function LayoutFlexBlock({ + styles, + onSetStyle, + disabled, +}: { + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + disabled: boolean; +}) { + const isFlex = styles.display === "flex" || styles.display === "inline-flex"; + if (!isFlex) return null; + const direction = styles["flex-direction"] || "row"; + return ( +
+
+ Flex +
+ void onSetStyle("flex-direction", next)} + /> + void onSetStyle("justify-content", next)} + /> + void onSetStyle("align-items", next)} + /> + void onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)} + /> +
+ ); +} + +export function LayoutTransform3DBlock({ + gsapRuntimeValues, + gsapAnimId, + resolveAnimIdForProp, + gsapKeyframes, + currentPct, + elStart, + elDuration, + element, + onCommitAnimatedProperty, + onCommitAnimatedProperties, + onSeekToTime, + onRemoveKeyframe, + onConvertToKeyframes, + onLivePreviewProps, +}: { + gsapRuntimeValues: Record; + gsapAnimId: string | null; + resolveAnimIdForProp?: (prop: string) => string | null; + gsapKeyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }> | null; + currentPct: number; + elStart: number; + elDuration: number; + element: DomEditSelection; + onCommitAnimatedProperty?: ( + element: DomEditSelection, + property: string, + value: number, + ) => Promise; + onCommitAnimatedProperties?: ( + element: DomEditSelection, + props: Record, + ) => Promise; + onSeekToTime?: (time: number) => void; + onRemoveKeyframe?: (animId: string, pct: number) => void; + onConvertToKeyframes?: (animId: string, duration?: number) => void; + onLivePreviewProps?: (element: DomEditSelection, props: Record) => void; +}) { + return ( +
+
+ 3D Transform +
+ +
+ ); +} + +interface FlatLayoutSectionProps + extends + Omit, + Pick< + Parameters[0], + | "gsapRuntimeValues" + | "resolveAnimIdForProp" + | "gsapKeyframes" + | "elStart" + | "elDuration" + | "onCommitAnimatedProperties" + | "onSeekToTime" + | "onLivePreviewProps" + > { + element: DomEditSelection; + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + disabled: boolean; +} + +export function FlatLayoutSection({ + element, + styles, + onSetStyle, + disabled, + gsapRuntimeValues, + resolveAnimIdForProp, + gsapKeyframes, + elStart, + elDuration, + onCommitAnimatedProperties, + onSeekToTime, + onLivePreviewProps, + ...geometry +}: FlatLayoutSectionProps) { + return ( +
+ + + + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx new file mode 100644 index 0000000000..7224d46ec2 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -0,0 +1,464 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; +import type { DomEditSelection } from "./domEditing"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeVideoElement(overrides: Partial = {}): DomEditSelection { + const el = document.createElement("video"); + el.setAttribute("src", "assets/intro-loop.mp4"); + return { + element: el, + id: "s1-bg", + selector: "#s1-bg", + label: "S1 Background", + tagName: "video", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 1920, height: 1080 }, + textContent: "", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderSection(overrides: Partial = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeVideoElement(overrides); + act(() => { + root.render( + , + ); + }); + return { host, root }; +} + +describe("FlatMediaSection — source row", () => { + it("renders the source path and copies it to clipboard on click", () => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + const { host, root } = renderSection(); + expect(host.textContent).toContain("assets/intro-loop.mp4"); + const copyButton = host.querySelector('[data-flat-media-copy="true"]'); + expect(copyButton).not.toBeNull(); + act(() => copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("assets/intro-loop.mp4"); + act(() => root.unmount()); + }); +}); + +describe("FlatMediaSection — cutout", () => { + it("shows the WebM label for video and fires background removal on click", async () => { + const onRemoveBackground = vi.fn().mockResolvedValue({ outputPath: "assets/intro-loop.webm" }); + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeVideoElement(); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("transparent WebM"); + const removeBgButton = host.querySelector( + '[data-flat-media-remove-bg="true"]', + ); + expect(removeBgButton).not.toBeNull(); + await act(async () => { + removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onRemoveBackground).toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("toggles BG plate via FlatToggle", () => { + const { host, root } = renderSection(); + const plateToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="BG plate"]', + ); + expect(plateToggle).not.toBeNull(); + expect(plateToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => plateToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(plateToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => root.unmount()); + }); +}); + +describe("FlatMediaSection — volume/rate/media-start", () => { + it("renders volume at its stored percentage and commits a new value on drag", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("50%"); + act(() => root.unmount()); + }); + + it("commits a new volume value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { volume: "0.2" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0]; + Object.defineProperty(volumeTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); + }); + // starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5" + expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5"); + act(() => root.unmount()); + }); + + it("commits a new rate value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const rateTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[1]; + Object.defineProperty(rateTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 })); + }); + // min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3" + expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3"); + act(() => root.unmount()); + }); + + it("commits a new media-start value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const mediaStartTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[2]; + Object.defineProperty(mediaStartTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + mediaStartTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + mediaStartTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 })); + }); + // no source-duration set -> mediaStartMax=Math.max(30, Math.ceil(0+10))=30 -> max=3000 + // ratio=1.0 -> raw=3000 -> commit(3000) -> (3000/100).toFixed(2) = "30.00" + expect(onSetAttribute).toHaveBeenCalledWith("media-start", "30.00"); + act(() => root.unmount()); + }); + + it("uses 5% rate and 0.1 second media-start keyboard increments", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + act(() => { + tracks[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + tracks[2]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + }); + expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "1.05"); + expect(onSetAttribute).toHaveBeenCalledWith("media-start", "0.10"); + act(() => root.unmount()); + }); +}); + +describe("FlatMediaSection — loop/muted/has-audio", () => { + it("toggles loop via onSetHtmlAttribute and shows has-audio-track for video", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const loopToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Loop"]', + ); + act(() => loopToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("loop", "true"); + + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => root.unmount()); + }); + + it("toggles muted via onSetHtmlAttribute", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const mutedToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Muted"]', + ); + expect(mutedToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => mutedToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true"); + act(() => root.unmount()); + }); + + it("enables has-audio-track and clears muted on click", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "true"); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", null); + act(() => root.unmount()); + }); + + it("disables has-audio-track and sets muted on click", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetAttribute).toHaveBeenCalledWith("has-audio", ""); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true"); + act(() => root.unmount()); + }); +}); + +describe("FlatMediaSection — fit/position", () => { + it("commits object-fit and object-position changes", () => { + const onSetStyle = vi.fn(); + const { host, root } = (() => { + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + return { host, root }; + })(); + const selects = host.querySelectorAll("select"); + const fitSelect = Array.from(selects).find((s) => s.value === "cover"); + expect(fitSelect).not.toBeUndefined(); + act(() => { + if (fitSelect) { + fitSelect.value = "contain"; + fitSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + expect(onSetStyle).toHaveBeenCalledWith("object-fit", "contain"); + act(() => root.unmount()); + }); + + it("commits an object-position change", () => { + const onSetStyle = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const selects = host.querySelectorAll("select"); + const positionSelect = Array.from(selects).find((s) => s.value === "center"); + expect(positionSelect).not.toBeUndefined(); + act(() => { + if (positionSelect) { + positionSelect.value = "left top"; + positionSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + expect(onSetStyle).toHaveBeenCalledWith("object-position", "left top"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx new file mode 100644 index 0000000000..e6e78497cf --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -0,0 +1,283 @@ +import { useEffect, useState } from "react"; +import { Check, ClipboardList } from "../../icons/SystemIcons"; +import type { DomEditSelection } from "./domEditing"; +import { + type BackgroundRemovalProgress, + type BackgroundRemovalResult, + formatNumericValue, + formatTimingValue, + parseNumericValue, + stripQueryAndHash, +} from "./propertyPanelHelpers"; +import { FlatSelectRow, FlatSlider, FlatToggle } from "./propertyPanelFlatPrimitives"; + +// fallow-ignore-next-line complexity +export function FlatMediaSection({ + projectDir, + element, + styles, + onSetStyle, + onSetAttribute, + onSetHtmlAttribute, + onRemoveBackground, +}: { + projectDir: string | null; + element: DomEditSelection; + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + onSetAttribute: (attr: string, value: string) => void | Promise; + onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; + onRemoveBackground?: ( + inputPath: string, + options: { + createBackgroundPlate?: boolean; + quality?: "fast" | "balanced" | "best"; + onProgress?: (progress: BackgroundRemovalProgress) => void; + }, + ) => Promise; +}) { + const isVideo = element.tagName === "video"; + const isAudio = element.tagName === "audio"; + const isImage = element.tagName === "img"; + const isVisualMedia = isVideo || isImage; + const el = element.element; + + const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; + const volumePercent = Math.round(volume * 100); + const mediaStart = + Number.parseFloat( + element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0", + ) || 0; + const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1; + const sourceDuration = + Number.parseFloat(element.dataAttributes["source-duration"] ?? "") || + (el as HTMLMediaElement).duration || + 0; + const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); + const hasLoop = el.hasAttribute("loop"); + const hasMuted = el.hasAttribute("muted"); + const hasAudio = element.dataAttributes["has-audio"] === "true"; + const objectFit = styles["object-fit"] || "contain"; + const objectPosition = styles["object-position"] || "center"; + + const srcAttr = el.getAttribute("src") ?? ""; + const [copied, setCopied] = useState(false); + const [removeBusy, setRemoveBusy] = useState(false); + const [removeProgress, setRemoveProgress] = useState(null); + const [createPlate, setCreatePlate] = useState(false); + const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced"); + + const absoluteSrc = + projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr; + const projectSrc = + srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) + ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) + : ""; + const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc); + + useEffect(() => { + setRemoveProgress(null); + setCreatePlate(false); + }, [srcAttr]); + + const applyCutoutResult = async (result: BackgroundRemovalResult) => { + await onSetHtmlAttribute("src", result.outputPath); + if (isVideo) { + await onSetAttribute("has-audio", ""); + await onSetHtmlAttribute("muted", "true"); + } + }; + + const runBackgroundRemoval = async () => { + if (!onRemoveBackground || !projectSrc || removeBusy) return; + setRemoveBusy(true); + setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" }); + try { + const result = await onRemoveBackground(projectSrc, { + createBackgroundPlate: isVideo && createPlate, + quality, + onProgress: setRemoveProgress, + }); + await applyCutoutResult(result); + setRemoveProgress({ status: "complete", progress: 100, stage: "Applied cutout", ...result }); + } catch (error) { + setRemoveProgress({ + status: "failed", + progress: 0, + stage: "Failed", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + setRemoveBusy(false); + } + }; + + return ( +
+
+ + + + {srcAttr} + + + +
+ {isVisualMedia && ( +
+
+ + Cutout + + transparent {isVideo ? "WebM" : "PNG"} + + + +
+ setQuality(next as typeof quality)} + /> + {isVideo && ( + + )} + {removeProgress && ( +
+
+ + {removeProgress.error ?? removeProgress.stage ?? "Processing"} + + {Math.round(removeProgress.progress)}% +
+
+
+
+
+ )} +
+ )} + {(isVideo || isAudio) && ( + <> + void onSetAttribute("volume", formatNumericValue(next / 100))} + /> + + void onSetAttribute("playback-rate", formatNumericValue(next / 100)) + } + /> + void onSetAttribute("media-start", (next / 100).toFixed(2))} + /> + void onSetHtmlAttribute("loop", next ? "true" : null)} + /> + void onSetHtmlAttribute("muted", next ? "true" : null)} + /> + {isVideo && ( + { + if (next) { + void onSetAttribute("has-audio", "true"); + void onSetHtmlAttribute("muted", null); + } else { + void onSetAttribute("has-audio", ""); + void onSetHtmlAttribute("muted", "true"); + } + }} + /> + )} + + )} + {isVisualMedia && ( + <> + void onSetStyle("object-fit", next)} + /> + void onSetStyle("object-position", next)} + /> + + )} +
+ ); +} 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 0000000000..2f5a47357e --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatMotionSection, 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()); + }); +}); + +describe("FlatMotionSection", () => { + it("renders Timing when showTiming is true and the effect list when showEffects is true", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Start"); + expect(host.textContent).toContain("power2.out"); + act(() => root.unmount()); + }); + + it("omits Timing entirely when showTiming is false", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).not.toContain("Start"); + act(() => root.unmount()); + }); + + it("omits the effect list entirely when showEffects is false", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).not.toContain("power2.out"); + act(() => root.unmount()); + }); + + it("opens the add-method menu on '+ Add effect' and calls onAddAnimation with the chosen method", () => { + const onAddAnimation = vi.fn(); + const { host, root } = renderInto( + , + ); + const buttons = () => Array.from(host.querySelectorAll("button")); + const addTrigger = buttons().find((b) => b.textContent === "+ Add effect"); + if (!addTrigger) throw new Error("expected an '+ Add effect' trigger button"); + act(() => { + addTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const animateButton = buttons().find((b) => b.textContent === "Animate"); + if (!animateButton) throw new Error("expected an 'Animate' method button"); + act(() => { + animateButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onAddAnimation).toHaveBeenCalledWith("to"); + // The menu closes back to the trigger after a selection. + expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true); + 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 0000000000..83c17cf6b4 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -0,0 +1,180 @@ +import { useState } from "react"; +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"; +import { AnimationCard } from "./AnimationCard"; +import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants"; +import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks"; +import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; + +export function FlatTimingRow({ + element, + animations = [], + onSetAttribute, +}: { + element: DomEditSelection; + animations?: GsapAnimation[]; + onSetAttribute: (attr: string, value: string) => void | Promise; +}) { + const { start, duration, inferred: derived } = deriveElementTiming(element, animations); + const end = start + duration; + + // While the range is inferred from animations, editing ONE field must pin the + // WHOLE displayed range: writing only data-duration flips inference off and + // drops start to data-start-or-0 (the clip silently shifts), and writing only + // data-start is ignored while duration is still inferred (the edit looks + // dead). Pin both attributes, sequentially, so the display never jumps. + const pinRange = async (nextStart: number, nextDuration: number) => { + await onSetAttribute("start", nextStart.toFixed(2)); + await onSetAttribute("duration", nextDuration.toFixed(2)); + }; + + const commitStart = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null) return; + if (derived) { + void pinRange(parsed, duration); + return; + } + void onSetAttribute("start", parsed.toFixed(2)); + }; + + const commitDuration = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= 0) return; + if (derived) { + void pinRange(start, parsed); + return; + } + void onSetAttribute("duration", parsed.toFixed(2)); + }; + + const commitEnd = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= start) return; + if (derived) { + void pinRange(start, 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. +

+ )} +
+ ); +} + +export function FlatMotionSection({ + element, + animations, + showTiming, + showEffects, + multipleTimelines, + unsupportedTimelinePattern, + onSetAttribute, + onAddAnimation, + ...callbacks +}: { + element: DomEditSelection; + animations: GsapAnimation[]; + showTiming: boolean; + showEffects: boolean; + multipleTimelines?: boolean; + unsupportedTimelinePattern?: boolean; + onSetAttribute: (attr: string, value: string) => void | Promise; + onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; +} & GsapAnimationEditCallbacks) { + const [addMenuOpen, setAddMenuOpen] = useState(false); + + return ( +
+ {showTiming && ( + + )} + {showEffects && ( + <> + {multipleTimelines && ( +

+ This file has multiple GSAP timelines. Animation editing is disabled to prevent data + loss — consolidate into a single timeline to enable editing. +

+ )} + {unsupportedTimelinePattern && ( +

+ This timeline uses a computed key the editor can't resolve statically. +

+ )} + {!multipleTimelines && !unsupportedTimelinePattern && ( +
+ {animations.map((anim, index) => ( + + ))} +
+ {addMenuOpen ? ( +
+ {ADD_METHODS.map((method) => ( + + ))} + +
+ ) : ( + + )} +
+
+ )} + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx new file mode 100644 index 0000000000..bf313a9921 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -0,0 +1,868 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FlatGroupHeader, + FlatRow, + FlatSegmentedRow, + FlatSelectRow, + FlatSlider, + FlatToggle, +} from "./propertyPanelFlatPrimitives"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; +}); + +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("FlatRow", () => { + it("renders the default tier with no reset button", () => { + const { host, root } = renderInto( + , + ); + const value = host.querySelector('[data-flat-row-value="true"]'); + expect(value?.className).toContain("text-panel-text-3"); + expect(host.querySelector('[data-flat-row-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("renders the explicitCustom tier with a mint value and a reset button", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const value = host.querySelector('[data-flat-row-value="true"]'); + expect(value?.className).toContain("text-panel-accent"); + const reset = host.querySelector('[data-flat-row-reset="true"]'); + expect(reset).not.toBeNull(); + act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("commits edits through the underlying CommitField input", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const input = host.querySelector("input"); + if (!input) throw new Error("expected an input"); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, "24px"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onCommit).toHaveBeenCalledWith("24px"); + act(() => root.unmount()); + }); +}); + +describe("FlatSegmentedRow", () => { + it("underlines the active option in mint and leaves others muted", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const options = host.querySelectorAll('[data-flat-segment="true"]'); + expect(options).toHaveLength(2); + expect((options[0] as HTMLElement).className).toContain("text-panel-text-4"); + expect((options[1] as HTMLElement).className).toContain("border-panel-accent"); + act(() => + (options[0] as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + expect(onChange).toHaveBeenCalledWith("left"); + act(() => root.unmount()); + }); +}); + +describe("FlatGroupHeader", () => { + it("renders the open header (name + caret), with no sticky-related props required", () => { + const onToggleOpen = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Text"); + const collapse = host.querySelector('button[title="Collapse"]'); + act(() => collapse?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("renders the collapsed row (name + summary + caret-right) with no sticky positioning", () => { + const onToggleOpen = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("fill none · 100%"); + const row = host.querySelector('[data-flat-group-collapsed="true"]'); + expect(row?.style.position).toBe(""); + act(() => row?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("applies the entrance animation class to both states, only when animateEntrance is set", () => { + const { host: openHost, root: openRoot } = renderInto( + , + ); + expect(openHost.firstElementChild?.className).toContain("hf-flat-group-enter"); + act(() => openRoot.unmount()); + + const { host: collapsedHost, root: collapsedRoot } = renderInto( + , + ); + const row = collapsedHost.querySelector('[data-flat-group-collapsed="true"]'); + expect(row?.className).toContain("hf-flat-group-enter"); + act(() => collapsedRoot.unmount()); + }); + + it("omits the entrance animation class in both states when animateEntrance is not set", () => { + const { host: openHost, root: openRoot } = renderInto( + , + ); + expect(openHost.firstElementChild?.className).not.toContain("hf-flat-group-enter"); + act(() => openRoot.unmount()); + + const { host: collapsedHost, root: collapsedRoot } = renderInto( + , + ); + const row = collapsedHost.querySelector('[data-flat-group-collapsed="true"]'); + expect(row?.className).not.toContain("hf-flat-group-enter"); + act(() => collapsedRoot.unmount()); + }); + + it("renders no inline position styling in either state (collapsed headers never move)", () => { + const { host: collapsedHost, root: collapsedRoot } = renderInto( + , + ); + const row = collapsedHost.querySelector( + '[data-flat-group-collapsed="true"]', + ); + expect(row?.getAttribute("style")).toBeNull(); + act(() => collapsedRoot.unmount()); + + const { host: openHost, root: openRoot } = renderInto( + , + ); + expect(openHost.textContent).toContain("Motion"); + expect(openHost.querySelector("[style]")).toBeNull(); + act(() => openRoot.unmount()); + }); +}); + +describe("FlatSlider", () => { + it("renders the default tier with a dim knob at the correct position", () => { + const { host, root } = renderInto( + , + ); + const knob = host.querySelector('[data-flat-slider-knob="true"]'); + expect(knob).not.toBeNull(); + expect(knob?.className).toContain("bg-panel-text-4"); + expect(knob?.style.left).toBe("0%"); + const value = host.querySelector('[data-flat-slider-value="true"]'); + expect(value?.className).toContain("text-panel-text-3"); + expect(value?.textContent).toBe("0px"); + act(() => root.unmount()); + }); + + it("renders the explicitCustom tier with a filled track and bright knob", () => { + const { host, root } = renderInto( + , + ); + const fill = host.querySelector('[data-flat-slider-fill="true"]'); + expect(fill?.style.width).toBe("100%"); + const knob = host.querySelector('[data-flat-slider-knob="true"]'); + expect(knob?.className).toContain("bg-white"); + act(() => root.unmount()); + }); + + it("commits a value on track click, proportional to click position", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 2, right: 200, bottom: 2 }), + }); + act(() => { + track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 })); + }); + expect(onCommit).toHaveBeenCalledWith(50); + act(() => root.unmount()); + }); + + it("widens the click/drag hit area vertically beyond the thin visible line", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + expect(track.className).toContain("touch-none"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true, clientX: 20, clientY: 18 }), + ); + track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 20 })); + }); + expect(onCommit).toHaveBeenCalledWith(10); + act(() => root.unmount()); + }); + + it("tracks the knob instantly on every pointermove during a drag (draft state)", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), + ); + }); + // Instant, un-throttled knob feedback via aria-valuenow (draft state) — + // this must update on every pointermove regardless of the commit throttle. + expect(track.getAttribute("aria-valuenow")).toBe("10"); + act(() => { + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }), + ); + }); + expect(track.getAttribute("aria-valuenow")).toBe("80"); + act(() => { + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }), + ); + }); + expect(track.getAttribute("aria-valuenow")).toBe("50"); + act(() => { + track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })); + }); + act(() => root.unmount()); + }); + + it("throttles rapid drag commits to leading edge + final value on release, not every step", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + // pointerdown fires the leading-edge commit immediately — a live + // preview needs to move the instant the drag starts, not wait 40ms. + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), + ); + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }), + ); + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }), + ); + }); + // The leading-edge commit (10) fired; the rapid intermediate position (80) + // from the first pointermove never committed — it's within the 40ms + // throttle window, so only the trailing flush or the pointerup release + // gets to send the next value. + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(10); + act(() => { + // Real pointerup events always carry the pointer's true release position + // (matches the last pointermove) — the handler recomputes from this + // rather than trusting a possibly-stale `draft` closure. + track.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, clientX: 100, pointerId: 1 }), + ); + }); + // Release flushes immediately with the LAST position only. + expect(onCommit).toHaveBeenCalledTimes(2); + expect(onCommit).toHaveBeenNthCalledWith(2, 50); + act(() => root.unmount()); + }); + + it("ignores pointermove once a drag has ended (pointer capture released)", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), + ); + track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })); + }); + onCommit.mockClear(); + act(() => { + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }), + ); + }); + expect(onCommit).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); +}); + +describe("FlatSlider — Grade extensions", () => { + it("renders a center tick when centerTick is true, and omits it by default", () => { + const { host: withTick, root: rootA } = renderInto( + , + ); + expect(withTick.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull(); + act(() => rootA.unmount()); + + const { host: withoutTick, root: rootB } = renderInto( + , + ); + expect(withoutTick.querySelector('[data-flat-slider-center-tick="true"]')).toBeNull(); + act(() => rootB.unmount()); + }); + + it("always reserves a 14px reset slot, showing the icon only when set and onReset is provided", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const slot = host.querySelector('[data-flat-slider-reset-slot="true"]'); + expect(slot).not.toBeNull(); + const resetButton = host.querySelector('[data-flat-slider-reset="true"]'); + expect(resetButton).not.toBeNull(); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + + const { host: unsetHost, root: rootB } = renderInto( + , + ); + expect(unsetHost.querySelector('[data-flat-slider-reset-slot="true"]')).not.toBeNull(); + expect(unsetHost.querySelector('[data-flat-slider-reset="true"]')).toBeNull(); + act(() => rootB.unmount()); + }); + + it("renders no reset slot at all when neither centerTick nor onReset is provided", () => { + const { host, root } = renderInto( + , + ); + expect(host.querySelector('[data-flat-slider-reset-slot="true"]')).toBeNull(); + expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("shows a reachable reset button on a non-centerTick slider that passes onReset (Grade Vignette/Effects)", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const resetButton = host.querySelector('[data-flat-slider-reset="true"]'); + expect(resetButton).not.toBeNull(); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("never commits from a click released on a disabled slider", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 50, pointerId: 1 }), + ); + track.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, clientX: 50, pointerId: 1 }), + ); + }); + expect(onCommit).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("does not reset or commit from a disabled slider reset button", () => { + const onCommit = vi.fn(); + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const reset = host.querySelector('[data-flat-slider-reset="true"]'); + expect(reset?.disabled).toBe(true); + act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("commits the latest draft when pointer capture is cancelled", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), + ); + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }), + ); + track.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 1 })); + }); + expect(onCommit).toHaveBeenLastCalledWith(80); + act(() => root.unmount()); + }); + + it("cancels a queued drag commit before resetting", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(1_000)); + const onCommit = vi.fn(); + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), + ); + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }), + ); + host + .querySelector('[data-flat-slider-reset="true"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + vi.advanceTimersByTime(100); + }); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("supports keyboard operation: focusable, arrow keys step, Home/End clamp to range", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + expect(track.getAttribute("tabindex")).toBe("0"); + expect(track.getAttribute("aria-valuemin")).toBe("0"); + expect(track.getAttribute("aria-valuemax")).toBe("100"); + act(() => { + track.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + }); + expect(onCommit).toHaveBeenLastCalledWith(51); + act(() => { + track.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); + }); + expect(onCommit).toHaveBeenLastCalledWith(0); + act(() => root.unmount()); + }); + + it("ignores the committed prop echoing back mid-drag (no knob snap-back)", () => { + const onCommit = vi.fn(); + function Harness() { + const [value, setValue] = React.useState(10); + return ( + { + onCommit(next); + setValue(next); + }} + /> + ); + } + const { host, root } = renderInto(); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + // Leading-edge commit fires at 30 and echoes back through the parent's + // state — mid-drag, that echo must NOT reset the draft. + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }), + ); + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }), + ); + }); + // Draft tracks the pointer (80), not the stale committed echo (30). + expect(track.getAttribute("aria-valuenow")).toBe("80"); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, clientX: 80, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenLastCalledWith(80); + expect(track.getAttribute("aria-valuenow")).toBe("80"); + act(() => root.unmount()); + }); +}); + +describe("FlatSelectRow", () => { + it("renders the default tier with no reset button", () => { + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + expect(select?.value).toBe("normal"); + expect(host.querySelector('[data-flat-select-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("renders the explicitCustom tier with a reset button and fires onReset", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + expect(select?.className).toContain("text-panel-accent"); + const reset = host.querySelector('[data-flat-select-reset="true"]'); + act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("fires onChange when the select value changes", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + if (!select) throw new Error("expected a select"); + act(() => { + select.value = "hidden"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onChange).toHaveBeenCalledWith("hidden"); + act(() => root.unmount()); + }); +}); + +describe("FlatSelectRow — label/value options", () => { + it("renders distinct labels for entries with a different display label than value", () => { + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + expect(select?.value).toBe("natural-lift"); + const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent); + expect(options).toEqual(["Neutral", "Natural Lift", "Fresh Pop"]); + act(() => root.unmount()); + }); + + it("still treats a bare string array as value===label (Plan 2 behavior unchanged)", () => { + const { host, root } = renderInto( + , + ); + const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent); + expect(options).toEqual(["normal", "multiply", "screen"]); + act(() => root.unmount()); + }); +}); + +describe("FlatToggle", () => { + it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const label = host.querySelector('[data-flat-toggle-label="true"]'); + expect(label?.className).toContain("text-panel-text-3"); + const pill = host.querySelector('[data-flat-toggle="true"]'); + expect(pill).not.toBeNull(); + act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onChange).toHaveBeenCalledWith(true); + act(() => root.unmount()); + }); + + it("renders the on state with an emphasized label and mint knob, and fires onChange(false) on click", () => { + const onChange = vi.fn(); + const { host, root } = renderInto(); + const label = host.querySelector('[data-flat-toggle-label="true"]'); + expect(label?.className).toContain("text-panel-text-2"); + const knob = host.querySelector('[data-flat-toggle-knob="true"]'); + expect(knob?.className).toContain("bg-panel-accent"); + const pill = host.querySelector('[data-flat-toggle="true"]'); + act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onChange).toHaveBeenCalledWith(false); + act(() => root.unmount()); + }); + + it("does not fire onChange when disabled", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const pill = host.querySelector('[data-flat-toggle="true"]'); + expect(pill?.disabled).toBe(true); + act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onChange).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx new file mode 100644 index 0000000000..472712d9b8 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -0,0 +1,576 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { RotateCcw } from "../../icons/SystemIcons"; +import { CommitField } from "./propertyPanelPrimitives"; +import { + VALUE_TIER_LABEL_CLASS, + VALUE_TIER_VALUE_CLASS, + type PropertyValueTier, +} from "./propertyPanelValueTier"; + +/* ------------------------------------------------------------------ */ +/* FlatRow — single-column label/value property row */ +/* ------------------------------------------------------------------ */ + +export function FlatRow({ + label, + value, + tier, + disabled, + liveCommit, + suffix, + dropdown, + onCommit, + onReset, +}: { + label: string; + value: string; + tier: PropertyValueTier; + disabled?: boolean; + liveCommit?: boolean; + suffix?: ReactNode; + /** Renders a trailing 10px caret-down, for select-backed rows. */ + dropdown?: boolean; + onCommit: (nextValue: string) => void; + onReset?: () => void; +}) { + return ( +
+ {label} + + + + + {suffix} + {tier === "explicitCustom" && onReset && ( + + )} + {dropdown && ( + + + + )} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatSegmentedRow — inline glyph runs, no container background */ +/* ------------------------------------------------------------------ */ + +export interface FlatSegmentOption { + key: string; + node: ReactNode; + active: boolean; +} + +export function FlatSegmentedRow({ + label, + options, + disabled, + /** Index (0-based) after which to render a 12px spacer — for combined rows + * like Text's "Case · Style", which pack two independent option groups. */ + spacerAfterIndex, + onChange, +}: { + label: string; + options: FlatSegmentOption[]; + disabled?: boolean; + spacerAfterIndex?: number; + onChange: (nextKey: string) => void; +}) { + return ( +
+ {label} + + {options.map((option, index) => ( + + + {spacerAfterIndex === index && + ))} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatGroupHeader — one-open-at-a-time accordion group header */ +/* (fixed-headers + scrollable-open-section layout, design_handoff */ +/* scrollable-open-section): renders ONLY the header bar — collapsed */ +/* button, or open-state title bar with the collapse control. Never */ +/* positioned (no sticky, no stacking offsets) — it always sits in */ +/* normal document flow. The open group's body content is rendered by */ +/* PropertyPanelFlat.tsx directly, in a dedicated scrollable region, */ +/* not as children here. */ +/* ------------------------------------------------------------------ */ + +export function FlatGroupHeader({ + title, + isOpen, + onToggleOpen, + accessory, + summary, + animateEntrance, +}: { + title: string; + isOpen: boolean; + onToggleOpen: () => void; + accessory?: ReactNode; + summary?: string; + /** Play the fast entrance animation on this render — set only for the one + * group(s) actually transitioning (see PropertyPanelFlat's justToggledIds). + * Not derived from `isOpen`/remounting alone: React's key-based diffing + * can still shift an unrelated collapsed sibling's position in the + * before/after-open arrays (e.g. when the newly opened group isn't + * adjacent to the previously open one), and Chromium restarts a CSS + * entrance animation on such a position change even though nothing about + * that sibling actually changed — gating explicitly avoids that replay. */ + animateEntrance?: boolean; +}) { + if (!isOpen) { + return ( + + ); + } + + return ( +
+ {title} + + {accessory} + + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatSlider — full-width label/track/value row */ +/* ------------------------------------------------------------------ */ + +/** Keyboard target for a slider keydown, or null for keys we don't handle. */ +function sliderKeyTarget( + key: string, + current: number, + min: number, + max: number, + step: number, +): number | null { + if (key === "Home") return min; + if (key === "End") return max; + const deltas: Record = { + ArrowLeft: -step, + ArrowDown: -step, + ArrowRight: step, + ArrowUp: step, + PageDown: -step * 10, + PageUp: step * 10, + }; + const delta = deltas[key]; + if (delta === undefined) return null; + return Math.max(min, Math.min(max, current + delta)); +} + +export function FlatSlider({ + label, + value, + min, + max, + step = 1, + tier, + displayValue, + disabled, + centerTick, + onReset, + onCommit, +}: { + label: string; + value: number; + min: number; + max: number; + step?: number; + tier: "default" | "explicitCustom"; + displayValue: string; + disabled?: boolean; + centerTick?: boolean; + onReset?: () => void; + onCommit: (nextValue: number) => void; +}) { + // `draft` gives the knob instant, drag-local visual feedback. `onCommit` is + // throttled (not debounced) to at most once per 40ms: a real drag fires + // pointermove faster than that, and a pure debounce (reset the timer on + // every move) never commits until the pointer pauses or lifts — which kills + // live preview updates during a continuous drag. Throttling still fires on + // the leading edge and on a trailing timer, so the preview keeps updating + // while dragging, with an immediate flush on release for the final value. + const [draft, setDraft] = useState(value); + const draftRef = useRef(value); + const commitTimerRef = useRef | null>(null); + const lastCommitAtRef = useRef(0); + const pendingRef = useRef(null); + // True from pointerdown to pointerup/cancel. While dragging, the committed + // prop echoing back through the parent must NOT reset `draft` — the echo is + // up to 40ms stale (throttled commit), and syncing it mid-drag snaps the + // knob backwards under the user's pointer. + const draggingRef = useRef(false); + // Tracks the last value actually sent to onCommit — separate from `value` + // (the committed prop) because in a single pointerdown+pointerup click the + // leading-edge commit fires before the parent has re-rendered with the new + // prop, so the release flush must dedupe against what we just sent, not + // against the stale prop, or the same value commits twice. + const lastCommittedRef = useRef(value); + + useEffect(() => { + if (draggingRef.current) return; + draftRef.current = value; + setDraft(value); + lastCommittedRef.current = value; + }, [value]); + useEffect( + () => () => { + if (commitTimerRef.current) clearTimeout(commitTimerRef.current); + }, + [], + ); + + const clampedPct = Math.max(0, Math.min(100, ((draft - min) / Math.max(max - min, 1e-6)) * 100)); + + const setDraftValue = (nextDraft: number) => { + draftRef.current = nextDraft; + setDraft(nextDraft); + }; + + const stepFromClientX = (clientX: number, rect: DOMRect) => { + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1))); + const raw = min + ratio * (max - min); + const stepped = Math.round(raw / step) * step; + return Math.max(min, Math.min(max, stepped)); + }; + const commitDraft = (nextDraft: number) => { + if (commitTimerRef.current) { + clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + pendingRef.current = null; + lastCommitAtRef.current = Date.now(); + if (nextDraft !== lastCommittedRef.current) { + lastCommittedRef.current = nextDraft; + onCommit(nextDraft); + } + }; + const scheduleCommit = (nextDraft: number) => { + const elapsed = Date.now() - lastCommitAtRef.current; + if (elapsed >= 40) { + commitDraft(nextDraft); + return; + } + pendingRef.current = nextDraft; + if (!commitTimerRef.current) { + commitTimerRef.current = setTimeout(() => { + commitTimerRef.current = null; + if (pendingRef.current !== null) commitDraft(pendingRef.current); + }, 40 - elapsed); + } + }; + const cancelPendingCommit = () => { + if (commitTimerRef.current) clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + pendingRef.current = null; + }; + const finishPointerDrag = (nextDraft?: number) => { + if (!draggingRef.current) return; + draggingRef.current = false; + const finalDraft = nextDraft ?? draftRef.current; + setDraftValue(finalDraft); + commitDraft(finalDraft); + }; + + return ( +
+ {label} +
{ + if (disabled) return; + draggingRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); + setDraftValue(stepped); + scheduleCommit(stepped); + }} + onPointerMove={(e) => { + if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return; + const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); + setDraftValue(stepped); + scheduleCommit(stepped); + }} + onPointerUp={(e) => { + if (disabled) return; + const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); + finishPointerDrag(stepped); + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }} + onPointerCancel={(e) => { + if (!disabled) finishPointerDrag(); + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }} + onLostPointerCapture={() => { + if (!disabled) finishPointerDrag(); + }} + onKeyDown={(e) => { + if (disabled) return; + const next = sliderKeyTarget(e.key, draft, min, max, step); + if (next === null) return; + e.preventDefault(); + setDraftValue(next); + commitDraft(next); + }} + > +
+ {centerTick && ( +
+ )} + {tier === "explicitCustom" && ( +
+ )} +
+
+
+ + {displayValue} + + {(centerTick || onReset) && ( + + {tier === "explicitCustom" && onReset && ( + + )} + + )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatSelectRow — label/value row backed by a native onChange(e.target.value)} + className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`} + > + {normalizedOptions.map((option) => ( + + ))} + + + + + + {tier === "explicitCustom" && onReset && ( + + )} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatToggle — 24×14 pill switch */ +/* ------------------------------------------------------------------ */ + +export function FlatToggle({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled?: boolean; + onChange: (next: boolean) => void; +}) { + return ( +
+ + {label} + + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts new file mode 100644 index 0000000000..dff96a42f2 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; + +describe("formatStrokeSummary", () => { + it("formats width and style into one string", () => { + expect(formatStrokeSummary(1, "solid")).toBe("1px solid"); + expect(formatStrokeSummary(2.5, "dashed")).toBe("2.5px dashed"); + expect(formatStrokeSummary(0, "none")).toBe("0px none"); + }); +}); + +describe("parseStrokeSummary", () => { + it("parses a well-formed summary back into width and style", () => { + expect(parseStrokeSummary("1px solid")).toEqual({ widthPx: 1, style: "solid" }); + expect(parseStrokeSummary(" 2.5px dashed ")).toEqual({ widthPx: 2.5, style: "dashed" }); + }); + + it("returns null for unparseable input", () => { + expect(parseStrokeSummary("garbage")).toBeNull(); + expect(parseStrokeSummary("")).toBeNull(); + expect(parseStrokeSummary("1px")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts new file mode 100644 index 0000000000..56939a48d0 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts @@ -0,0 +1,27 @@ +// Mirrors legacy `propertyPanelStyleSections.tsx`'s `SelectField` "Style" options — +// the single source of truth for which border-style tokens are valid. +export const STROKE_STYLE_OPTIONS: string[] = [ + "none", + "solid", + "dashed", + "dotted", + "double", + "hidden", + "groove", + "ridge", + "inset", + "outset", +]; + +export function formatStrokeSummary(widthPx: number, style: string): string { + return `${widthPx}px ${style}`; +} + +export function parseStrokeSummary(text: string): { widthPx: number; style: string } | null { + const match = /^\s*(-?\d+(?:\.\d+)?)px\s+(\S+)\s*$/.exec(text); + if (!match) return null; + const widthPx = Number.parseFloat(match[1]); + const style = match[2]; + if (!Number.isFinite(widthPx) || !style) return null; + return { widthPx, style }; +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx new file mode 100644 index 0000000000..018f17ca50 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -0,0 +1,598 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; +import type { DomEditSelection } from "./domEditing"; +import { buildDefaultGradientModel, serializeGradient } from "./gradientValue"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeElement(overrides: Partial = {}): DomEditSelection { + return { + element: document.createElement("div"), + id: "stat-card", + selector: ".stat-card", + label: "Stat Card", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 24, y: 120, width: 420, height: 260 }, + textContent: "", + dataAttributes: {}, + inlineStyles: { "background-color": "#0D0C09" }, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderSection( + styles: Record = {}, + overrides: Partial = {}, + gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = null, +) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeElement(overrides); + const onSetStyle = vi.fn(); + const mergedStyles = { "background-color": "#0D0C09", "border-width": "0px", ...styles }; + act(() => { + root.render( + , + ); + }); + return { host, root, onSetStyle }; +} + +function clickSegment(host: HTMLElement, label: string) { + const segment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find( + (el) => el.textContent === label, + ); + act(() => segment?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); +} + +describe("FlatStyleSection — Fill", () => { + it("renders the Fill segmented control defaulting to Solid, and a mint Color row when a color is set", () => { + const { host, root } = renderSection(); + expect(host.textContent).toContain("Fill"); + expect(host.textContent).toContain("Solid"); + const swatch = host.querySelector('[data-flat-color-trigger="true"]'); + expect(swatch).not.toBeNull(); + act(() => root.unmount()); + }); + + it("switches to the Gradient field when Gradient is selected", () => { + const { host, root } = renderSection({ + "background-image": "linear-gradient(90deg, #000, #fff)", + }); + const gradientSegment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find( + (el) => el.textContent === "Gradient", + ); + expect(gradientSegment?.className).toContain("text-panel-text-0"); + act(() => root.unmount()); + }); + + it("clicking Gradient commits a serialized default gradient built from the current fill color", () => { + const { host, root, onSetStyle } = renderSection(); + clickSegment(host, "Gradient"); + const expectedGradient = serializeGradient(buildDefaultGradientModel("#0D0C09")); + expect(onSetStyle).toHaveBeenCalledWith("background-image", expectedGradient); + act(() => root.unmount()); + }); + + it("clicking Solid clears the background-image back to none", () => { + const { host, root, onSetStyle } = renderSection({ + "background-image": "linear-gradient(90deg, #000, #fff)", + }); + clickSegment(host, "Solid"); + expect(onSetStyle).toHaveBeenCalledWith("background-image", "none"); + act(() => root.unmount()); + }); + + it("clicking Image switches to the image-fill field without committing a style", () => { + const { host, root, onSetStyle } = renderSection(); + clickSegment(host, "Image"); + expect(host.textContent).toContain("Upload image"); + expect(onSetStyle).not.toHaveBeenCalledWith("background-image", expect.anything()); + act(() => root.unmount()); + }); +}); + +function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement { + const rows = Array.from(host.querySelectorAll(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + const input = row?.querySelector("input"); + if (!input) throw new Error(`expected an input for row "${label}"`); + return input; +} + +async function commitFlatRowInput(host: HTMLElement, label: string, nextValue: string) { + const input = getFlatRowInput(host, label); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +const STROKE_STYLES = { + "border-width": "1px", + "border-style": "solid", + "border-color": "rgba(255,255,255,.12)", +}; + +function getMetricFieldInput(host: HTMLElement, label: string): HTMLInputElement { + const spans = Array.from(host.querySelectorAll("span")).filter((el) => el.textContent === label); + for (const span of spans) { + const input = span.parentElement?.querySelector("input"); + if (input) return input; + } + throw new Error(`expected a metric field input for "${label}"`); +} + +function setInputValue(input: HTMLInputElement, nextValue: string) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("FlatStyleSection — Stroke and Radius", () => { + it("renders the combined stroke row and commits width+style together on blur", () => { + const { host, root } = renderSection(STROKE_STYLES); + expect(host.textContent).toContain("Stroke"); + expect(getFlatRowInput(host, "Stroke").value).toBe("1px solid"); + act(() => root.unmount()); + }); + + it("commits the stroke row's new width and style together on blur", async () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + await commitFlatRowInput(host, "Stroke", "2px dashed"); + expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px"); + expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed"); + act(() => root.unmount()); + }); + + it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + await commitFlatRowInput(host, "Stroke", "9999px solid"); + expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px"); + act(() => root.unmount()); + }); + + it("rejects a stroke commit whose style token is not a valid border-style (fix 2)", async () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + await commitFlatRowInput(host, "Stroke", "12px bogus"); + expect(onSetStyle).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("commits a stroke style change through the discoverable Stroke style select (fix 2)", () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + changeFlatSelectRow(host, "Stroke style", "dashed"); + expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed"); + act(() => root.unmount()); + }); + + it("commits a new stroke color through the flat ColorField (fix 1)", () => { + const { host, root, onSetStyle } = renderSection({ + "border-width": "1px", + "border-style": "solid", + "border-color": "rgb(10, 20, 30)", + }); + const trigger = Array.from( + host.querySelectorAll('[data-flat-color-trigger="true"]'), + ).find((btn) => btn.getAttribute("aria-label") === "Pick stroke color color"); + if (!trigger) throw new Error("expected the stroke color trigger"); + act(() => trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const hexInput = Array.from(document.querySelectorAll("input")).find( + (input) => !host.contains(input), + ); + if (!hexInput) throw new Error("expected the color picker's hex input"); + act(() => setInputValue(hexInput, "#112233")); + expect(onSetStyle).toHaveBeenCalledWith("border-color", "rgb(17, 34, 51)"); + act(() => root.unmount()); + }); + + it("uses BorderRadiusEditor for radius, linked by default, even when corners are uniform (fix 3)", () => { + const { host, root } = renderSection({ "border-radius": "12px" }); + const unlinkButton = host.querySelector('button[title="Unlink corners"]'); + expect(unlinkButton).not.toBeNull(); + expect(getMetricFieldInput(host, "All").value).toBe("12"); + act(() => root.unmount()); + }); + + it("commits a uniform radius value through BorderRadiusEditor's linked All field", () => { + const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" }); + const allInput = getMetricFieldInput(host, "All"); + act(() => setInputValue(allInput, "20")); + act(() => allInput.dispatchEvent(new Event("focusout", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px"); + act(() => root.unmount()); + }); + + it("commits a single-corner radius update after unlinking a uniform radius (fix 3)", () => { + const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" }); + const unlinkButton = host.querySelector('button[title="Unlink corners"]'); + if (!unlinkButton) throw new Error("expected the unlink toggle button"); + act(() => unlinkButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const trInput = getMetricFieldInput(host, "TR"); + act(() => setInputValue(trInput, "18")); + act(() => trInput.dispatchEvent(new Event("focusout", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px"); + expect(onSetStyle).not.toHaveBeenCalledWith("border-radius", expect.anything()); + act(() => root.unmount()); + }); + + it("falls back to the legacy BorderRadiusEditor when corners are not uniform", () => { + const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 }); + expect(host.textContent).not.toContain("Linked"); + act(() => root.unmount()); + }); + + it("commits a per-corner radius update through the legacy BorderRadiusEditor when unlinked", () => { + const { host, root, onSetStyle } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 }); + const trInput = Array.from(host.querySelectorAll("input")).find( + (el) => el.value === "12", + ); + if (!trInput) throw new Error("expected the TR corner input"); + act(() => setInputValue(trInput, "18")); + act(() => { + trInput.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px"); + act(() => root.unmount()); + }); +}); + +function getFlatSelectRow(host: HTMLElement, label: string) { + const rows = Array.from(host.querySelectorAll(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + if (!row) throw new Error(`expected a select row for "${label}"`); + const select = row.querySelector("select"); + if (!select) throw new Error(`expected a select for "${label}"`); + const resetButton = row.querySelector('[data-flat-select-reset="true"]'); + return { row, select, resetButton }; +} + +function changeFlatSelectRow(host: HTMLElement, label: string, nextValue: string) { + const { select } = getFlatSelectRow(host, label); + act(() => { + select.value = nextValue; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("FlatStyleSection — Shadow and Blend", () => { + it("renders Shadow with the inferred preset and a reset when set, Blend with a plain select", () => { + const { host, root } = renderSection({ "box-shadow": "0 8px 24px rgba(0,0,0,.35)" }); + expect(host.textContent).toContain("Shadow"); + expect(host.textContent).toContain("Blend"); + const selects = host.querySelectorAll("select"); + expect(selects.length).toBeGreaterThanOrEqual(2); + act(() => root.unmount()); + }); + + it("commits a shadow preset change through onSetStyle", () => { + const { host, root, onSetStyle } = renderSection({}); + changeFlatSelectRow(host, "Shadow", "soft"); + expect(onSetStyle).toHaveBeenCalledWith("box-shadow", expect.any(String)); + act(() => root.unmount()); + }); + + it("commits a blend mode change through onSetStyle", () => { + const { host, root, onSetStyle } = renderSection({}); + changeFlatSelectRow(host, "Blend", "multiply"); + expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "multiply"); + act(() => root.unmount()); + }); + + it("resets the shadow preset back to none via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ + "box-shadow": "0 12px 36px rgba(0, 0, 0, 0.28)", + }); + const { resetButton } = getFlatSelectRow(host, "Shadow"); + if (!resetButton) throw new Error("expected the shadow reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("box-shadow", "none"); + act(() => root.unmount()); + }); + + it("resets the blend mode back to normal via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ "mix-blend-mode": "multiply" }); + const { resetButton } = getFlatSelectRow(host, "Blend"); + if (!resetButton) throw new Error("expected the blend reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "normal"); + act(() => root.unmount()); + }); +}); + +describe("FlatStyleSection — blur sliders", () => { + it("renders Layer blur and Backdrop sliders and commits through onSetStyle", () => { + const onSetStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("Layer blur"); + expect(host.textContent).toContain("Backdrop"); + expect(host.textContent).toContain("4px"); + const track = host.querySelectorAll('[data-flat-slider-track="true"]')[0]; + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); + }); + // filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px. + expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)"); + act(() => root.unmount()); + }); + + it("renders the Backdrop slider from backdrop-filter and commits a new blur value on track click", () => { + const { host, root, onSetStyle } = renderSection({ "backdrop-filter": "blur(6px)" }); + expect(host.textContent).toContain("Backdrop"); + expect(host.textContent).toContain("6px"); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + // Track order is Layer blur, Backdrop, Opacity — Backdrop is the second track. + const backdropTrack = tracks[1]; + Object.defineProperty(backdropTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + backdropTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); + }); + // backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px. + expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)"); + act(() => root.unmount()); + }); + + it("does not render a fill/knob highlight for a zero-value blur (default tier)", () => { + const { host, root } = renderSection({}); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + // Only the first two tracks are the blur sliders (Layer blur, Backdrop); Opacity + // (the third track) always renders a fill by design, so it's excluded here. + const blurTracks = Array.from(tracks).slice(0, 2); + for (const track of blurTracks) { + expect(track.querySelectorAll('[data-flat-slider-fill="true"]')).toHaveLength(0); + } + act(() => root.unmount()); + }); +}); + +function getInsetSideInputOrNull(host: HTMLElement, label: "T" | "R" | "B" | "L") { + const span = Array.from(host.querySelectorAll("span")).find((el) => el.textContent === label); + return span?.parentElement?.querySelector("input") ?? null; +} + +function getInsetSideInput(host: HTMLElement, label: "T" | "R" | "B" | "L"): HTMLInputElement { + const input = getInsetSideInputOrNull(host, label); + if (!input) throw new Error(`expected an inset side input for "${label}"`); + return input; +} + +async function commitInsetSideInput( + host: HTMLElement, + label: "T" | "R" | "B" | "L", + nextValue: string, +) { + const input = getInsetSideInput(host, label); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + await Promise.resolve(); + }); +} + +describe("FlatStyleSection — Overflow and Mask", () => { + it("renders Overflow and Mask selects, and inset-side rows when the mask is an inset", () => { + const { host, root } = renderSection({ + overflow: "hidden", + "clip-path": "inset(8px round 4px)", + }); + expect(host.textContent).toContain("Overflow"); + expect(host.textContent).toContain("Mask"); + expect(host.textContent).toContain("hidden"); + act(() => root.unmount()); + }); + + it("commits an overflow change through onSetStyle", () => { + const onSetStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const overflowSelect = Array.from(host.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "scroll"), + ); + if (!overflowSelect) throw new Error("expected the overflow select"); + act(() => { + overflowSelect.value = "hidden"; + overflowSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onSetStyle).toHaveBeenCalledWith("overflow", "hidden"); + act(() => root.unmount()); + }); + + it("resets overflow back to visible via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ overflow: "scroll" }); + const { resetButton } = getFlatSelectRow(host, "Overflow"); + if (!resetButton) throw new Error("expected the overflow reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("overflow", "visible"); + act(() => root.unmount()); + }); + + it("commits a mask preset change through onSetStyle, building an inset() clip-path", () => { + const { host, root, onSetStyle } = renderSection({}); + changeFlatSelectRow(host, "Mask", "inset"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(0 round 0px)"); + act(() => root.unmount()); + }); + + it("resets the mask back to none via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "circle(50% at 50% 50%)" }); + const { resetButton } = getFlatSelectRow(host, "Mask"); + if (!resetButton) throw new Error("expected the mask reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "none"); + act(() => root.unmount()); + }); + + it("does not render inset-side rows when the mask is not an inset", () => { + const { host, root } = renderSection({ "clip-path": "circle(50% at 50% 50%)" }); + expect(getInsetSideInputOrNull(host, "T")).toBeNull(); + act(() => root.unmount()); + }); + + it("commits a T inset-side edit through onSetStyle, preserving the other sides and radius", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "T", "10"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(10px 8px 8px 8px round 4px)"); + act(() => root.unmount()); + }); + + it("commits an L inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "L", "2"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 8px 2px round 4px)"); + act(() => root.unmount()); + }); + + it("commits an R inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "R", "3"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 3px 8px 8px round 4px)"); + act(() => root.unmount()); + }); + + it("commits a B inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "B", "5"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)"); + act(() => root.unmount()); + }); + + it("renders a uniform Mask inset slider and commits clip-path via buildInsetClipPathValue (fix 4)", () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + expect(host.textContent).toContain("Mask inset"); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + // Track order: Layer blur, Backdrop, Mask inset, Opacity. + const maskInsetTrack = tracks[2]; + Object.defineProperty(maskInsetTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + maskInsetTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + maskInsetTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); + }); + // clipInsetValue=8 -> max=Math.max(120, 8)=120; clientX=50 of width 100 -> ratio 0.5 -> 60px. + // border-radius is unset here, so the clip-path's own `round 4px` is not reused — radiusValue + // (read from the `border-radius` style, matching legacy) is 0. + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(60px round 0px)"); + act(() => root.unmount()); + }); +}); + +describe("FlatStyleSection — Opacity", () => { + it("renders the Opacity slider at 100% by default and commits a change through onSetStyle", () => { + const onSetStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("Opacity"); + expect(host.textContent).toContain("100%"); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + const opacityTrack = tracks[tracks.length - 1]; + Object.defineProperty(opacityTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + opacityTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); + }); + expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx new file mode 100644 index 0000000000..6dbc093164 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -0,0 +1,600 @@ +// fallow-ignore-file code-duplication +import { useEffect, useState } from "react"; +import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; +import { buildDefaultGradientModel, serializeGradient } from "./gradientValue"; +import { BorderRadiusEditor } from "./BorderRadiusEditor"; +import { + formatStrokeSummary, + parseStrokeSummary, + STROKE_STYLE_OPTIONS, +} from "./propertyPanelFlatStyleHelpers"; +import { + buildBoxShadowPresetValue, + buildClipPathValue, + buildInsetClipPathSides, + buildInsetClipPathValue, + buildStrokeStyleUpdates, + buildStrokeWidthStyleUpdates, + extractBackgroundImageUrl, + formatNumericValue, + formatPxMetricValue, + getClipPathInsetPx, + getCssFilterFunctionPx, + inferBoxShadowPreset, + inferClipPathPreset, + normalizePanelPxValue, + parseInsetClipPathSides, + parseNumericValue, + parsePxMetricValue, + setCssFilterFunctionPx, + type BoxShadowPreset, + type ClipPathInsetSides, +} from "./propertyPanelHelpers"; +import { + FlatRow, + FlatSegmentedRow, + FlatSelectRow, + FlatSlider, +} from "./propertyPanelFlatPrimitives"; +import { MetricField } from "./propertyPanelPrimitives"; +import { resolveValueTier } from "./propertyPanelValueTier"; +import { ColorField } from "./propertyPanelColor"; +import { GradientField, ImageFillField } from "./propertyPanelFill"; + +/* ------------------------------------------------------------------ */ +/* Flat Fill sub-block (design_handoff_studio_inspector, #11a) */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatFillFields({ + projectId, + element, + styles, + assets, + onSetStyle, + onImportAssets, +}: { + projectId: string; + element: DomEditSelection; + styles: Record; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; +}) { + const styleEditingDisabled = !element.capabilities.canEditStyles; + const backgroundImage = styles["background-image"] ?? "none"; + const hasTextControls = isTextEditableSelection(element); + const fillMode = + backgroundImage && backgroundImage !== "none" + ? backgroundImage.includes("gradient") + ? "Gradient" + : "Image" + : "Solid"; + const [preferredFillMode, setPreferredFillMode] = useState(fillMode); + const imageUrl = extractBackgroundImageUrl(backgroundImage); + + useEffect(() => { + setPreferredFillMode(fillMode); + }, [fillMode, element.id, element.selector, backgroundImage]); + + const handleFillModeChange = (nextMode: string) => { + setPreferredFillMode(nextMode); + if (nextMode === "Solid") { + onSetStyle("background-image", "none"); + return; + } + if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) { + onSetStyle( + "background-image", + serializeGradient(buildDefaultGradientModel(styles["background-color"])), + ); + } + }; + + return ( + <> + + {preferredFillMode === "Solid" ? ( + onSetStyle("background-color", next)} + /> + ) : preferredFillMode === "Gradient" ? ( + onSetStyle("background-image", next)} + /> + ) : ( + onSetStyle("background-image", next)} + onImportAssets={onImportAssets} + /> + )} + {!hasTextControls && ( + onSetStyle("color", next)} + /> + )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Stroke row — combined width+style+color */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatStrokeRow({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const borderWidthValue = + parsePxMetricValue(styles["border-width"] ?? "") ?? + parsePxMetricValue(styles["border-top-width"] ?? "") ?? + 0; + const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none"; + const borderColorValue = + styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)"; + const summary = formatStrokeSummary(borderWidthValue, borderStyleValue); + const tier = resolveValueTier( + styles["border-width"] != null || styles["border-style"] != null ? summary : undefined, + formatStrokeSummary(0, "none"), + ); + + return ( + <> + { + const parsed = parseStrokeSummary(next); + if (!parsed) return; + if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return; + const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, { + min: 0, + max: 200, + fallback: borderWidthValue, + }); + if (!normalizedWidth) return; + for (const [property, value] of buildStrokeWidthStyleUpdates( + normalizedWidth, + parsed.style, + )) { + await onSetStyle(property, value); + } + for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) { + await onSetStyle(property, value); + } + }} + suffix={ + <> + + {borderColorValue} + + } + /> + { + for (const [property, value] of buildStrokeStyleUpdates( + next, + formatPxMetricValue(borderWidthValue), + )) { + await onSetStyle(property, value); + } + }} + /> + onSetStyle("border-color", next)} + /> + + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Radius row — always delegates to BorderRadiusEditor */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatRadiusRow({ + styles, + gsapBorderRadius, + disabled, + onSetStyle, +}: { + styles: Record; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + 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 commit = (corner: "all" | "tl" | "tr" | "br" | "bl", value: number) => { + const px = `${formatNumericValue(value)}px`; + if (corner === "all") { + void onSetStyle("border-radius", px); + return; + } + const prop = { + tl: "border-top-left-radius", + tr: "border-top-right-radius", + br: "border-bottom-right-radius", + bl: "border-bottom-left-radius", + }[corner]; + void onSetStyle(prop, px); + }; + + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Shadow + Blend rows */ +/* ------------------------------------------------------------------ */ + +function FlatShadowBlendRows({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]); + const blendValue = styles["mix-blend-mode"] || "normal"; + + return ( + <> + { + if (next === "custom") return; + void onSetStyle( + "box-shadow", + buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]), + ); + }} + onReset={() => void onSetStyle("box-shadow", "none")} + /> + void onSetStyle("mix-blend-mode", next)} + onReset={() => void onSetStyle("mix-blend-mode", "normal")} + /> + + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Layer blur + Backdrop sliders */ +/* ------------------------------------------------------------------ */ + +function FlatBlurSliders({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur"); + const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur"); + + return ( + <> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(filterBlurValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next)) + } + /> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(backdropBlurValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle( + "backdrop-filter", + setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next), + ) + } + /> + + ); +} + +// Flat Overflow + Mask rows (+ inset sides). +function FlatOverflowMaskRows({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; + const clipPathValue = styles["clip-path"] || "none"; + const clipPathPreset = inferClipPathPreset(clipPathValue); + + return ( + <> + void onSetStyle("overflow", next)} + onReset={() => void onSetStyle("overflow", "visible")} + /> + { + if (next === "custom") return; + void onSetStyle( + "clip-path", + buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue), + ); + }} + onReset={() => void onSetStyle("clip-path", "none")} + /> + + + ); +} + +// Flat Mask inset — uniform slider + per-side fields. +function FlatMaskInsetRows({ + clipPathValue, + radiusValue, + disabled, + onSetStyle, +}: { + clipPathValue: string; + radiusValue: number; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const clipPathPreset = inferClipPathPreset(clipPathValue); + const parsedClipInsets = parseInsetClipPathSides(clipPathValue); + const clipInsetValue = getClipPathInsetPx(clipPathValue); + const clipInsetSides = parsedClipInsets ?? { + top: clipInsetValue, + right: clipInsetValue, + bottom: clipInsetValue, + left: clipInsetValue, + radius: radiusValue, + }; + const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null; + + const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => { + const next = parsePxMetricValue(nextValue); + if (next == null) return; + const sides: ClipPathInsetSides = { + top: clipInsetSides.top, + right: clipInsetSides.right, + bottom: clipInsetSides.bottom, + left: clipInsetSides.left, + }; + sides[side] = next; + void onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius)); + }; + + return ( + <> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(clipInsetValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue)) + } + /> + {showClipInsetSides && ( +
+ commitClipInsetSide("top", next)} + /> + commitClipInsetSide("right", next)} + /> + commitClipInsetSide("bottom", next)} + /> + commitClipInsetSide("left", next)} + /> +
+ )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Opacity slider */ +/* ------------------------------------------------------------------ */ + +function FlatOpacitySlider({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100); + + return ( + void onSetStyle("opacity", formatNumericValue(next / 100))} + /> + ); +} + +export function FlatStyleSection({ + projectId, + element, + styles, + assets, + onSetStyle, + onImportAssets, + gsapBorderRadius, +}: { + projectId: string; + element: DomEditSelection; + styles: Record; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; +}) { + const styleEditingDisabled = !element.capabilities.canEditStyles; + return ( +
+ + + + + + + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx new file mode 100644 index 0000000000..b7164b7183 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -0,0 +1,441 @@ +// @vitest-environment happy-dom + +import React, { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection"; +import type { DomEditSelection, DomEditTextField } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +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 }; +} + +const FIELDS = [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, + { + key: "b", + label: "Text", + value: "Subhead", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, +]; + +describe("FlatTextLayerList", () => { + it("falls back to a numbered label per index for empty fields, not a bare 'Text'", () => { + const emptyFields = [ + { ...FIELDS[0], value: "" }, + { ...FIELDS[1], value: "" }, + ]; + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Text 1"); + expect(host.textContent).toContain("Text 2"); + act(() => root.unmount()); + }); + + it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => { + const onSelect = vi.fn(); + const onAdd = vi.fn(); + const onRemove = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Headline"); + expect(host.textContent).toContain("Subhead"); + + const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + expect((rows[1] as HTMLElement).getAttribute("data-active")).toBe("false"); + + act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSelect).toHaveBeenCalledWith("b"); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onAdd).toHaveBeenCalledTimes(1); + + const removeButton = host.querySelector( + '[data-flat-text-layer-remove="true"]', + ); + act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onRemove).toHaveBeenCalledWith("a"); + // stopPropagation on the remove button must prevent the row's own onClick + // from also firing onSelect for the removed field's key. + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalledWith("a"); + act(() => root.unmount()); + }); +}); + +function makeMultiFieldElement(): DomEditSelection { + return { + element: document.createElement("div"), + id: "multi", + selector: ".multi", + label: "Multi", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 100, height: 100 }, + textContent: "Headline Subhead", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + { + key: "b", + label: "Text", + value: "Subhead", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + } as DomEditSelection; +} + +function makeSingleFieldElement(overrides: Partial = {}): DomEditSelection { + const base = makeMultiFieldElement(); + return { + ...base, + textFields: [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + ...overrides, + }, + ], + } as DomEditSelection; +} + +function segmentedRowButtons(host: HTMLElement, label: string): HTMLButtonElement[] { + const labelSpan = Array.from(host.querySelectorAll("span")).find( + (el) => el.textContent === label, + ); + const row = labelSpan?.parentElement; + return Array.from(row?.querySelectorAll('[data-flat-segment="true"]') ?? []); +} + +describe("FlatTextFieldEditor controls", () => { + it("commits text-transform: capitalize when the new 'Ag' case button is clicked", () => { + const onSetTextFieldStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + const capitalizeButton = segmentedRowButtons(host, "Case · Style").find( + (button) => button.textContent === "Ag", + ); + expect(capitalizeButton).not.toBeUndefined(); + act(() => capitalizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-transform", "capitalize"); + act(() => root.unmount()); + }); + + it("preserves text-align: end instead of coercing it to right", () => { + const onSetTextFieldStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + const alignButtons = segmentedRowButtons(host, "Align"); + const endButton = alignButtons.find((button) => button.textContent === "E"); + expect(endButton).not.toBeUndefined(); + expect(endButton?.className).toContain("border-panel-accent"); + act(() => endButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "end"); + act(() => root.unmount()); + }); + + it("live-commits the Size field on input, without requiring blur/Enter", async () => { + const onSetTextFieldStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + const sizeLabel = Array.from(host.querySelectorAll("span")).find( + (el) => el.textContent === "Size", + ); + const input = sizeLabel?.parentElement?.querySelector("input"); + if (!input) throw new Error("expected the Size row's input"); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, "24px"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + // liveCommit debounces on a 120ms timer — no blur/Enter dispatched here. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 160)); + }); + expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px"); + act(() => root.unmount()); + }); +}); + +describe("FlatTextSection — multi-field", () => { + it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("Headline"); + expect(host.textContent).toContain("Subhead"); + // Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor). + expect(host.textContent).toContain("Weight"); + // Exactly one "Text layers" micro-label — this component doesn't duplicate its own list. + const layerLabels = Array.from(host.querySelectorAll("div")).filter( + (el) => el.textContent === "Text layers", + ); + expect(layerLabels.length).toBeLessThanOrEqual(1); + + const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.textContent).toContain("Subhead"); + act(() => root.unmount()); + }); + + it("wires onAdd/onRemove end-to-end: async onAddTextField switches the active field once it appears in props, and the resync effect falls back to the first field when the active one disappears", async () => { + let addResolved = false; + + function Harness() { + const [fields, setFields] = useState(makeMultiFieldElement().textFields); + const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields }; + return ( + + Promise.resolve().then(() => { + addResolved = true; + setFields((prev) => [ + ...prev, + { + key: "c", + label: "Text", + value: "Third", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ]); + return "c"; + }) + } + onRemoveTextField={(fieldKey: string) => + setFields((prev) => prev.filter((field) => field.key !== fieldKey)) + } + /> + ); + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + let rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(addResolved).toBe(true); + rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(3); + expect((rows[2] as HTMLElement).getAttribute("data-active")).toBe("true"); + + // Remove the active field ("c") through the wired onRemoveTextField — the + // resync useEffect must fall back to the first remaining field ("a") + // since "c" no longer exists in element.textFields. + const removeButtons = host.querySelectorAll( + '[data-flat-text-layer-remove="true"]', + ); + act(() => { + removeButtons[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + + act(() => root.unmount()); + }); + + it("auto-focuses the Content textarea when a new text field is added", async () => { + let addResolved = false; + + function Harness() { + const [fields, setFields] = useState(makeMultiFieldElement().textFields); + const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields }; + return ( + + Promise.resolve().then(() => { + addResolved = true; + setFields((prev) => [ + ...prev, + { + key: "c", + label: "Text", + value: "", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ]); + return "c"; + }) + } + onRemoveTextField={vi.fn()} + /> + ); + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + // Wait for onAddTextField's promise to resolve (adds field "c" and makes it + // active) before checking focus, mirroring the async add-field pattern above. + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(addResolved).toBe(true); + + const contentTextarea = host.querySelector("textarea"); + expect(contentTextarea).not.toBeNull(); + expect(document.activeElement).toBe(contentTextarea); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx new file mode 100644 index 0000000000..4802f54527 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -0,0 +1,386 @@ +import { useEffect, useState } from "react"; +import { Plus, X } from "../../icons/SystemIcons"; +import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; +import type { ImportedFontAsset } from "./fontAssets"; +import { normalizeTextMetricValue, selectionIdentityKey } from "./propertyPanelHelpers"; +import { ColorField } from "./propertyPanelColor"; +import { FontFamilyField } from "./propertyPanelFont"; +import { PromotableControl } from "./PromotableControl"; +import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives"; +import { + resolveValueTier, + VALUE_TIER_LABEL_CLASS, + VALUE_TIER_VALUE_CLASS, +} from "./propertyPanelValueTier"; +import { + detectAvailableWeights, + formatTextFieldPreview, + getTextFieldColor, + getTextStyleValue, + TextAreaField, + WEIGHT_LABELS, +} from "./propertyPanelSections"; + +/* ------------------------------------------------------------------ */ +/* Flat text section (design_handoff_studio_inspector, #10a) */ +/* ------------------------------------------------------------------ */ + +const ALIGN_OPTIONS = [ + { key: "start", label: "start", node: "S" }, + { key: "left", label: "left", node: "L" }, + { key: "center", label: "center", node: "C" }, + { key: "right", label: "right", node: "R" }, + { key: "end", label: "end", node: "E" }, + { key: "justify", label: "justify", node: "J" }, +]; + +const CASE_OPTIONS = [ + { key: "none", node: "–" }, + { key: "uppercase", node: "AG" }, + { key: "lowercase", node: "ag" }, + { key: "capitalize", node: "Ag" }, +]; + +function FlatTextFieldEditor({ + field, + styles, + fontAssets, + onImportFonts, + onSetText, + onSetTextFieldStyle, + autoFocus = false, +}: { + field: DomEditSelection["textFields"][number]; + styles: Record; + fontAssets: ImportedFontAsset[]; + onImportFonts?: (files: FileList | File[]) => Promise; + onSetText: (value: string, fieldKey?: string) => void; + onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; + autoFocus?: boolean; +}) { + const weight = getTextStyleValue(field, styles, "font-weight", "400"); + const weightOptions = detectAvailableWeights( + field.computedStyles["font-family"] || styles["font-family"] || "", + ); + const align = getTextStyleValue(field, styles, "text-align", "start"); + const textTransform = getTextStyleValue(field, styles, "text-transform", "none"); + const fontStyle = getTextStyleValue(field, styles, "font-style", "normal"); + + return ( + <> + + {({ value, onCommit }) => ( + onSetText(next, field.key))} + /> + )} + + + {({ value, onCommit }) => ( + onSetTextFieldStyle(field.key, "font-family", next))} + /> + )} + + onSetTextFieldStyle(field.key, "font-size", next)} + /> +
+ + Weight + + +
+ + onSetTextFieldStyle( + field.key, + "letter-spacing", + normalizeTextMetricValue("letter-spacing", next), + ) + } + onReset={() => onSetTextFieldStyle(field.key, "letter-spacing", "")} + /> + + onSetTextFieldStyle( + field.key, + "line-height", + normalizeTextMetricValue("line-height", next), + ) + } + onReset={() => onSetTextFieldStyle(field.key, "line-height", "")} + /> + ({ + key: option.key, + node: option.node, + active: align === option.key, + }))} + onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)} + /> + ({ + key: option.key, + node: option.node, + active: textTransform === option.key, + })), + { key: "normal", node: "A", active: fontStyle === "normal" }, + { key: "italic", node: "A", active: fontStyle === "italic" }, + ]} + spacerAfterIndex={2} + onChange={(next) => { + if (next === "normal" || next === "italic") { + onSetTextFieldStyle(field.key, "font-style", next); + } else { + onSetTextFieldStyle(field.key, "text-transform", next); + } + }} + /> + + {({ value, onCommit }) => ( + onSetTextFieldStyle(field.key, "color", next))} + /> + )} + + + ); +} + +export function FlatTextSection({ + element, + styles, + fontAssets, + onImportFonts, + onSetText, + onSetTextFieldStyle, + onAddTextField, + onRemoveTextField, +}: { + element: DomEditSelection; + styles: Record; + fontAssets: ImportedFontAsset[]; + onImportFonts?: (files: FileList | File[]) => Promise; + onSetText: (value: string, fieldKey?: string) => void; + onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; + onAddTextField: (afterFieldKey?: string) => string | Promise | null; + onRemoveTextField: (fieldKey: string) => void; +}) { + const elementIdentity = selectionIdentityKey(element); + const firstTextFieldKey = element.textFields[0]?.key ?? null; + const [activeFieldKey, setActiveFieldKey] = useState(firstTextFieldKey); + + // A new element can expose the same text-field keys as the prior selection. + // Always return to its first field instead of preserving stale local state. + useEffect(() => { + setActiveFieldKey(firstTextFieldKey); + }, [elementIdentity, firstTextFieldKey]); + + useEffect(() => { + const nextFields = element.textFields; + setActiveFieldKey((current) => { + if (current && nextFields.some((field) => field.key === current)) return current; + return nextFields[0]?.key ?? null; + }); + }, [element.textFields]); + + if (!isTextEditableSelection(element)) return null; + const textFields = element.textFields; + const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0]; + if (!activeField) return null; + + if (textFields.length > 1) { + return ( +
+ + void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => { + if (nextKey) setActiveFieldKey(nextKey); + }) + } + onRemove={onRemoveTextField} + /> + +
+ ); + } + + return ( +
+ + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Multi-field layer list (design_handoff_studio_inspector, #10a — */ +/* no mock exists for this row; layout originated by this plan, */ +/* following the "left-rule nested content" convention established */ +/* by Text's own content block, Motion's effect cards, and Media's */ +/* cutout block. Flag for design review.) */ +/* ------------------------------------------------------------------ */ + +export function FlatTextLayerList({ + fields, + activeFieldKey, + styles, + onSelect, + onAdd, + onRemove, +}: { + fields: DomEditSelection["textFields"]; + activeFieldKey: string; + styles: Record; + onSelect: (fieldKey: string) => void; + onAdd: () => void; + onRemove: (fieldKey: string) => void; +}) { + return ( +
+
+ Text layers +
+
+ {fields.map((field, index) => { + const active = field.key === activeFieldKey; + return ( +
onSelect(field.key)} + className={`flex min-h-[26px] cursor-pointer items-center gap-2 rounded px-1 ${ + active ? "bg-panel-accent/10" : "hover:bg-panel-hover" + }`} + > + + + {formatTextFieldPreview(field.value) || `Text ${index + 1}`} + + + {field.tagName} + + {fields.length > 1 && ( + + )} +
+ ); + })} +
+ +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts new file mode 100644 index 0000000000..910934f800 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; +import type { DomEditSelection } from "./domEditingTypes"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; + +function withDataAttributes( + dataAttributes: Record, +): Pick { + return { dataAttributes }; +} + +describe("deriveElementTiming", () => { + it("uses the explicit data-start/data-duration attributes when duration is authored", () => { + const result = deriveElementTiming(withDataAttributes({ start: "8", duration: "4" })); + expect(result).toEqual({ start: 8, duration: 4, inferred: false }); + }); + + it("infers start/duration from animations when there is no explicit data-duration", () => { + const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation]; + const result = deriveElementTiming( + withDataAttributes({ start: "0", duration: "0" }), + animations, + ); + expect(result).toEqual({ start: 2, duration: 3, inferred: true }); + }); + + it("spans the earliest tween start to the latest tween end across multiple animations", () => { + const animations = [ + { position: 1, duration: 2 } as unknown as GsapAnimation, // 1 -> 3 + { position: 2, duration: 4 } as unknown as GsapAnimation, // 2 -> 6 + ]; + const result = deriveElementTiming(withDataAttributes({}), animations); + expect(result).toEqual({ start: 1, duration: 5, inferred: true }); + }); + + it("prefers an explicit data-duration over inference even when animations exist", () => { + const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation]; + const result = deriveElementTiming( + withDataAttributes({ start: "0", duration: "10" }), + animations, + ); + expect(result).toEqual({ start: 0, duration: 10, inferred: false }); + }); + + it("falls back to hf-authored-duration when data-duration is absent", () => { + const result = deriveElementTiming( + withDataAttributes({ start: "1", "hf-authored-duration": "6" }), + ); + expect(result).toEqual({ start: 1, duration: 6, inferred: false }); + }); + + it("returns a zero-duration, non-inferred result with no attributes and no animations", () => { + const result = deriveElementTiming(withDataAttributes({})); + expect(result).toEqual({ start: 0, duration: 0, inferred: false }); + }); + + // This is the exact bug from the whole-plan coherence review: Layout's + // keyframe-seek basis must land on the same absolute time that Motion's + // Timing row displays as the element's midpoint. + it("agrees with a keyframe-percentage seek: 50% lands on the same midpoint the Timing row would show", () => { + const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation]; + const timing = deriveElementTiming( + withDataAttributes({ start: "0", duration: "0" }), + animations, + ); + const seekTimeAt50Pct = timing.start + (50 / 100) * timing.duration; + const timingRowMidpoint = timing.start + timing.duration / 2; + expect(seekTimeAt50Pct).toBe(timingRowMidpoint); + expect(seekTimeAt50Pct).toBe(3.5); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.ts b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.ts new file mode 100644 index 0000000000..a4cb30121c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTimingDerivation.ts @@ -0,0 +1,59 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * The single source of truth for an element's clip start/duration in the flat + * inspector. Both the Motion group's Timing row (`FlatTimingRow`) and the + * Layout group's keyframe gutter (fed via `elStart`/`elDuration` from + * `PropertyPanel.tsx` through `PropertyPanelFlat.tsx`) must derive this the + * same way — otherwise a keyframe-percentage seek in Layout lands on a + * different absolute time than the range Motion displays for the same + * element (found by the Plan 3a+3b whole-plan coherence review). + * + * Precedence: an explicit `data-duration` (or `data-hf-authored-duration`) + * wins outright. Only when neither is present do we infer the range from the + * element's own GSAP tweens (earliest tween start → latest tween end). + * + * Scoped to the FLAT inspector only — the legacy (non-flat) panel keeps its + * own, unrelated `elStart`/`elDuration ?? 1` computation in `PropertyPanel.tsx` + * untouched. + */ +export interface ElementTiming { + start: number; + duration: number; + /** True when duration/start came from `deriveTimingFromAnimations`, not an authored attribute. */ + inferred: boolean; +} + +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 deriveElementTiming( + element: Pick, + animations: GsapAnimation[] = [], +): ElementTiming { + 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); + return { + start: derived ? derived.start : explicitStart, + duration: derived ? derived.duration : explicitDuration, + inferred: derived !== null, + }; +} diff --git a/packages/studio/src/components/editor/propertyPanelFont.test.tsx b/packages/studio/src/components/editor/propertyPanelFont.test.tsx new file mode 100644 index 0000000000..fe4d32a684 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFont.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FontFamilyField } from "./propertyPanelFont"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("FontFamilyField flat trigger", () => { + it("renders as a label/value row with a trailing dropdown caret, no boxed border", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const trigger = host.querySelector('[data-flat-font-trigger="true"]'); + expect(trigger).not.toBeNull(); + expect(trigger?.className).not.toContain("border-neutral-800"); + expect(host.textContent).toContain("JetBrains Mono"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index a0fd50d7ab..47f9cce4c7 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -123,12 +123,14 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void { export function FontFamilyField({ value, disabled, + flat, importedFonts, onImportFonts, onCommit, }: { value: string; disabled?: boolean; + flat?: boolean; importedFonts: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; onCommit: (nextValue: string) => void; @@ -366,6 +368,130 @@ export function FontFamilyField({ setOpen(false); }; + const dropdown = open && ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + } + if (e.key === "Enter" && filteredOptions[0]) { + e.preventDefault(); + commitFamily(filteredOptions[0]); + } + }} + className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600" + /> + {canQueryLocalFonts && ( + + )} + + { + await handleImportFonts(event.target.files); + event.target.value = ""; + }} + /> +
+ {fontNotice && ( +
+ {fontNotice} +
+ )} +
+ {filteredOptions.length === 0 ? ( +
No fonts found.
+ ) : ( + filteredOptions.map((option) => ( + + )) + )} +
+
+ ); + + if (flat) { + return ( +
+ Font + + {dropdown} +
+ ); + } + return (
Font family @@ -385,98 +511,7 @@ export function FontFamilyField({ Font - - {open && ( -
-
- setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault(); - setOpen(false); - } - if (e.key === "Enter" && filteredOptions[0]) { - e.preventDefault(); - commitFamily(filteredOptions[0]); - } - }} - className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600" - /> - {canQueryLocalFonts && ( - - )} - - { - await handleImportFonts(event.target.files); - event.target.value = ""; - }} - /> -
- {fontNotice && ( -
- {fontNotice} -
- )} -
- {filteredOptions.length === 0 ? ( -
No fonts found.
- ) : ( - filteredOptions.map((option) => ( - - )) - )} -
-
- )} + {dropdown}
); } diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.test.ts b/packages/studio/src/components/editor/propertyPanelHelpers.test.ts new file mode 100644 index 0000000000..4efb4067df --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelHelpers.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { selectionIdentityKey } from "./propertyPanelHelpers"; + +describe("selectionIdentityKey", () => { + it("keeps otherwise matching elements in different source files distinct", () => { + const sharedIdentity = { + id: null, + hfId: "hero-title", + selector: ".title", + selectorIndex: 0, + }; + const intro = { ...sharedIdentity, sourceFile: "scenes/intro.html" }; + const outro = { ...sharedIdentity, sourceFile: "scenes/outro.html" }; + expect(selectionIdentityKey(intro)).not.toBe(selectionIdentityKey(outro)); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 0032c7ca30..b35133b81f 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -29,6 +29,24 @@ export function isSelectedElementHidden( ); } +/** + * 5-part element identity for keying panel remounts on selection change — + * id or selector alone collides for id-less same-selector siblings, leaving + * mount-initialized state pointed at the previous element. Source scope is + * required because sub-compositions can reuse the same DOM identity. + */ +export function selectionIdentityKey( + element: Pick, +): string { + return [ + element.sourceFile ?? "", + element.id ?? "", + element.hfId ?? "", + element.selector ?? "", + String(element.selectorIndex ?? ""), + ].join("|"); +} + /* ------------------------------------------------------------------ */ /* Font types & constants (shared by font and section modules) */ /* ------------------------------------------------------------------ */ @@ -505,3 +523,56 @@ export function readGsapBorderRadiusForPanel( return null; } } + +/** + * Builds the multi-line "element info" text copied to the clipboard for an AI + * agent. Shared by both the legacy and flat inspector headers (the flat split + * needs the same string), so it lives here rather than as a PropertyPanel + * closure. Pure — the caller owns the clipboard write, toast, and copied state. + */ +// fallow-ignore-next-line complexity +export function buildElementInfoText( + element: DomEditSelection, + sourceLabel: string, + gsapAnimations: GsapAnimation[], + previewIframeRef?: React.RefObject, +): string { + 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}`); + } + return lines.join("\n"); +} diff --git a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index ae12435607..d8a36eeef1 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers"; -function CommitField({ +export function CommitField({ value, disabled, liveCommit, diff --git a/packages/studio/src/components/editor/propertyPanelSections.test.tsx b/packages/studio/src/components/editor/propertyPanelSections.test.tsx new file mode 100644 index 0000000000..075915ed1d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelSections.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTextSection } from "./propertyPanelFlatTextSection"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeElement(overrides: Partial = {}): DomEditSelection { + return { + element: document.createElement("div"), + id: "mono-label", + selector: ".mono-label", + label: "Mono Label", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: -24, width: 257, height: 29 }, + textContent: "PACKETS / FRAME", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [ + { + key: "field-0", + label: "Text", + value: "PACKETS / FRAME", + tagName: "div", + attributes: [], + inlineStyles: { "letter-spacing": "3.96px" }, + computedStyles: { + "font-family": "JetBrains Mono", + "font-size": "22px", + "font-weight": "400", + "letter-spacing": "3.96px", + "line-height": "normal", + "text-align": "right", + "text-transform": "none", + "font-style": "normal", + color: "rgb(255, 176, 32)", + }, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderSection(overrides: Partial = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeElement(overrides); + act(() => { + root.render( + , + ); + }); + return { host, root }; +} + +describe("FlatTextSection", () => { + it("renders the content block and every row from #10a", () => { + const { host, root } = renderSection(); + expect(host.textContent).toContain("PACKETS / FRAME"); + expect(host.textContent).toContain("Font"); + expect(host.textContent).toContain("Weight"); + expect(host.textContent).toContain("Letter spacing"); + expect(host.textContent).toContain("Line height"); + expect(host.textContent).toContain("Align"); + act(() => root.unmount()); + }); + + it("colors letter-spacing mint (explicit, differs from 0px default) with a reset button", () => { + const { host, root } = renderSection(); + const resetButtons = host.querySelectorAll('[data-flat-row-reset="true"]'); + expect(resetButtons.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); + + it("commits a font-weight change through onSetTextFieldStyle", () => { + const onSetTextFieldStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeElement(); + act(() => { + root.render( + , + ); + }); + const select = host.querySelector("select"); + if (!select) throw new Error("expected a weight