@hyperframes/studio 0.7.106 → 0.7.108
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-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +86 -3
- package/dist/index.html +2 -2
- package/dist/index.js +24325 -17831
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +15 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +31 -4
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +141 -21
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +4 -0
- package/src/hooks/useDomEditTextCommits.ts +112 -11
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/telemetry/system.ts +8 -0
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/studioTelemetry.ts +16 -10
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-DerI0ikN.js +0 -428
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Editing an element's text where it sits, in the composition itself.
|
|
6
|
+
*
|
|
7
|
+
* The alternative is an input positioned over the element, which has to
|
|
8
|
+
* reproduce its font, size, weight, spacing, colour, alignment and wrapping to
|
|
9
|
+
* look right, and is subtly wrong the moment any of those is missed. The
|
|
10
|
+
* preview is a same-origin document holding the real element, and the commit
|
|
11
|
+
* path already mutates that exact node, so the element is both the most
|
|
12
|
+
* accurate surface to type into and the one the rest of the code understands.
|
|
13
|
+
*
|
|
14
|
+
* The session owns the element's editable state for its whole life, and tears
|
|
15
|
+
* down the same way whichever way it ends. A session that failed to close
|
|
16
|
+
* would leave the canvas unable to select anything.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `true`, not `plaintext-only`.
|
|
21
|
+
*
|
|
22
|
+
* `plaintext-only` was what kept a text edit from becoming a structural one,
|
|
23
|
+
* and it also made it impossible to give three characters a colour, which is
|
|
24
|
+
* the point of editing in the composition rather than in a field. The guard it
|
|
25
|
+
* was providing is rebuilt as two narrower ones that do not cost the feature:
|
|
26
|
+
* paste arrives as plain text, and what leaves the element goes through the
|
|
27
|
+
* sanitiser before anyone writes it to a file.
|
|
28
|
+
*/
|
|
29
|
+
const EDITABLE = "true";
|
|
30
|
+
/** Studio's accent, so the mark belongs to Studio rather than to the design. */
|
|
31
|
+
const EDITING_OUTLINE = "2px solid #3CE6AC";
|
|
32
|
+
|
|
33
|
+
export interface InlineTextEditSession {
|
|
34
|
+
element: HTMLElement;
|
|
35
|
+
/**
|
|
36
|
+
* The element's markup when editing started, for putting back on cancel.
|
|
37
|
+
* Markup rather than text: cancelling an edit that recoloured a word has to
|
|
38
|
+
* restore the colours it replaced, not just the letters.
|
|
39
|
+
*/
|
|
40
|
+
original: string;
|
|
41
|
+
/** The element's own outline, to put back when the session ends. */
|
|
42
|
+
outline: string;
|
|
43
|
+
/** The element's own outline offset, restored with the outline. */
|
|
44
|
+
outlineOffset: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The live markup to persist and the session snapshot to restore on failure. */
|
|
48
|
+
export interface InlineTextEditCommit {
|
|
49
|
+
/** The exact preview node that owned the edit; a reload must not retarget it. */
|
|
50
|
+
element: HTMLElement;
|
|
51
|
+
html: string;
|
|
52
|
+
previousHtml: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface InlineTextEditControls {
|
|
56
|
+
session: InlineTextEditSession | null;
|
|
57
|
+
/**
|
|
58
|
+
* Begin editing this element. `caretAt` is a point in the element's own
|
|
59
|
+
* document, so the caret can open where the user pointed rather than at a
|
|
60
|
+
* fixed end. Returns false when a session is already open.
|
|
61
|
+
*/
|
|
62
|
+
start: (element: HTMLElement, caretAt?: { x: number; y: number }) => boolean;
|
|
63
|
+
/** Hand the current text to the commit function and close. */
|
|
64
|
+
commit: () => void;
|
|
65
|
+
/** Put the original text back and close, persisting nothing. */
|
|
66
|
+
cancel: () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Drop the text selection, but only when it lives inside this element.
|
|
71
|
+
*
|
|
72
|
+
* A selection somewhere else in the preview belongs to whatever put it there
|
|
73
|
+
* and is not this session's to clear.
|
|
74
|
+
*/
|
|
75
|
+
function clearSelectionWithin(element: HTMLElement): void {
|
|
76
|
+
const selection = element.ownerDocument.defaultView?.getSelection();
|
|
77
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
78
|
+
const range = selection.getRangeAt(0);
|
|
79
|
+
if (!element.contains(range.startContainer) && !element.contains(range.endContainer)) return;
|
|
80
|
+
selection.removeAllRanges();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useInlineTextEdit({
|
|
84
|
+
onCommit,
|
|
85
|
+
onPause,
|
|
86
|
+
}: {
|
|
87
|
+
/** Where the edited text goes. The caller owns persistence. */
|
|
88
|
+
onCommit: (commit: InlineTextEditCommit) => void;
|
|
89
|
+
/** Stop playback, so the element is not animating under the caret. */
|
|
90
|
+
onPause?: () => void;
|
|
91
|
+
}): InlineTextEditControls {
|
|
92
|
+
const [session, setSession] = useState<InlineTextEditSession | null>(null);
|
|
93
|
+
// The teardown reads this rather than the state, so an exit path that runs
|
|
94
|
+
// before React re-renders still sees the element it has to clean up.
|
|
95
|
+
const openRef = useRef<InlineTextEditSession | null>(null);
|
|
96
|
+
/** The pending caret placement, so a session that closes first can drop it. */
|
|
97
|
+
const framesRef = useRef<number | null>(null);
|
|
98
|
+
|
|
99
|
+
const teardown = useCallback((): InlineTextEditSession | null => {
|
|
100
|
+
const open = openRef.current;
|
|
101
|
+
if (!open) return null;
|
|
102
|
+
if (framesRef.current !== null) {
|
|
103
|
+
open.element.ownerDocument.defaultView?.cancelAnimationFrame(framesRef.current);
|
|
104
|
+
framesRef.current = null;
|
|
105
|
+
}
|
|
106
|
+
openRef.current = null;
|
|
107
|
+
setSession(null);
|
|
108
|
+
// An element removed from the document mid-session is not an error, it is
|
|
109
|
+
// just nothing left to clean up.
|
|
110
|
+
if (open.element.isConnected) {
|
|
111
|
+
open.element.removeAttribute("contenteditable");
|
|
112
|
+
// Restored rather than cleared: the composition may have authored one.
|
|
113
|
+
open.element.style.outline = open.outline;
|
|
114
|
+
open.element.style.outlineOffset = open.outlineOffset;
|
|
115
|
+
// Drop the highlight too. Removing contenteditable and blurring leaves a
|
|
116
|
+
// selection made inside the element painted on screen, so a word picked
|
|
117
|
+
// with a double press stayed grey after the click that closed the edit.
|
|
118
|
+
clearSelectionWithin(open.element);
|
|
119
|
+
open.element.blur();
|
|
120
|
+
}
|
|
121
|
+
return open;
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const start = useCallback(
|
|
125
|
+
(element: HTMLElement, caretAt?: { x: number; y: number }): boolean => {
|
|
126
|
+
if (openRef.current) return false;
|
|
127
|
+
|
|
128
|
+
const open = {
|
|
129
|
+
element,
|
|
130
|
+
original: element.innerHTML,
|
|
131
|
+
outline: element.style.outline,
|
|
132
|
+
outlineOffset: element.style.outlineOffset,
|
|
133
|
+
};
|
|
134
|
+
// Drawn on the element itself, not in Studio's overlay above it. This is
|
|
135
|
+
// the only mark that says the caret is in the TEXT rather than the
|
|
136
|
+
// element being selected, and it has to sit in the same document as the
|
|
137
|
+
// caret to read that way.
|
|
138
|
+
element.style.outline = EDITING_OUTLINE;
|
|
139
|
+
element.style.outlineOffset = "2px";
|
|
140
|
+
openRef.current = open;
|
|
141
|
+
setSession(open);
|
|
142
|
+
onPause?.();
|
|
143
|
+
|
|
144
|
+
element.setAttribute("contenteditable", EDITABLE);
|
|
145
|
+
// Focused and selected on the next frame, not now. The press that opened
|
|
146
|
+
// this is still in flight: the canvas overlay takes focus on its own
|
|
147
|
+
// pointer-down, and the click that follows puts a caret in the element
|
|
148
|
+
// and collapses any selection. Doing it after all of that is what lands.
|
|
149
|
+
const view = element.ownerDocument.defaultView;
|
|
150
|
+
const raf = view?.requestAnimationFrame(() => {
|
|
151
|
+
framesRef.current = null;
|
|
152
|
+
if (openRef.current?.element !== element) return;
|
|
153
|
+
element.focus({ preventScroll: true });
|
|
154
|
+
placeCaret(element, caretAt);
|
|
155
|
+
});
|
|
156
|
+
framesRef.current = raf ?? null;
|
|
157
|
+
return true;
|
|
158
|
+
},
|
|
159
|
+
[onPause],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const commit = useCallback(() => {
|
|
163
|
+
const open = openRef.current;
|
|
164
|
+
if (!open) return;
|
|
165
|
+
// Sanitised here, in the element, so the preview shows exactly what will be
|
|
166
|
+
// saved rather than something the server will quietly cut down.
|
|
167
|
+
sanitizeRichTextChildren(open.element);
|
|
168
|
+
const html = open.element.innerHTML;
|
|
169
|
+
teardown();
|
|
170
|
+
// After teardown, so the commit path's own resync does not fight an
|
|
171
|
+
// element that is still editable.
|
|
172
|
+
onCommit({ element: open.element, html, previousHtml: open.original });
|
|
173
|
+
}, [onCommit, teardown]);
|
|
174
|
+
|
|
175
|
+
const cancel = useCallback(() => {
|
|
176
|
+
const open = openRef.current;
|
|
177
|
+
if (!open) return;
|
|
178
|
+
if (open.element.isConnected) open.element.innerHTML = open.original;
|
|
179
|
+
teardown();
|
|
180
|
+
}, [teardown]);
|
|
181
|
+
|
|
182
|
+
// The keys belong to the element, not to the document: the element lives in
|
|
183
|
+
// the preview's own document, so a listener on Studio's would never see them.
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const element = session?.element;
|
|
186
|
+
if (!element) return;
|
|
187
|
+
|
|
188
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
189
|
+
// Shift+Enter is a line break in a multi-line element, and is left alone.
|
|
190
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
commit();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (event.key === "Escape") {
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
cancel();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
// Clicking away keeps the work, which is what every other field in Studio
|
|
201
|
+
// does and what a user who has just typed something expects.
|
|
202
|
+
const onBlur = () => commit();
|
|
203
|
+
// Nothing here for double or triple click: the browser already takes the
|
|
204
|
+
// word on two and the whole text on three, which is what a text field does
|
|
205
|
+
// everywhere else. Overriding the double click to take everything cost the
|
|
206
|
+
// word selection and gained nothing the triple click did not already do.
|
|
207
|
+
// Dropping `plaintext-only` means the browser would otherwise paste a whole
|
|
208
|
+
// web page's markup straight in. What arrives is the words.
|
|
209
|
+
const insertPlainText = (text: string) => {
|
|
210
|
+
if (text) element.ownerDocument.execCommand("insertText", false, text);
|
|
211
|
+
};
|
|
212
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
insertPlainText(event.clipboardData?.getData("text/plain") ?? "");
|
|
215
|
+
};
|
|
216
|
+
// `contenteditable="true"` accepts dragged HTML as eagerly as pasted HTML.
|
|
217
|
+
// Keep the drop path on the same plain-text rail as paste so markup cannot
|
|
218
|
+
// execute in the same-origin preview before commit-time sanitisation.
|
|
219
|
+
const onDragOver = (event: DragEvent) => event.preventDefault();
|
|
220
|
+
const onDrop = (event: DragEvent) => {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
insertPlainText(event.dataTransfer?.getData("text/plain") ?? "");
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
element.addEventListener("keydown", onKeyDown);
|
|
226
|
+
element.addEventListener("blur", onBlur);
|
|
227
|
+
element.addEventListener("paste", onPaste);
|
|
228
|
+
element.addEventListener("dragover", onDragOver);
|
|
229
|
+
element.addEventListener("drop", onDrop);
|
|
230
|
+
return () => {
|
|
231
|
+
element.removeEventListener("keydown", onKeyDown);
|
|
232
|
+
element.removeEventListener("blur", onBlur);
|
|
233
|
+
element.removeEventListener("paste", onPaste);
|
|
234
|
+
element.removeEventListener("dragover", onDragOver);
|
|
235
|
+
element.removeEventListener("drop", onDrop);
|
|
236
|
+
};
|
|
237
|
+
}, [session, commit, cancel]);
|
|
238
|
+
|
|
239
|
+
// Navigation can remove the overlay while the opening frame is pending.
|
|
240
|
+
// Teardown is the single owner of cancelling that frame and restoring the
|
|
241
|
+
// composition node, so unmount closes through the same path as every exit.
|
|
242
|
+
useEffect(
|
|
243
|
+
() => () => {
|
|
244
|
+
teardown();
|
|
245
|
+
},
|
|
246
|
+
[teardown],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
return { session, start, commit, cancel };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Put the caret where the user pointed, or after the last character.
|
|
254
|
+
*
|
|
255
|
+
* Opening on a point is what makes this feel like text rather than a dialog:
|
|
256
|
+
* the caret lands between the two letters that were clicked, exactly as it
|
|
257
|
+
* would in any other editor.
|
|
258
|
+
*/
|
|
259
|
+
function placeCaret(element: HTMLElement, at?: { x: number; y: number }): void {
|
|
260
|
+
const doc = element.ownerDocument;
|
|
261
|
+
const selection = doc.defaultView?.getSelection();
|
|
262
|
+
if (!selection) return;
|
|
263
|
+
|
|
264
|
+
const range = at ? caretRangeAt(doc, at) : null;
|
|
265
|
+
if (range && element.contains(range.startContainer)) {
|
|
266
|
+
selection.removeAllRanges();
|
|
267
|
+
selection.addRange(range);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
placeCaretAtEnd(element);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The caret position under a point, across the two APIs browsers expose. */
|
|
274
|
+
function caretRangeAt(doc: Document, at: { x: number; y: number }): Range | null {
|
|
275
|
+
const legacy = doc as Document & {
|
|
276
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
|
277
|
+
};
|
|
278
|
+
if (typeof legacy.caretRangeFromPoint === "function") {
|
|
279
|
+
return legacy.caretRangeFromPoint(at.x, at.y);
|
|
280
|
+
}
|
|
281
|
+
const standard = doc as Document & {
|
|
282
|
+
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
|
|
283
|
+
};
|
|
284
|
+
const position = standard.caretPositionFromPoint?.(at.x, at.y);
|
|
285
|
+
if (!position) return null;
|
|
286
|
+
const range = doc.createRange();
|
|
287
|
+
range.setStart(position.offsetNode, position.offset);
|
|
288
|
+
range.collapse(true);
|
|
289
|
+
return range;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Put the caret after the last character, with nothing selected.
|
|
294
|
+
*
|
|
295
|
+
* Selecting the whole text would mean the next keystroke silently destroys it,
|
|
296
|
+
* which is a bad thing to do to someone who double-clicked to fix a typo. A
|
|
297
|
+
* caret at the end is where a person who wants to keep typing expects to be,
|
|
298
|
+
* and everything else stays available: click anywhere to move it, drag to
|
|
299
|
+
* select, Cmd+A to take the lot.
|
|
300
|
+
*/
|
|
301
|
+
function placeCaretAtEnd(element: HTMLElement): void {
|
|
302
|
+
const doc = element.ownerDocument;
|
|
303
|
+
const selection = doc.defaultView?.getSelection();
|
|
304
|
+
if (!selection) return;
|
|
305
|
+
const range = doc.createRange();
|
|
306
|
+
// Into the text node, not just past the last child: collapsing the element's
|
|
307
|
+
// contents leaves the caret at a node boundary, which types in the right
|
|
308
|
+
// place but reports itself as "after child 0" and is a different position
|
|
309
|
+
// from the one the user sees at the end of the word.
|
|
310
|
+
const last = element.lastChild;
|
|
311
|
+
if (last && last.nodeType === 3) {
|
|
312
|
+
range.setStart(last, last.textContent?.length ?? 0);
|
|
313
|
+
range.collapse(true);
|
|
314
|
+
} else {
|
|
315
|
+
range.selectNodeContents(element);
|
|
316
|
+
range.collapse(false);
|
|
317
|
+
}
|
|
318
|
+
selection.removeAllRanges();
|
|
319
|
+
selection.addRange(range);
|
|
320
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playhead in composition seconds, live while the transport runs.
|
|
3
|
+
*
|
|
4
|
+
* The RAF loop deliberately does not push every frame through the store — it
|
|
5
|
+
* notifies `liveTime` instead, so the playhead can move without re-rendering the
|
|
6
|
+
* app. A panel that wants to follow it therefore has to subscribe itself, and
|
|
7
|
+
* throttle: 30 Hz reads as continuous and costs an order of magnitude less than a
|
|
8
|
+
* render per frame.
|
|
9
|
+
*
|
|
10
|
+
* Paused, the store is the truth — a seek or a scrub lands there — so this returns
|
|
11
|
+
* that instead, which is what lets a readout follow the playhead while it is being
|
|
12
|
+
* dragged as well as while it is playing.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from "react";
|
|
15
|
+
import { liveTime, usePlayerStore } from "../player";
|
|
16
|
+
|
|
17
|
+
/** Long enough to be much cheaper than a frame, short enough to read as motion. */
|
|
18
|
+
const THROTTLE_MS = 33;
|
|
19
|
+
|
|
20
|
+
export function useLivePlayheadTime(): number {
|
|
21
|
+
const storeTime = usePlayerStore((s) => s.currentTime);
|
|
22
|
+
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
23
|
+
const liveRef = useRef(storeTime);
|
|
24
|
+
const [, forceRender] = useState(0);
|
|
25
|
+
|
|
26
|
+
// Paused, the ref tracks the store so the first frame of playback is never a
|
|
27
|
+
// stale value from the last time the transport ran.
|
|
28
|
+
if (!isPlaying) liveRef.current = storeTime;
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!isPlaying) return;
|
|
32
|
+
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
|
|
33
|
+
const unsubscribe = liveTime.subscribe((t) => {
|
|
34
|
+
liveRef.current = t;
|
|
35
|
+
if (!timerId) {
|
|
36
|
+
timerId = setTimeout(() => {
|
|
37
|
+
timerId = 0;
|
|
38
|
+
forceRender((v) => v + 1);
|
|
39
|
+
}, THROTTLE_MS);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
unsubscribe();
|
|
44
|
+
if (timerId) clearTimeout(timerId);
|
|
45
|
+
};
|
|
46
|
+
}, [isPlaying]);
|
|
47
|
+
|
|
48
|
+
return isPlaying ? liveRef.current : storeTime;
|
|
49
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
15
15
|
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
|
|
16
16
|
import { usePlayerStore } from "../player";
|
|
17
|
+
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
|
|
17
18
|
|
|
18
19
|
/** The restore payload the undo/redo preview-sync consumes (from the history store). */
|
|
19
20
|
interface HistoryPreviewRestore {
|
|
@@ -207,7 +208,13 @@ export function usePreviewPersistence({
|
|
|
207
208
|
player.setElements([]);
|
|
208
209
|
player.setSelectedElementId(null);
|
|
209
210
|
player.setTimelineReady(false);
|
|
211
|
+
return;
|
|
210
212
|
}
|
|
213
|
+
// A soft restore patched the reverted attributes onto the live preview, but the
|
|
214
|
+
// player store keeps its own copy and that copy is what the automation lanes
|
|
215
|
+
// draw — so without this an undone envelope edit stayed invisible until a
|
|
216
|
+
// reload. The full path above clears the store and waits for discovery instead.
|
|
217
|
+
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
|
|
211
218
|
},
|
|
212
219
|
[previewIframeRef, activeCompPathRef, reloadPreview],
|
|
213
220
|
);
|
|
@@ -449,6 +449,9 @@ export function useTimelineEditing({
|
|
|
449
449
|
kind: "timeline",
|
|
450
450
|
files: { [targetPath]: patchedContent },
|
|
451
451
|
readFile: async () => originalContent,
|
|
452
|
+
// remove-element already wrote the removal, so disk holds THAT — not the
|
|
453
|
+
// content read at the top. Undo still goes back to the original.
|
|
454
|
+
diskContent: { [targetPath]: removedContent },
|
|
452
455
|
writeFile: writeProjectFile,
|
|
453
456
|
recordEdit,
|
|
454
457
|
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context menu for a right-click inside an automation time selection: the four
|
|
3
|
+
* utility shapes, then Simplify. Portal + dismiss handling mirror
|
|
4
|
+
* TrackGapContextMenu; rows never vanish — an inapplicable Simplify dims with
|
|
5
|
+
* a reason instead of leaving a shorter menu.
|
|
6
|
+
*/
|
|
7
|
+
import { memo } from "react";
|
|
8
|
+
import { createPortal } from "react-dom";
|
|
9
|
+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
10
|
+
import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes";
|
|
11
|
+
|
|
12
|
+
interface AutomationSelectionMenuProps {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
onClose(): void;
|
|
16
|
+
onInsertShape(shape: AutomationShapeId): void;
|
|
17
|
+
onSimplify(): void;
|
|
18
|
+
/** At least three points in the range — fewer has nothing to thin. */
|
|
19
|
+
canSimplify: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
23
|
+
x,
|
|
24
|
+
y,
|
|
25
|
+
onClose,
|
|
26
|
+
onInsertShape,
|
|
27
|
+
onSimplify,
|
|
28
|
+
canSimplify,
|
|
29
|
+
}: AutomationSelectionMenuProps) {
|
|
30
|
+
const menuRef = useContextMenuDismiss(onClose);
|
|
31
|
+
const row =
|
|
32
|
+
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
|
|
33
|
+
// Same edge-clamping precedent as TrackGapContextMenu: without it a
|
|
34
|
+
// right-click near the bottom/right of the timeline renders this menu
|
|
35
|
+
// partially off-screen.
|
|
36
|
+
const menuWidth = 140;
|
|
37
|
+
const menuHeight = AUTOMATION_SHAPES.length * 24 + 32;
|
|
38
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
39
|
+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
40
|
+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
41
|
+
return createPortal(
|
|
42
|
+
<div
|
|
43
|
+
ref={menuRef}
|
|
44
|
+
className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
|
|
45
|
+
style={{ left: adjustedX, top: adjustedY }}
|
|
46
|
+
>
|
|
47
|
+
{AUTOMATION_SHAPES.map((shape) => (
|
|
48
|
+
<button
|
|
49
|
+
key={shape.id}
|
|
50
|
+
type="button"
|
|
51
|
+
className={row}
|
|
52
|
+
onClick={() => {
|
|
53
|
+
onInsertShape(shape.id);
|
|
54
|
+
onClose();
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{shape.label}
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
<div className="my-1 border-t border-panel-border-input" />
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
className={row}
|
|
64
|
+
disabled={!canSimplify}
|
|
65
|
+
title={canSimplify ? undefined : "Fewer than three points in the selection"}
|
|
66
|
+
onClick={() => {
|
|
67
|
+
onSimplify();
|
|
68
|
+
onClose();
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
Simplify
|
|
72
|
+
</button>
|
|
73
|
+
</div>,
|
|
74
|
+
document.body,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing an exact value into a breakpoint.
|
|
3
|
+
*
|
|
4
|
+
* Dragging is how an envelope is shaped, but it cannot land on a number: -6.0 dB
|
|
5
|
+
* is not a pixel you can find. This is the lane's keyboard route to one value,
|
|
6
|
+
* kept in its own file so the lane component stays about the pointer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface AutomationValueInputProps {
|
|
10
|
+
text: string;
|
|
11
|
+
/** Left edge in the lane's own coordinates. */
|
|
12
|
+
leftPx: number;
|
|
13
|
+
label: string;
|
|
14
|
+
onChange(text: string): void;
|
|
15
|
+
/** Enter or blur: apply what was typed. */
|
|
16
|
+
onCommit(): void;
|
|
17
|
+
/** Escape: leave the point where it was. */
|
|
18
|
+
onCancel(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AutomationValueInput({
|
|
22
|
+
text,
|
|
23
|
+
leftPx,
|
|
24
|
+
label,
|
|
25
|
+
onChange,
|
|
26
|
+
onCommit,
|
|
27
|
+
onCancel,
|
|
28
|
+
}: AutomationValueInputProps) {
|
|
29
|
+
return (
|
|
30
|
+
<input
|
|
31
|
+
// pointer-events-auto: the lane band around it takes none, so that clips
|
|
32
|
+
// sharing the row do not cover each other's envelopes (see the lane).
|
|
33
|
+
className="hf-automation-value pointer-events-auto absolute rounded-[3px] border border-panel-border-input bg-panel-bg-2 px-1 font-mono text-[9px] text-panel-text-1"
|
|
34
|
+
style={{ left: leftPx, top: 1, width: 44, zIndex: 4 }}
|
|
35
|
+
// The lane is a pointer surface, and the timeline above it owns single-key
|
|
36
|
+
// shortcuts. Without stopping both, a press lands on the lane instead of
|
|
37
|
+
// the field and a typed "5" scrubs the transport.
|
|
38
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
39
|
+
onKeyDown={(e) => {
|
|
40
|
+
e.stopPropagation();
|
|
41
|
+
if (e.key === "Enter") onCommit();
|
|
42
|
+
if (e.key === "Escape") onCancel();
|
|
43
|
+
}}
|
|
44
|
+
onChange={(e) => onChange(e.target.value)}
|
|
45
|
+
onBlur={onCommit}
|
|
46
|
+
value={text}
|
|
47
|
+
aria-label={`${label} value`}
|
|
48
|
+
autoFocus
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { CaretRight } from "@phosphor-icons/react";
|
|
2
|
-
import type { TimelineElement } from "../store/playerStore";
|
|
3
2
|
import { TRACK_H } from "./timelineLayout";
|
|
4
3
|
import { TrackClipCount } from "./TrackClipCount";
|
|
5
4
|
|
|
6
5
|
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
|
|
7
6
|
// here, not on the clip bar, and re-expands a collapsed layer.
|
|
8
7
|
export function LayerDisclosureRow({
|
|
9
|
-
|
|
8
|
+
name,
|
|
10
9
|
clipCount,
|
|
11
10
|
isExpanded,
|
|
12
11
|
gutterBackground,
|
|
@@ -15,7 +14,10 @@ export function LayerDisclosureRow({
|
|
|
15
14
|
onToggleClipExpanded,
|
|
16
15
|
children,
|
|
17
16
|
}: {
|
|
18
|
-
|
|
17
|
+
/** What this row is called. The active clip's own name when it is alone on the
|
|
18
|
+
* track; the track itself once it holds several, since naming a shared row
|
|
19
|
+
* after one of its clips reads as if the rows under it were that clip's. */
|
|
20
|
+
name: string;
|
|
19
21
|
clipCount: number;
|
|
20
22
|
isExpanded: boolean;
|
|
21
23
|
gutterBackground: string;
|
|
@@ -31,7 +33,6 @@ export function LayerDisclosureRow({
|
|
|
31
33
|
/** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
|
|
32
34
|
children?: React.ReactNode;
|
|
33
35
|
}) {
|
|
34
|
-
const name = keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id;
|
|
35
36
|
return (
|
|
36
37
|
<div
|
|
37
38
|
className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
getTimelineLaneTop,
|
|
38
38
|
createTimelineRowGeometry,
|
|
39
39
|
} from "./timelineLayout";
|
|
40
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
40
41
|
import { formatTime } from "../lib/time";
|
|
41
42
|
import { usePlayerStore } from "../store/playerStore";
|
|
42
43
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
@@ -578,6 +579,89 @@ describe("Timeline provider boundary", () => {
|
|
|
578
579
|
act(() => root.unmount());
|
|
579
580
|
});
|
|
580
581
|
|
|
582
|
+
// The caret belongs to the row, not to whichever clip on it is selected: the
|
|
583
|
+
// automation lanes below it are the track's, shared per property. Toggling one
|
|
584
|
+
// clip left the row's state depending on the selection, and a collapse that
|
|
585
|
+
// only dropped the active clip left the row stuck open.
|
|
586
|
+
it("expands and collapses every clip on a shared track together", () => {
|
|
587
|
+
const host = createSizedTimelineHost(720);
|
|
588
|
+
const automation = JSON.stringify({
|
|
589
|
+
version: 1,
|
|
590
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
591
|
+
});
|
|
592
|
+
usePlayerStore.setState({
|
|
593
|
+
duration: 8,
|
|
594
|
+
timelineReady: true,
|
|
595
|
+
elements: [
|
|
596
|
+
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
597
|
+
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
598
|
+
],
|
|
599
|
+
});
|
|
600
|
+
const root = createRoot(host);
|
|
601
|
+
act(() => root.render(React.createElement(Timeline)));
|
|
602
|
+
|
|
603
|
+
const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
|
|
604
|
+
?.parentElement;
|
|
605
|
+
// A row of several clips is named for the track, so the caret is too.
|
|
606
|
+
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]');
|
|
607
|
+
expect(caret()?.getAttribute("aria-label")).toBe("Expand Track 1 keyframes");
|
|
608
|
+
|
|
609
|
+
act(() => caret()?.click());
|
|
610
|
+
// One shared volume row, and BOTH clips hold it open.
|
|
611
|
+
expectTrackExpansion(row, ["narration-1", "narration-2"], TRACK_H + AUTOMATION_LANE_H);
|
|
612
|
+
|
|
613
|
+
// Every clip bar on the row is capped to one track height. Only the clip
|
|
614
|
+
// owning the property lanes used to be, so its siblings stretched the whole
|
|
615
|
+
// expanded row and painted their waveforms over the envelopes below.
|
|
616
|
+
expect(
|
|
617
|
+
["narration-1", "narration-2"].map(
|
|
618
|
+
(id) => host.querySelector<HTMLElement>(`[data-el-id="${id}"]`)?.style.height,
|
|
619
|
+
),
|
|
620
|
+
).toEqual([`${TRACK_H - 2 * CLIP_Y}px`, `${TRACK_H - 2 * CLIP_Y}px`]);
|
|
621
|
+
|
|
622
|
+
act(() => caret()?.click());
|
|
623
|
+
expectTrackExpansion(row, [], TRACK_H);
|
|
624
|
+
act(() => root.unmount());
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// The lanes are the row's, and selecting a clip must not rebuild them. They
|
|
628
|
+
// used to hang off the active clip's property lanes, so clicking a sibling
|
|
629
|
+
// moved the whole subtree into a different element and remounted every lane —
|
|
630
|
+
// which threw away each one's hover state and any gesture in flight. Pressing
|
|
631
|
+
// a lane to select its clip therefore made the handles vanish under the
|
|
632
|
+
// pointer, which is the one gesture the read-only lane exists to support.
|
|
633
|
+
it("keeps the automation lanes mounted when the selection moves along the row", () => {
|
|
634
|
+
const host = createSizedTimelineHost(720);
|
|
635
|
+
const automation = JSON.stringify({
|
|
636
|
+
version: 1,
|
|
637
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
638
|
+
});
|
|
639
|
+
usePlayerStore.setState({
|
|
640
|
+
duration: 8,
|
|
641
|
+
timelineReady: true,
|
|
642
|
+
selectedElementId: "narration-2",
|
|
643
|
+
elements: [
|
|
644
|
+
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
645
|
+
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
const root = createRoot(host);
|
|
649
|
+
act(() => root.render(React.createElement(Timeline)));
|
|
650
|
+
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]')?.click());
|
|
651
|
+
|
|
652
|
+
const before = [...host.querySelectorAll(".hf-automation-lane")];
|
|
653
|
+
expect(before).toHaveLength(2);
|
|
654
|
+
|
|
655
|
+
act(() => usePlayerStore.setState({ selectedElementId: "narration-1" }));
|
|
656
|
+
|
|
657
|
+
// Identity, not deep equality: a remount produces structurally identical
|
|
658
|
+
// nodes, so only `toBe` can tell the two apart.
|
|
659
|
+
const after = [...host.querySelectorAll(".hf-automation-lane")];
|
|
660
|
+
expect(after).toHaveLength(before.length);
|
|
661
|
+
after.forEach((node, index) => expect(node).toBe(before[index]));
|
|
662
|
+
act(() => root.unmount());
|
|
663
|
+
});
|
|
664
|
+
|
|
581
665
|
it("marks every clip in selectedElementIds as selected", () => {
|
|
582
666
|
const host = createSizedTimelineHost(720);
|
|
583
667
|
|