@hyperframes/studio 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -177,7 +177,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
|
|
177
177
|
type="button"
|
|
178
178
|
disabled={!prevKf}
|
|
179
179
|
onClick={() => prevKf && onSeek(prevKf.percentage)}
|
|
180
|
-
|
|
180
|
+
title="Previous keyframe"
|
|
181
|
+
aria-label={`Previous ${property} keyframe`}
|
|
182
|
+
className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
|
|
181
183
|
>
|
|
182
184
|
<ArrowLeft disabled={!prevKf} />
|
|
183
185
|
</button>
|
|
@@ -197,7 +199,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
|
|
197
199
|
type="button"
|
|
198
200
|
disabled={!nextKf}
|
|
199
201
|
onClick={() => nextKf && onSeek(nextKf.percentage)}
|
|
200
|
-
|
|
202
|
+
title="Next keyframe"
|
|
203
|
+
aria-label={`Next ${property} keyframe`}
|
|
204
|
+
className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
|
|
201
205
|
>
|
|
202
206
|
<ArrowRight disabled={!nextKf} />
|
|
203
207
|
</button>
|
|
@@ -469,14 +469,23 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
469
469
|
: selected
|
|
470
470
|
? "bg-panel-accent/14 text-panel-accent"
|
|
471
471
|
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
|
|
472
|
-
} ${dragKey ? "cursor-grabbing" :
|
|
472
|
+
} ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
|
|
473
473
|
style={{ paddingLeft: 8 + layer.depth * 16 }}
|
|
474
|
+
title={
|
|
475
|
+
draggable
|
|
476
|
+
? layer.element.hasAttribute("data-hf-group")
|
|
477
|
+
? "Double-click to enter group"
|
|
478
|
+
: undefined
|
|
479
|
+
: "This layer can't be reordered"
|
|
480
|
+
}
|
|
474
481
|
>
|
|
475
482
|
{hasChildren ? (
|
|
476
483
|
<button
|
|
477
484
|
type="button"
|
|
478
485
|
onClick={(e) => toggleCollapse(layer.key, e)}
|
|
479
|
-
|
|
486
|
+
aria-expanded={!isCollapsed}
|
|
487
|
+
aria-label={isCollapsed ? "Expand children" : "Collapse children"}
|
|
488
|
+
className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300 before:absolute before:-inset-1.5 before:content-['']"
|
|
480
489
|
>
|
|
481
490
|
<svg
|
|
482
491
|
width="8"
|
|
@@ -536,12 +536,11 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
|
|
|
536
536
|
if (!xRow) throw new Error("expected an X row");
|
|
537
537
|
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
538
538
|
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
// the 50% keyframe — exactly the case the coherence bug affected.
|
|
539
|
+
// At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
|
|
540
|
+
// disabled (no earlier keyframe) and the next arrow seeks to the 50%
|
|
541
|
+
// keyframe — exactly the case the coherence bug affected.
|
|
543
542
|
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
|
|
544
|
-
(b) =>
|
|
543
|
+
(b) => b.title === "Next keyframe" && !b.disabled,
|
|
545
544
|
);
|
|
546
545
|
if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
|
|
547
546
|
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
@@ -582,7 +581,9 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
|
|
|
582
581
|
if (!xRow) throw new Error("expected an X row");
|
|
583
582
|
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
|
584
583
|
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
|
585
|
-
|
|
584
|
+
// The diamond is the only gutter button that reports a pressed state;
|
|
585
|
+
// the prev/next arrows carry titles too, so `button[title]` is ambiguous.
|
|
586
|
+
const diamond = gutter.querySelector<HTMLButtonElement>("button[aria-pressed]");
|
|
586
587
|
if (!diamond) throw new Error("expected a keyframe diamond button");
|
|
587
588
|
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
|
|
588
589
|
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
|
|
@@ -234,11 +234,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
234
234
|
|
|
235
235
|
const handleCopyElementInfo = () => {
|
|
236
236
|
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
// Claim the copy only once the write actually lands — a denied clipboard
|
|
238
|
+
// permission otherwise reports a copy that never happened.
|
|
239
|
+
navigator.clipboard
|
|
240
|
+
.writeText(text)
|
|
241
|
+
.then(() => {
|
|
242
|
+
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
|
|
243
|
+
setClipboardCopied(true);
|
|
244
|
+
clearTimeout(clipboardTimerRef.current);
|
|
245
|
+
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
|
246
|
+
})
|
|
247
|
+
.catch(() => {
|
|
248
|
+
showToast("Couldn't copy to the clipboard — check browser permissions", "error");
|
|
249
|
+
});
|
|
242
250
|
};
|
|
243
251
|
|
|
244
252
|
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
|
@@ -14,7 +14,6 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
|
|
14
14
|
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
|
15
15
|
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
|
16
16
|
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
|
|
17
|
-
import { isCanaryEnabled } from "../../telemetry/canary";
|
|
18
17
|
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
|
19
18
|
import { useVolumeAutomation } from "./useVolumeAutomation";
|
|
20
19
|
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
|
|
@@ -79,6 +78,7 @@ export function PropertyPanelFlat({
|
|
|
79
78
|
onRemoveTextField,
|
|
80
79
|
onAskAgent,
|
|
81
80
|
onToggleElementHidden,
|
|
81
|
+
onAutoGroupCarveSources,
|
|
82
82
|
onImportAssets,
|
|
83
83
|
onAddMediaOverlay,
|
|
84
84
|
onImportFonts,
|
|
@@ -427,10 +427,7 @@ export function PropertyPanelFlat({
|
|
|
427
427
|
});
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
|
-
|
|
431
|
-
// and render still honour a `data-fx-chain` already on an element, so a
|
|
432
|
-
// composition written through the skill does not go silently dry off-cohort.
|
|
433
|
-
if (sections.audioFx && isCanaryEnabled("audio-fx-rack")) {
|
|
430
|
+
if (sections.audioFx) {
|
|
434
431
|
groups.push({
|
|
435
432
|
id: "audio-fx",
|
|
436
433
|
title: "Audio FX",
|
|
@@ -440,6 +437,7 @@ export function PropertyPanelFlat({
|
|
|
440
437
|
element={element}
|
|
441
438
|
onSetAttributeQuiet={onSetAttributeQuiet ?? onSetAttributeLive}
|
|
442
439
|
onSetAttributeLive={onSetAttributeLive}
|
|
440
|
+
onAutoGroupCarveSources={onAutoGroupCarveSources}
|
|
443
441
|
/>
|
|
444
442
|
),
|
|
445
443
|
});
|
|
@@ -102,7 +102,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
102
102
|
{motionPathCreateAvailable && (
|
|
103
103
|
<button
|
|
104
104
|
type="button"
|
|
105
|
-
className={`rounded-md p-1.5 transition-colors ${
|
|
105
|
+
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
|
106
106
|
motionPathArmed
|
|
107
107
|
? "bg-studio-accent/20 text-studio-accent"
|
|
108
108
|
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
@@ -118,7 +118,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
118
118
|
)}
|
|
119
119
|
<button
|
|
120
120
|
type="button"
|
|
121
|
-
className={`rounded-md p-1.5 transition-colors ${
|
|
121
|
+
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
|
122
122
|
prefs.snapEnabled
|
|
123
123
|
? "bg-studio-accent/20 text-studio-accent"
|
|
124
124
|
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
@@ -134,7 +134,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
134
134
|
<button
|
|
135
135
|
ref={gridButtonRef}
|
|
136
136
|
type="button"
|
|
137
|
-
className={`rounded-md p-1.5 transition-colors ${
|
|
137
|
+
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
|
138
138
|
prefs.gridVisible
|
|
139
139
|
? "bg-studio-accent/20 text-studio-accent"
|
|
140
140
|
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
@@ -144,11 +144,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
144
144
|
e.preventDefault();
|
|
145
145
|
setGridPopoverOpen((v) => !v);
|
|
146
146
|
}}
|
|
147
|
-
title={
|
|
147
|
+
title={
|
|
148
|
+
prefs.gridVisible
|
|
149
|
+
? "Grid visible (G) — right-click for spacing options"
|
|
150
|
+
: "Grid hidden (G) — right-click for spacing options"
|
|
151
|
+
}
|
|
148
152
|
aria-label="Toggle grid"
|
|
149
153
|
>
|
|
150
154
|
<GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
|
|
151
155
|
</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
className="absolute -right-0.5 -bottom-0.5 rounded p-0.5 text-white/50 hover:text-white/90 bg-black/50"
|
|
159
|
+
onClick={() => setGridPopoverOpen((v) => !v)}
|
|
160
|
+
title="Grid options"
|
|
161
|
+
aria-label="Grid options"
|
|
162
|
+
aria-expanded={gridPopoverOpen}
|
|
163
|
+
>
|
|
164
|
+
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true">
|
|
165
|
+
<path d="M1 2.5l3 3 3-3z" />
|
|
166
|
+
</svg>
|
|
167
|
+
</button>
|
|
152
168
|
|
|
153
169
|
{gridPopoverOpen && (
|
|
154
170
|
<div
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
6
|
+
import { TimelineFxPopover } from "./TimelineFxPopover.js";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
|
|
11
|
+
const RECT = { left: 0, top: 0, right: 0, bottom: 0 } as DOMRect;
|
|
12
|
+
|
|
13
|
+
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
|
14
|
+
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mount(overrides: Partial<Parameters<typeof TimelineFxPopover>[0]> = {}) {
|
|
18
|
+
const onClose = vi.fn();
|
|
19
|
+
const onChainChange = vi.fn();
|
|
20
|
+
const onChainPreview = vi.fn();
|
|
21
|
+
const onOpenRack = vi.fn();
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
document.body.append(host);
|
|
24
|
+
act(() => {
|
|
25
|
+
createRoot(host).render(
|
|
26
|
+
<TimelineFxPopover
|
|
27
|
+
anchorRect={RECT}
|
|
28
|
+
chain={EMPTY_CHAIN}
|
|
29
|
+
onClose={onClose}
|
|
30
|
+
onChainChange={onChainChange}
|
|
31
|
+
onChainPreview={onChainPreview}
|
|
32
|
+
onOpenRack={onOpenRack}
|
|
33
|
+
{...overrides}
|
|
34
|
+
/>,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
return { host, onClose, onChainChange, onChainPreview, onOpenRack };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
document.body.innerHTML = "";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("TimelineFxPopover", () => {
|
|
45
|
+
it("applies a preset with exactly one onChainChange write, and closes", () => {
|
|
46
|
+
const { host, onChainChange, onClose } = mount();
|
|
47
|
+
const button = byTextButton(host, "Chipmunk");
|
|
48
|
+
expect(button).toBeDefined();
|
|
49
|
+
act(() => button?.click());
|
|
50
|
+
expect(onChainChange).toHaveBeenCalledTimes(1);
|
|
51
|
+
const written = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
|
|
52
|
+
expect(written.nodes.length).toBeGreaterThan(0);
|
|
53
|
+
expect(onClose).toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("auditions on hover (focus is the keyboard's hover) and reverts on leave", () => {
|
|
57
|
+
const { host, onChainPreview } = mount();
|
|
58
|
+
const button = byTextButton(host, "Chipmunk");
|
|
59
|
+
expect(button).toBeDefined();
|
|
60
|
+
act(() => (button as HTMLButtonElement).focus());
|
|
61
|
+
expect(onChainPreview).toHaveBeenCalled();
|
|
62
|
+
const previewed = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
|
63
|
+
expect(previewed.nodes.length).toBeGreaterThan(0);
|
|
64
|
+
onChainPreview.mockClear();
|
|
65
|
+
act(() => {
|
|
66
|
+
button?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
|
67
|
+
});
|
|
68
|
+
expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("Escape closes without letting the keystroke propagate past the popover", () => {
|
|
72
|
+
const { host, onClose } = mount();
|
|
73
|
+
const outer = vi.fn();
|
|
74
|
+
document.body.addEventListener("keydown", outer);
|
|
75
|
+
const dialog = host.querySelector('[role="dialog"]');
|
|
76
|
+
expect(dialog).toBeTruthy();
|
|
77
|
+
act(() => {
|
|
78
|
+
dialog?.dispatchEvent(
|
|
79
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
expect(onClose).toHaveBeenCalled();
|
|
83
|
+
expect(outer).not.toHaveBeenCalled();
|
|
84
|
+
document.body.removeEventListener("keydown", outer);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("dismisses on an outside pointerdown", () => {
|
|
88
|
+
const { onClose } = mount();
|
|
89
|
+
act(() => {
|
|
90
|
+
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
91
|
+
});
|
|
92
|
+
expect(onClose).toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not dismiss on a pointerdown inside the popover", () => {
|
|
96
|
+
const { host, onClose } = mount();
|
|
97
|
+
const dialog = host.querySelector('[role="dialog"]');
|
|
98
|
+
expect(dialog).toBeTruthy();
|
|
99
|
+
act(() => {
|
|
100
|
+
dialog?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
101
|
+
});
|
|
102
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("the footer opens the rack and closes the popover", () => {
|
|
106
|
+
const { host, onOpenRack, onClose } = mount();
|
|
107
|
+
const openRack = byTextButton(host, "Open rack");
|
|
108
|
+
expect(openRack).toBeDefined();
|
|
109
|
+
act(() => openRack?.click());
|
|
110
|
+
expect(onOpenRack).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(onClose).toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FX popover the timeline's per-track/per-group FX button opens (C1).
|
|
3
|
+
*
|
|
4
|
+
* A THIN positioner around existing pieces, not a second rack: the body is
|
|
5
|
+
* `FxPresetMenu` exactly as the property panel's FX section renders it, and
|
|
6
|
+
* applying or auditioning a preset goes through the caller's own write path
|
|
7
|
+
* (a group's own attribute for a group target, the selected element's for a
|
|
8
|
+
* clip) — nothing here serializes a chain of its own.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from "react";
|
|
12
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
13
|
+
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
|
14
|
+
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
|
|
15
|
+
import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
|
|
16
|
+
import { useFxAudition } from "./useFxAudition.js";
|
|
17
|
+
|
|
18
|
+
const POPOVER_WIDTH = 260;
|
|
19
|
+
const VIEWPORT_MARGIN = 8;
|
|
20
|
+
|
|
21
|
+
function clampedStyle(anchorRect: DOMRect): CSSProperties {
|
|
22
|
+
const left = Math.min(
|
|
23
|
+
Math.max(anchorRect.left, VIEWPORT_MARGIN),
|
|
24
|
+
Math.max(VIEWPORT_MARGIN, window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN),
|
|
25
|
+
);
|
|
26
|
+
const spaceBelow = window.innerHeight - anchorRect.bottom;
|
|
27
|
+
const openUpward = spaceBelow < 260 && anchorRect.top > spaceBelow;
|
|
28
|
+
return {
|
|
29
|
+
position: "fixed",
|
|
30
|
+
left,
|
|
31
|
+
width: POPOVER_WIDTH,
|
|
32
|
+
...(openUpward
|
|
33
|
+
? { bottom: window.innerHeight - anchorRect.top + 4 }
|
|
34
|
+
: { top: anchorRect.bottom + 4 }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TimelineFxPopoverProps {
|
|
39
|
+
anchorRect: DOMRect;
|
|
40
|
+
chain: HfAudioFxChain;
|
|
41
|
+
trackKind?: HfAudioNameKind;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
/** Persist the applied preset onto the target (group attribute, or the
|
|
44
|
+
* selected clip's attribute — the caller resolves which). */
|
|
45
|
+
onChainChange: (next: HfAudioFxChain) => void;
|
|
46
|
+
/** Preview a hypothetical chain on the running graph without persisting. */
|
|
47
|
+
onChainPreview?: (next: HfAudioFxChain) => void;
|
|
48
|
+
onAuditionTransport?: (on: boolean) => void;
|
|
49
|
+
/** Select the target the way clicking it in the timeline does, and ensure
|
|
50
|
+
* the property panel's Audio FX group is expanded. */
|
|
51
|
+
onOpenRack: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function TimelineFxPopover({
|
|
55
|
+
anchorRect,
|
|
56
|
+
chain,
|
|
57
|
+
trackKind,
|
|
58
|
+
onClose,
|
|
59
|
+
onChainChange,
|
|
60
|
+
onChainPreview,
|
|
61
|
+
onAuditionTransport,
|
|
62
|
+
onOpenRack,
|
|
63
|
+
}: TimelineFxPopoverProps) {
|
|
64
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
65
|
+
const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
|
|
66
|
+
|
|
67
|
+
// Outside click dismisses like any other popover; the button itself is
|
|
68
|
+
// excluded by pointerdown timing (the button's own click hasn't happened yet).
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
71
|
+
if (rootRef.current && !rootRef.current.contains(event.target as Node)) onClose();
|
|
72
|
+
};
|
|
73
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
74
|
+
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
|
75
|
+
}, [onClose]);
|
|
76
|
+
|
|
77
|
+
const applyPreset = (id: string) => {
|
|
78
|
+
const next = applyPresetToChain(chain, id, trackKind);
|
|
79
|
+
if (!next) return;
|
|
80
|
+
clearAudition();
|
|
81
|
+
onChainChange(next);
|
|
82
|
+
onClose();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Escape closes without deselecting whatever is behind this — a keystroke
|
|
86
|
+
// aimed at the popover is not aimed at the clip under it.
|
|
87
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
88
|
+
if (event.key !== "Escape") return;
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
audition(null);
|
|
91
|
+
onClose();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
ref={rootRef}
|
|
97
|
+
role="dialog"
|
|
98
|
+
aria-label="Effects"
|
|
99
|
+
className="z-50 rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
|
|
100
|
+
style={clampedStyle(anchorRect)}
|
|
101
|
+
onKeyDown={onKeyDown}
|
|
102
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
103
|
+
>
|
|
104
|
+
<FxPresetMenu
|
|
105
|
+
trackKind={trackKind}
|
|
106
|
+
onPick={applyPreset}
|
|
107
|
+
onAudition={
|
|
108
|
+
onChainPreview
|
|
109
|
+
? (id) =>
|
|
110
|
+
audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
|
|
111
|
+
: undefined
|
|
112
|
+
}
|
|
113
|
+
/>
|
|
114
|
+
<div className="mt-2 flex items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
className="hover:text-white"
|
|
118
|
+
onClick={() => {
|
|
119
|
+
onClose();
|
|
120
|
+
onOpenRack();
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
+ effect
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
className="hover:text-white"
|
|
128
|
+
onClick={() => {
|
|
129
|
+
onClose();
|
|
130
|
+
onOpenRack();
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
Open rack ›
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -163,6 +163,25 @@ export function Transform3DCube({
|
|
|
163
163
|
setDraft(null);
|
|
164
164
|
};
|
|
165
165
|
|
|
166
|
+
const onKeyDown = (e: React.KeyboardEvent<SVGSVGElement>) => {
|
|
167
|
+
const STEP = e.altKey ? 1 : 5;
|
|
168
|
+
let next: CubePose | null = null;
|
|
169
|
+
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
170
|
+
const dir = e.key === "ArrowUp" ? -1 : 1;
|
|
171
|
+
next = e.shiftKey
|
|
172
|
+
? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
|
|
173
|
+
: { ...shown, rotationX: wrapDeg(shown.rotationX + dir * STEP) };
|
|
174
|
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
175
|
+
const dir = e.key === "ArrowRight" ? 1 : -1;
|
|
176
|
+
next = e.shiftKey
|
|
177
|
+
? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
|
|
178
|
+
: { ...shown, rotationY: wrapDeg(shown.rotationY + dir * STEP) };
|
|
179
|
+
}
|
|
180
|
+
if (!next) return;
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
onPoseCommit(next);
|
|
183
|
+
};
|
|
184
|
+
|
|
166
185
|
return (
|
|
167
186
|
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
|
|
168
187
|
<svg
|
|
@@ -174,8 +193,10 @@ export function Transform3DCube({
|
|
|
174
193
|
onPointerMove={onPointerMove}
|
|
175
194
|
onPointerUp={onPointerUp}
|
|
176
195
|
onPointerCancel={onPointerUp}
|
|
196
|
+
onKeyDown={onKeyDown}
|
|
197
|
+
tabIndex={0}
|
|
177
198
|
role="slider"
|
|
178
|
-
aria-label="
|
|
199
|
+
aria-label="3D rotation. Arrow keys rotate X/Y, Shift+arrows roll Z, Alt for fine steps; drag to rotate, scroll to change depth"
|
|
179
200
|
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
|
|
180
201
|
shown.rotationY,
|
|
181
202
|
)}°, Z ${Math.round(shown.rotationZ)}°`}
|
|
@@ -2,6 +2,7 @@ import { Window } from "happy-dom";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
applyManualOffsetDragCommit,
|
|
5
|
+
resumeGsapTimelines,
|
|
5
6
|
applyManualOffsetDragDraft,
|
|
6
7
|
applyManualOffsetDragMatrix,
|
|
7
8
|
createManualOffsetDragMember,
|
|
@@ -447,3 +448,51 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
|
|
|
447
448
|
expect(element.style.getPropertyValue("transform")).toMatch(/translate\(/);
|
|
448
449
|
});
|
|
449
450
|
});
|
|
451
|
+
|
|
452
|
+
describe("resumeGsapTimelines", () => {
|
|
453
|
+
it("unpauses exactly the timelines the drag start paused, then re-seeks the player", () => {
|
|
454
|
+
const window = new Window();
|
|
455
|
+
const element = window.document.createElement("div");
|
|
456
|
+
element.setAttribute("data-hf-drag-paused-timelines", "figma-demo-unlock,figma-demo-stagger");
|
|
457
|
+
window.document.body.append(element);
|
|
458
|
+
|
|
459
|
+
const pausedState: Record<string, boolean> = {
|
|
460
|
+
"figma-demo-unlock": true,
|
|
461
|
+
"figma-demo-stagger": true,
|
|
462
|
+
main: true,
|
|
463
|
+
};
|
|
464
|
+
const makeTl = (id: string) => ({
|
|
465
|
+
paused: (value?: boolean) => {
|
|
466
|
+
if (value !== undefined) pausedState[id] = value;
|
|
467
|
+
return pausedState[id]!;
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
const seeks: number[] = [];
|
|
471
|
+
const win = element.ownerDocument.defaultView as unknown as {
|
|
472
|
+
__timelines?: Record<string, unknown>;
|
|
473
|
+
__player?: { seek: (t: number) => void; getTime: () => number };
|
|
474
|
+
};
|
|
475
|
+
win.__timelines = {
|
|
476
|
+
"figma-demo-unlock": makeTl("figma-demo-unlock"),
|
|
477
|
+
"figma-demo-stagger": makeTl("figma-demo-stagger"),
|
|
478
|
+
main: makeTl("main"),
|
|
479
|
+
};
|
|
480
|
+
win.__player = { seek: (t: number) => seeks.push(t), getTime: () => 3.5 };
|
|
481
|
+
|
|
482
|
+
resumeGsapTimelines(element);
|
|
483
|
+
|
|
484
|
+
expect(pausedState["figma-demo-unlock"]).toBe(false);
|
|
485
|
+
expect(pausedState["figma-demo-stagger"]).toBe(false);
|
|
486
|
+
// main was NOT paused by the drag — leave its state alone
|
|
487
|
+
expect(pausedState["main"]).toBe(true);
|
|
488
|
+
expect(seeks).toEqual([3.5]);
|
|
489
|
+
expect(element.hasAttribute("data-hf-drag-paused-timelines")).toBe(false);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("is a no-op without the paused-timelines attribute", () => {
|
|
493
|
+
const window = new Window();
|
|
494
|
+
const element = window.document.createElement("div");
|
|
495
|
+
window.document.body.append(element);
|
|
496
|
+
expect(() => resumeGsapTimelines(element)).not.toThrow();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
@@ -561,11 +561,23 @@ export function resumeGsapTimelines(element: HTMLElement): void {
|
|
|
561
561
|
if (!ids) return;
|
|
562
562
|
const win = element.ownerDocument.defaultView as
|
|
563
563
|
| (Window & {
|
|
564
|
-
__timelines?: Record<string, {
|
|
564
|
+
__timelines?: Record<string, { paused?: (value?: boolean) => boolean }>;
|
|
565
565
|
__player?: { seek?: (t: number) => void; getTime?: () => number };
|
|
566
566
|
})
|
|
567
567
|
| null;
|
|
568
568
|
if (!win) return;
|
|
569
|
+
// Unpause exactly the timelines the drag start paused. The player seek below
|
|
570
|
+
// repositions them, but a seek alone leaves play-state-driven sub-composition
|
|
571
|
+
// timelines paused forever — the "selecting a piece kills its animation" bug:
|
|
572
|
+
// main survives (seek-driven every frame) while every scene timeline freezes,
|
|
573
|
+
// and deselecting can't recover because nothing else ever resumes them.
|
|
574
|
+
for (const id of ids.split(",")) {
|
|
575
|
+
try {
|
|
576
|
+
win.__timelines?.[id]?.paused?.(false);
|
|
577
|
+
} catch {
|
|
578
|
+
/* cross-origin guard */
|
|
579
|
+
}
|
|
580
|
+
}
|
|
569
581
|
const t = win.__player?.getTime?.() ?? 0;
|
|
570
582
|
win.__player?.seek?.(t);
|
|
571
583
|
}
|
|
@@ -322,11 +322,19 @@ export function PropertyPanel3dTransform({
|
|
|
322
322
|
<button
|
|
323
323
|
type="button"
|
|
324
324
|
onClick={() => setCollapsed((v) => !v)}
|
|
325
|
-
|
|
325
|
+
aria-expanded={!collapsed}
|
|
326
|
+
className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400 active:scale-[0.99]"
|
|
326
327
|
>
|
|
327
328
|
<span>3D Transform</span>
|
|
328
|
-
<svg
|
|
329
|
-
|
|
329
|
+
<svg
|
|
330
|
+
width="9"
|
|
331
|
+
height="9"
|
|
332
|
+
viewBox="0 0 10 10"
|
|
333
|
+
fill="currentColor"
|
|
334
|
+
aria-hidden
|
|
335
|
+
className={`transition-transform duration-150 ${collapsed ? "-rotate-90" : ""}`}
|
|
336
|
+
>
|
|
337
|
+
<path d="M2 3l3 4 3-4z" />
|
|
330
338
|
</svg>
|
|
331
339
|
</button>
|
|
332
340
|
{collapsed ? null : (
|