@hyperframes/studio 0.8.108 → 0.8.109
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-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
- package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +44 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7021 -5669
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +3 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +36 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +6 -0
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
- package/src/player/components/clipMenuAudioItems.tsx +31 -23
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuToolItems.tsx +1 -3
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +11 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
2
|
+
import { MEDIA_LINK_ATTR, SYNC_ORIGIN_ATTR, mintLinkId } from "@hyperframes/core/media-link";
|
|
3
|
+
import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
|
|
4
|
+
import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
|
|
5
|
+
import {
|
|
6
|
+
applyPatchByTarget,
|
|
7
|
+
findTagByTarget,
|
|
8
|
+
readAttributeByTarget,
|
|
9
|
+
type PatchOperation,
|
|
10
|
+
type PatchTarget,
|
|
11
|
+
} from "../../utils/sourcePatcher";
|
|
12
|
+
import { collectHtmlIds } from "../../utils/studioHelpers";
|
|
13
|
+
import { generateId } from "../../utils/generateId";
|
|
14
|
+
import {
|
|
15
|
+
COPIED_TIMING_ATTRS,
|
|
16
|
+
MOVED_SOUND_ATTRS,
|
|
17
|
+
firstFreeName,
|
|
18
|
+
formatAttrs,
|
|
19
|
+
insertBeforeTarget,
|
|
20
|
+
readAuthoredSrc,
|
|
21
|
+
splitAutomation,
|
|
22
|
+
} from "./mediaAudioEdits";
|
|
23
|
+
|
|
24
|
+
const LINK_PROPERTY = MEDIA_LINK_ATTR.slice("data-".length);
|
|
25
|
+
const HIDDEN_ATTR = "data-hidden";
|
|
26
|
+
const SYNC_ORIGIN_PROPERTY = SYNC_ORIGIN_ATTR.slice("data-".length);
|
|
27
|
+
const SYNC_TOLERANCE_S = 1e-3;
|
|
28
|
+
|
|
29
|
+
const dataOp = (property: string, value: string | null): PatchOperation => ({
|
|
30
|
+
type: "attribute",
|
|
31
|
+
property: property.startsWith("data-") ? property.slice("data-".length) : property,
|
|
32
|
+
value,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const htmlOp = (property: string, value: string | null): PatchOperation => ({
|
|
36
|
+
type: "html-attribute",
|
|
37
|
+
property,
|
|
38
|
+
value,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function applyOps(source: string, target: PatchTarget, ops: readonly PatchOperation[]): string {
|
|
42
|
+
return ops.reduce((html, op) => applyPatchByTarget(html, target, op), source);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mintLinkIdForSource(source: string): string {
|
|
46
|
+
const links = Array.from(
|
|
47
|
+
source.matchAll(/\bdata-(?:link|sync-origin)=["']([^"']+)["']/g),
|
|
48
|
+
(match) => match[1] ?? "",
|
|
49
|
+
);
|
|
50
|
+
return mintLinkId([...collectHtmlIds(source), ...links]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Write (or with `null`, remove) `data-link` on every target. */
|
|
54
|
+
export function setLinkInSource(
|
|
55
|
+
source: string,
|
|
56
|
+
targets: readonly PatchTarget[],
|
|
57
|
+
linkId: string | null,
|
|
58
|
+
): string {
|
|
59
|
+
return targets.reduce(
|
|
60
|
+
(html, target) => applyPatchByTarget(html, target, dataOp(LINK_PROPERTY, linkId)),
|
|
61
|
+
source,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Link the targets under one freshly minted id, which is also their sync origin. */
|
|
66
|
+
export function linkInSource(source: string, targets: readonly PatchTarget[]): string {
|
|
67
|
+
const linkId = mintLinkIdForSource(source);
|
|
68
|
+
return targets.reduce(
|
|
69
|
+
(html, target) =>
|
|
70
|
+
applyOps(html, target, [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]),
|
|
71
|
+
source,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Remove one element (open tag through its matching close tag) and its line's indentation. */
|
|
76
|
+
export function removeElementInSource(source: string, target: PatchTarget): string {
|
|
77
|
+
const match = findTagByTarget(source, target);
|
|
78
|
+
if (!match) return source;
|
|
79
|
+
const tagName = /^<([a-zA-Z][\w-]*)/.exec(match.tag)?.[1]?.toLowerCase() ?? "";
|
|
80
|
+
const closing = `</${tagName}>`;
|
|
81
|
+
const closeAt = match.tag.endsWith("/>")
|
|
82
|
+
? -1
|
|
83
|
+
: source.toLowerCase().indexOf(closing, match.end + 1);
|
|
84
|
+
let end = closeAt >= 0 ? closeAt + closing.length : match.end + 1;
|
|
85
|
+
let start = match.start;
|
|
86
|
+
const lineStart = source.lastIndexOf("\n", start) + 1;
|
|
87
|
+
if (/^[ \t]*$/.test(source.slice(lineStart, start)) && source[end] === "\n") {
|
|
88
|
+
start = lineStart;
|
|
89
|
+
end += 1;
|
|
90
|
+
}
|
|
91
|
+
return source.slice(0, start) + source.slice(end);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasBooleanAttr(source: string, target: PatchTarget, name: string): boolean {
|
|
95
|
+
const tag = findTagByTarget(source, target)?.tag ?? "";
|
|
96
|
+
return new RegExp(`\\s${name}(?=[\\s=/>]|$)`, "i").test(tag);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface DetachAudioResult {
|
|
100
|
+
html: string;
|
|
101
|
+
audioId: string;
|
|
102
|
+
linkId: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Detach audio: insert a linked `<audio>` over the same file and window, move the
|
|
107
|
+
* sound attributes onto it, and mute the video. Renders identically.
|
|
108
|
+
*/
|
|
109
|
+
export function detachAudioInSource(
|
|
110
|
+
source: string,
|
|
111
|
+
input: { target: PatchTarget; videoId: string | null; track: number },
|
|
112
|
+
): DetachAudioResult | null {
|
|
113
|
+
const { target } = input;
|
|
114
|
+
if (!findTagByTarget(source, target)) return null;
|
|
115
|
+
const linkId = mintLinkIdForSource(source);
|
|
116
|
+
const audioId = firstFreeName(
|
|
117
|
+
`${input.videoId || "video"}-audio`,
|
|
118
|
+
new Set(collectHtmlIds(source)),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const moved: Array<[string, string]> = [];
|
|
122
|
+
const videoOps: PatchOperation[] = [htmlOp("muted", "true"), dataOp("has-audio", null)];
|
|
123
|
+
for (const name of MOVED_SOUND_ATTRS) {
|
|
124
|
+
const value = readAttributeByTarget(source, target, name);
|
|
125
|
+
if (value === undefined) continue;
|
|
126
|
+
moved.push([name, value]);
|
|
127
|
+
videoOps.push(dataOp(name, null));
|
|
128
|
+
}
|
|
129
|
+
const automation = readAttributeByTarget(source, target, HF_AUDIO_AUTOMATION_ATTR);
|
|
130
|
+
if (automation !== undefined) {
|
|
131
|
+
const { videoKeeps, audioTakes } = splitAutomation(automation);
|
|
132
|
+
moved.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
|
|
133
|
+
videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, videoKeeps));
|
|
134
|
+
}
|
|
135
|
+
videoOps.push(dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId));
|
|
136
|
+
|
|
137
|
+
const timing: Array<[string, string]> = [];
|
|
138
|
+
for (const name of COPIED_TIMING_ATTRS) {
|
|
139
|
+
const value = readAttributeByTarget(source, target, name);
|
|
140
|
+
if (value !== undefined) timing.push([`data-${name}`, value]);
|
|
141
|
+
}
|
|
142
|
+
const attrs: Array<[string, string]> = [
|
|
143
|
+
["id", audioId],
|
|
144
|
+
["data-hf-id", `hf-${generateId()}`],
|
|
145
|
+
["class", "clip"],
|
|
146
|
+
["src", readAuthoredSrc(source, target)],
|
|
147
|
+
...timing,
|
|
148
|
+
["data-track-index", String(input.track)],
|
|
149
|
+
[MEDIA_LINK_ATTR, linkId],
|
|
150
|
+
[SYNC_ORIGIN_ATTR, linkId],
|
|
151
|
+
...moved,
|
|
152
|
+
];
|
|
153
|
+
if (hasBooleanAttr(source, target, "loop")) attrs.push(["loop", ""]);
|
|
154
|
+
if (hasBooleanAttr(source, target, HIDDEN_ATTR)) attrs.push([HIDDEN_ATTR, ""]);
|
|
155
|
+
const inserted = insertBeforeTarget(source, target, `<audio ${formatAttrs(attrs)}></audio>`);
|
|
156
|
+
return { html: applyOps(inserted, target, videoOps), audioId, linkId };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Merge back: move the audio's sound attributes onto its video, unmute the
|
|
161
|
+
* video, drop the link, and delete the audio. The inverse of detach.
|
|
162
|
+
*/
|
|
163
|
+
export function mergeAudioInSource(
|
|
164
|
+
source: string,
|
|
165
|
+
input: { videoTarget: PatchTarget; audioTarget: PatchTarget },
|
|
166
|
+
): string | null {
|
|
167
|
+
const { videoTarget, audioTarget } = input;
|
|
168
|
+
if (!findTagByTarget(source, videoTarget) || !findTagByTarget(source, audioTarget)) return null;
|
|
169
|
+
const assetOf = (target: PatchTarget) =>
|
|
170
|
+
mediaAssetIdentity({ src: readAuthoredSrc(source, target) });
|
|
171
|
+
const videoAsset = assetOf(videoTarget);
|
|
172
|
+
if (!videoAsset || videoAsset !== assetOf(audioTarget)) return null;
|
|
173
|
+
const hiddenOf = (target: PatchTarget) => hasBooleanAttr(source, target, HIDDEN_ATTR);
|
|
174
|
+
if (hiddenOf(videoTarget) !== hiddenOf(audioTarget)) return null;
|
|
175
|
+
const videoOps: PatchOperation[] = [];
|
|
176
|
+
for (const name of MOVED_SOUND_ATTRS) {
|
|
177
|
+
const value = readAttributeByTarget(source, audioTarget, name);
|
|
178
|
+
videoOps.push(dataOp(name, value ?? null));
|
|
179
|
+
}
|
|
180
|
+
const automation = readAttributeByTarget(source, audioTarget, HF_AUDIO_AUTOMATION_ATTR);
|
|
181
|
+
if (automation !== undefined) videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, automation));
|
|
182
|
+
videoOps.push(
|
|
183
|
+
htmlOp("muted", null),
|
|
184
|
+
dataOp(LINK_PROPERTY, null),
|
|
185
|
+
dataOp(SYNC_ORIGIN_PROPERTY, null),
|
|
186
|
+
dataOp("has-audio", "true"),
|
|
187
|
+
);
|
|
188
|
+
return applyOps(removeElementInSource(source, audioTarget), videoTarget, videoOps);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type TimedElement = Pick<
|
|
192
|
+
TimelineElement,
|
|
193
|
+
"id" | "key" | "tag" | "src" | "start" | "duration" | "playbackStart" | "playbackRate" | "link"
|
|
194
|
+
> &
|
|
195
|
+
Pick<TimelineElement, "muted" | "hasAudio" | "hidden" | "sourceFile" | "compositionScope">;
|
|
196
|
+
|
|
197
|
+
const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
|
|
198
|
+
const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
|
|
199
|
+
const near = (a: number, b: number) => Math.abs(a - b) <= SYNC_TOLERANCE_S;
|
|
200
|
+
|
|
201
|
+
function hasIdenticalTiming(a: TimedElement, b: TimedElement): boolean {
|
|
202
|
+
return (
|
|
203
|
+
near(a.start, b.start) &&
|
|
204
|
+
near(a.duration, b.duration) &&
|
|
205
|
+
near(a.playbackStart ?? 0, b.playbackStart ?? 0) &&
|
|
206
|
+
near(a.playbackRate ?? 1, b.playbackRate ?? 1)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sameAssetInScope = (a: TimedElement, b: TimedElement) => {
|
|
211
|
+
const asset = mediaAssetIdentity(a);
|
|
212
|
+
return asset !== null && asset === mediaAssetIdentity(b) && sameCompositionScope(a, b);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** A video whose sound is on the video itself: what Detach audio acts on. */
|
|
216
|
+
export function canDetachAudio(el: TimedElement): boolean {
|
|
217
|
+
return tagOf(el) === "video" && el.muted !== true && el.hasAudio === true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The (muted video, audio) pair Merge back acts on, found from either member:
|
|
222
|
+
* same file, and linked to each other or with identical timing.
|
|
223
|
+
*/
|
|
224
|
+
export function findMergePair<T extends TimedElement>(
|
|
225
|
+
element: T,
|
|
226
|
+
elements: readonly T[],
|
|
227
|
+
): { video: T; audio: T } | null {
|
|
228
|
+
const isVideo = tagOf(element) === "video";
|
|
229
|
+
if (!isVideo && tagOf(element) !== "audio") return null;
|
|
230
|
+
const partnerTag = isVideo ? "audio" : "video";
|
|
231
|
+
const candidates = elements.filter(
|
|
232
|
+
(el) =>
|
|
233
|
+
keyOf(el) !== keyOf(element) && tagOf(el) === partnerTag && sameAssetInScope(el, element),
|
|
234
|
+
);
|
|
235
|
+
const partner =
|
|
236
|
+
candidates.find((el) => sharesLinkGroup(el, element)) ??
|
|
237
|
+
candidates.find((el) => hasIdenticalTiming(el, element));
|
|
238
|
+
if (!partner) return null;
|
|
239
|
+
const video = isVideo ? element : partner;
|
|
240
|
+
const audio = isVideo ? partner : element;
|
|
241
|
+
if ((video.hidden === true) !== (audio.hidden === true)) return null;
|
|
242
|
+
return video.muted === true ? { video, audio } : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Exactly one video and one audio, same file, identical timing, not already linked together. */
|
|
246
|
+
export function canLinkPair(selected: readonly TimedElement[]): boolean {
|
|
247
|
+
if (selected.length !== 2) return false;
|
|
248
|
+
const [a, b] = selected;
|
|
249
|
+
if (!a || !b) return false;
|
|
250
|
+
const tags = new Set([tagOf(a), tagOf(b)]);
|
|
251
|
+
if (!tags.has("video") || !tags.has("audio")) return false;
|
|
252
|
+
if (sharesLinkGroup(a, b)) return false;
|
|
253
|
+
return sameAssetInScope(a, b) && hasIdenticalTiming(a, b);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type TrackedElement = Pick<TimelineElement, "tag" | "track" | "start" | "duration"> & {
|
|
257
|
+
authoredTrack?: number;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The detached audio's track: the first audio-only track below every visual
|
|
262
|
+
* track that is free for the clip's window, else a new track at the bottom.
|
|
263
|
+
*/
|
|
264
|
+
export function pickDetachedAudioTrack(
|
|
265
|
+
elements: readonly TrackedElement[],
|
|
266
|
+
window: { start: number; duration: number },
|
|
267
|
+
): number {
|
|
268
|
+
const trackOf = (el: TrackedElement) => el.authoredTrack ?? el.track;
|
|
269
|
+
const visualTracks = elements.filter((el) => tagOf(el) !== "audio").map(trackOf);
|
|
270
|
+
const lowestAudio = Math.max(-1, ...visualTracks) + 1;
|
|
271
|
+
const end = window.start + window.duration;
|
|
272
|
+
const allTracks = elements.map(trackOf);
|
|
273
|
+
const bottom = Math.max(lowestAudio - 1, ...allTracks) + 1;
|
|
274
|
+
for (let track = lowestAudio; track < bottom; track += 1) {
|
|
275
|
+
const occupants = elements.filter((el) => trackOf(el) === track);
|
|
276
|
+
const free = occupants.every(
|
|
277
|
+
(el) => tagOf(el) === "audio" && (el.start >= end || el.start + el.duration <= window.start),
|
|
278
|
+
);
|
|
279
|
+
if (free) return track;
|
|
280
|
+
}
|
|
281
|
+
return bottom;
|
|
282
|
+
}
|
|
@@ -879,10 +879,11 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
|
|
|
879
879
|
htmlOp("muted", "true"),
|
|
880
880
|
dataOp("has-audio", null),
|
|
881
881
|
dataOp("volume", null),
|
|
882
|
-
dataOp("link", "
|
|
882
|
+
dataOp("link", "lk-1"),
|
|
883
|
+
dataOp("sync-origin", "lk-1"),
|
|
883
884
|
]);
|
|
884
885
|
const source =
|
|
885
|
-
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="
|
|
886
|
+
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="lk-1"></video></div>';
|
|
886
887
|
const prepared = options?.prepareContent?.(source) ?? "";
|
|
887
888
|
const audio = new DOMParser()
|
|
888
889
|
.parseFromString(prepared, "text/html")
|
|
@@ -890,7 +891,7 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
|
|
|
890
891
|
expect(audio?.getAttribute("src")).toBe("assets/intro-loop.mp4");
|
|
891
892
|
expect(audio?.getAttribute("data-start")).toBe("2");
|
|
892
893
|
expect(audio?.getAttribute("data-duration")).toBe("3");
|
|
893
|
-
expect(audio?.getAttribute("data-link")).toBe("
|
|
894
|
+
expect(audio?.getAttribute("data-link")).toBe("lk-1");
|
|
894
895
|
expect(audio?.getAttribute("data-volume")).toBe("0.5");
|
|
895
896
|
expect(text).toContain("Background removed. Sound kept on a linked audio track.");
|
|
896
897
|
});
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
audioGainToText,
|
|
25
25
|
} from "@hyperframes/core/audio-gain";
|
|
26
26
|
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
27
|
+
import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
|
|
27
28
|
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
28
29
|
|
|
29
30
|
// fallow-ignore-next-line complexity
|
|
@@ -56,6 +57,10 @@ export function MediaSection({
|
|
|
56
57
|
) => Promise<BackgroundRemovalResult>;
|
|
57
58
|
}) {
|
|
58
59
|
const track = useTrackDesignInput();
|
|
60
|
+
const setRate = withLinkedPlaybackRate(
|
|
61
|
+
onSetAttribute,
|
|
62
|
+
useLinkedSpeedCommit(element, onSetAttributeBatch),
|
|
63
|
+
);
|
|
59
64
|
const isVideo = element.tagName === "video";
|
|
60
65
|
const isAudio = element.tagName === "audio";
|
|
61
66
|
const isImage = element.tagName === "img";
|
|
@@ -279,7 +284,7 @@ export function MediaSection({
|
|
|
279
284
|
displayValue={`${formatNumericValue(playbackRate)}x`}
|
|
280
285
|
formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
|
|
281
286
|
onCommit={(next) => {
|
|
282
|
-
void
|
|
287
|
+
void setRate("playback-rate", formatNumericValue(next / 100));
|
|
283
288
|
}}
|
|
284
289
|
/>
|
|
285
290
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { InlineTextCaret } from "./InlineTextCaret";
|
|
2
3
|
import { InlineTextToolbar } from "./InlineTextToolbar";
|
|
3
4
|
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
4
5
|
import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
|
|
@@ -110,10 +111,16 @@ export function useInlineTextEditing(
|
|
|
110
111
|
return {
|
|
111
112
|
editing: inlineText.session !== null,
|
|
112
113
|
toolbar: (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
<>
|
|
115
|
+
<InlineTextToolbar
|
|
116
|
+
session={inlineText.session}
|
|
117
|
+
iframe={actions?.previewIframeRef?.current ?? null}
|
|
118
|
+
/>
|
|
119
|
+
<InlineTextCaret
|
|
120
|
+
session={inlineText.session}
|
|
121
|
+
iframe={actions?.previewIframeRef?.current ?? null}
|
|
122
|
+
/>
|
|
123
|
+
</>
|
|
117
124
|
),
|
|
118
125
|
handleKeyDown,
|
|
119
126
|
startFromPress: (event) => {
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
|
27
27
|
import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp";
|
|
28
28
|
import { clampNumber } from "../../utils/studioHelpers";
|
|
29
|
+
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
30
|
+
import type { LinkedSpeedCommit } from "./linkedSpeedEdits";
|
|
29
31
|
|
|
30
32
|
export interface LaneBinding {
|
|
31
33
|
automated: boolean;
|
|
@@ -59,6 +61,7 @@ export function useVolumeAutomation(
|
|
|
59
61
|
element: DomEditSelection,
|
|
60
62
|
currentTime: number,
|
|
61
63
|
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
64
|
+
linkedSpeed: LinkedSpeedCommit | null = null,
|
|
62
65
|
): VolumeAutomationBinding {
|
|
63
66
|
// The chain is not needed to resolve a volume or rate lane: both are always valid
|
|
64
67
|
// targets, so this deliberately does not parse it.
|
|
@@ -77,7 +80,22 @@ export function useVolumeAutomation(
|
|
|
77
80
|
// playing track, while the same click on an effect parameter did not.
|
|
78
81
|
void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
|
|
79
82
|
};
|
|
80
|
-
|
|
83
|
+
type Edit = (current: HfAutomation) => HfAutomation;
|
|
84
|
+
const writeOwn = (edit: Edit) => write(edit(automation));
|
|
85
|
+
// Speed moves every data-link member together, so a linked clip's rate lane edits fan out.
|
|
86
|
+
const writeRate = (edit: Edit) => {
|
|
87
|
+
if (!linkedSpeed) return writeOwn(edit);
|
|
88
|
+
void linkedSpeed.commitAttribute(
|
|
89
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
90
|
+
(raw) => automationAttrValue(edit(readPanelAutomation(raw, undefined))) || null,
|
|
91
|
+
"Edit speed",
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
const laneBinding = (
|
|
95
|
+
target: string,
|
|
96
|
+
seed: number,
|
|
97
|
+
writeLane: (edit: Edit) => void,
|
|
98
|
+
): LaneBinding => {
|
|
81
99
|
const lane = automation.lanes.find((l) => l.target === target);
|
|
82
100
|
return {
|
|
83
101
|
automated: lane !== undefined,
|
|
@@ -85,9 +103,9 @@ export function useVolumeAutomation(
|
|
|
85
103
|
? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale)
|
|
86
104
|
: undefined,
|
|
87
105
|
// Seeded at the level the control already shows, so automating does not change it.
|
|
88
|
-
onAutomate: () =>
|
|
89
|
-
onRemoveAutomation: () =>
|
|
90
|
-
onCommitAt: (v: number) =>
|
|
106
|
+
onAutomate: () => writeLane((a) => withSeededLane(a, target, seed)),
|
|
107
|
+
onRemoveAutomation: () => writeLane((a) => withoutLane(a, target)),
|
|
108
|
+
onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)),
|
|
91
109
|
};
|
|
92
110
|
};
|
|
93
111
|
// `??` alone would let an empty `data-volume` through as Number("") === 0, so
|
|
@@ -96,9 +114,13 @@ export function useVolumeAutomation(
|
|
|
96
114
|
const raw = element.dataAttributes?.["volume"];
|
|
97
115
|
const parsed = raw ? Number(raw) : 1;
|
|
98
116
|
const current = Number.isFinite(parsed) ? parsed : 1;
|
|
99
|
-
const volume = laneBinding(VOLUME_TARGET, current);
|
|
117
|
+
const volume = laneBinding(VOLUME_TARGET, current, writeOwn);
|
|
100
118
|
const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
|
|
101
|
-
const rate = laneBinding(
|
|
119
|
+
const rate = laneBinding(
|
|
120
|
+
RATE_TARGET,
|
|
121
|
+
Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1,
|
|
122
|
+
writeRate,
|
|
123
|
+
);
|
|
102
124
|
return {
|
|
103
125
|
volumeAutomated: volume.automated,
|
|
104
126
|
automatedVolumeValue: volume.automatedValue,
|
|
@@ -109,7 +131,7 @@ export function useVolumeAutomation(
|
|
|
109
131
|
...rate,
|
|
110
132
|
canApplyPreset: elDuration > 0,
|
|
111
133
|
onApplyPreset: (id) => {
|
|
112
|
-
if (elDuration > 0)
|
|
134
|
+
if (elDuration > 0) writeRate((a) => withLane(a, speedPresetLane(id, elDuration)));
|
|
113
135
|
},
|
|
114
136
|
},
|
|
115
137
|
};
|
|
@@ -54,6 +54,8 @@ export interface TimelineEditCallbackDeps {
|
|
|
54
54
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
55
55
|
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
56
56
|
handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
57
|
+
handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
58
|
+
handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
|
|
57
59
|
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
|
58
60
|
handleGroupClips?: (
|
|
59
61
|
clipIds: readonly string[],
|
|
@@ -134,6 +136,8 @@ export function useTimelineEditCallbacks({
|
|
|
134
136
|
handleRazorSplit,
|
|
135
137
|
handleRazorSplitAll,
|
|
136
138
|
handleFreezeFrame,
|
|
139
|
+
handleLinkEdit,
|
|
140
|
+
handleTimelineElementDeleteOnly,
|
|
137
141
|
handleGroupClips,
|
|
138
142
|
setElementFxAttribute,
|
|
139
143
|
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
|
@@ -230,6 +234,8 @@ export function useTimelineEditCallbacks({
|
|
|
230
234
|
onRazorSplit: handleRazorSplit,
|
|
231
235
|
onRazorSplitAll: handleRazorSplitAll,
|
|
232
236
|
onFreezeFrame: handleFreezeFrame,
|
|
237
|
+
onLinkEdit: handleLinkEdit,
|
|
238
|
+
onDeleteElementOnly: handleTimelineElementDeleteOnly,
|
|
233
239
|
onDeleteAllKeyframes: (element, animationId) => {
|
|
234
240
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
235
241
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
@@ -427,6 +433,8 @@ export function useTimelineEditCallbacks({
|
|
|
427
433
|
handleRazorSplit,
|
|
428
434
|
handleRazorSplitAll,
|
|
429
435
|
handleFreezeFrame,
|
|
436
|
+
handleLinkEdit,
|
|
437
|
+
handleTimelineElementDeleteOnly,
|
|
430
438
|
handleGsapRemoveAllKeyframes,
|
|
431
439
|
resolveElementAnimations,
|
|
432
440
|
resolveKeyframeTarget,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import React, { act, useState } from "react";
|
|
6
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
7
6
|
import { compile } from "tailwindcss";
|
|
8
7
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
9
8
|
import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
|
|
@@ -11,27 +10,11 @@ import { ContextMenu, Menu, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparat
|
|
|
11
10
|
import { Popover } from "./Popover";
|
|
12
11
|
import { isTypingTarget } from "../../utils/typingTarget";
|
|
13
12
|
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
13
|
+
import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
|
|
14
14
|
|
|
15
15
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
function render(element: React.ReactElement): HTMLElement {
|
|
20
|
-
const host = document.createElement("div");
|
|
21
|
-
document.body.append(host);
|
|
22
|
-
const root = createRoot(host);
|
|
23
|
-
mounted = { root, host };
|
|
24
|
-
act(() => root.render(element));
|
|
25
|
-
return host;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
if (!mounted) return;
|
|
30
|
-
const { root, host } = mounted;
|
|
31
|
-
mounted = null;
|
|
32
|
-
act(() => root.unmount());
|
|
33
|
-
host.remove();
|
|
34
|
-
});
|
|
17
|
+
afterEach(cleanupMounted);
|
|
35
18
|
|
|
36
19
|
/** Base UI moves focus into the popup one task after open, not synchronously. */
|
|
37
20
|
async function settle(): Promise<void> {
|
|
@@ -310,9 +293,7 @@ describe("Popover", () => {
|
|
|
310
293
|
renderOpen(false);
|
|
311
294
|
await settle();
|
|
312
295
|
expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
|
|
313
|
-
|
|
314
|
-
mounted?.host.remove();
|
|
315
|
-
mounted = null;
|
|
296
|
+
cleanupMounted();
|
|
316
297
|
|
|
317
298
|
renderOpen(true);
|
|
318
299
|
await settle();
|
|
@@ -52,8 +52,8 @@ export const popupSurface = cn(
|
|
|
52
52
|
"data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
|
|
53
53
|
);
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
const POPUP_LAYER = "z-200";
|
|
55
|
+
/** Every floating list, menu, popover and tooltip sits on this layer, above panel chrome and modals. */
|
|
56
|
+
export const POPUP_LAYER = "z-200";
|
|
57
57
|
|
|
58
58
|
const menuPopup = cn(popupSurface, "min-w-36 p-1 shadow-menu");
|
|
59
59
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
7
7
|
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
|
8
8
|
import { cn } from "./cn";
|
|
9
|
-
import { popupSurface, type PopupPreviewState } from "./Menu";
|
|
9
|
+
import { POPUP_LAYER, popupSurface, type PopupPreviewState } from "./Menu";
|
|
10
10
|
|
|
11
11
|
type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
12
12
|
|
|
@@ -70,7 +70,7 @@ export function Popover({
|
|
|
70
70
|
sideOffset={sideOffset}
|
|
71
71
|
collisionPadding={VIEWPORT_MARGIN}
|
|
72
72
|
arrowPadding={ARROW_CORNER_CLEARANCE}
|
|
73
|
-
className=
|
|
73
|
+
className={POPUP_LAYER}
|
|
74
74
|
>
|
|
75
75
|
<BasePopover.Popup
|
|
76
76
|
aria-label={ariaLabel}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Select as BaseSelect } from "@base-ui/react/select";
|
|
7
7
|
import { cn } from "./cn";
|
|
8
8
|
import { fieldBase } from "./Input";
|
|
9
|
-
import { floatingMotion } from "./Menu";
|
|
9
|
+
import { floatingMotion, POPUP_LAYER } from "./Menu";
|
|
10
10
|
import type { PreviewState } from "./Button";
|
|
11
11
|
|
|
12
12
|
export interface SelectOption {
|
|
@@ -77,7 +77,7 @@ export function Select({
|
|
|
77
77
|
</BaseSelect.Trigger>
|
|
78
78
|
|
|
79
79
|
<BaseSelect.Portal>
|
|
80
|
-
<BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
|
|
80
|
+
<BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false} className={POPUP_LAYER}>
|
|
81
81
|
<BaseSelect.Popup
|
|
82
82
|
className={cn(
|
|
83
83
|
"min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
|
|
7
7
|
import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
|
|
8
8
|
import { cn } from "./cn";
|
|
9
|
-
import { floatingMotion } from "./Menu";
|
|
9
|
+
import { floatingMotion, POPUP_LAYER } from "./Menu";
|
|
10
10
|
|
|
11
11
|
interface TooltipProps {
|
|
12
12
|
label: string;
|
|
@@ -39,7 +39,7 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|
|
39
39
|
side={side}
|
|
40
40
|
sideOffset={SIDE_OFFSET}
|
|
41
41
|
collisionPadding={VIEWPORT_MARGIN}
|
|
42
|
-
className=
|
|
42
|
+
className={POPUP_LAYER}
|
|
43
43
|
>
|
|
44
44
|
<BaseTooltip.Popup
|
|
45
45
|
id={tooltipId}
|
|
@@ -4,6 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
4
4
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
5
5
|
|
|
6
6
|
let mounted: { root: Root; host: HTMLElement } | null = null;
|
|
7
|
+
const tracked: Root[] = [];
|
|
8
|
+
|
|
9
|
+
export function trackedRoot(host: HTMLElement): Root {
|
|
10
|
+
const root = createRoot(host);
|
|
11
|
+
tracked.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
7
14
|
|
|
8
15
|
/** Renders into a fresh host attached to the body; `cleanupMounted` tears it down. */
|
|
9
16
|
export function mountHost(element: ReactElement): HTMLElement {
|
|
@@ -16,6 +23,7 @@ export function mountHost(element: ReactElement): HTMLElement {
|
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function cleanupMounted(): void {
|
|
26
|
+
act(() => tracked.splice(0).forEach((root) => root.unmount()));
|
|
19
27
|
if (!mounted) return;
|
|
20
28
|
const { root, host } = mounted;
|
|
21
29
|
mounted = null;
|
|
@@ -4,7 +4,6 @@ import { createRequire } from "node:module";
|
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import React, { act } from "react";
|
|
7
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
8
7
|
import { compile } from "tailwindcss";
|
|
9
8
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
9
|
|
|
@@ -21,33 +20,15 @@ import { Toggle } from "./Toggle";
|
|
|
21
20
|
import { isTypingTarget } from "../../utils/typingTarget";
|
|
22
21
|
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
23
22
|
import { __resetDesignInputThrottle, trackDesignInput } from "../../utils/designInputTracking";
|
|
23
|
+
import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
|
|
24
24
|
|
|
25
25
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
26
26
|
|
|
27
|
-
let mounted: { root: Root; host: HTMLElement } | null = null;
|
|
28
|
-
|
|
29
|
-
function render(element: React.ReactElement): HTMLElement {
|
|
30
|
-
const host = document.createElement("div");
|
|
31
|
-
document.body.append(host);
|
|
32
|
-
const root = createRoot(host);
|
|
33
|
-
mounted = { root, host };
|
|
34
|
-
act(() => root.render(element));
|
|
35
|
-
return host;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function unmount() {
|
|
39
|
-
if (!mounted) return;
|
|
40
|
-
const { root, host } = mounted;
|
|
41
|
-
mounted = null;
|
|
42
|
-
act(() => root.unmount());
|
|
43
|
-
host.remove();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
27
|
beforeEach(() => {
|
|
47
28
|
trackStudioEvent.mockReset();
|
|
48
29
|
__resetDesignInputThrottle();
|
|
49
30
|
});
|
|
50
|
-
afterEach(
|
|
31
|
+
afterEach(cleanupMounted);
|
|
51
32
|
|
|
52
33
|
/** Base UI moves focus a task later than React renders; happy-dom is no faster. */
|
|
53
34
|
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
@@ -39,10 +39,13 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
|
|
|
39
39
|
onSetElementAttributeQuiet: true,
|
|
40
40
|
onRevertElementAttributeLive: true,
|
|
41
41
|
onBlockedEditAttempt: true,
|
|
42
|
+
onLinkEdit: true,
|
|
43
|
+
onDeleteElementOnly: true,
|
|
42
44
|
onSplitElement: true,
|
|
43
45
|
onRazorSplit: true,
|
|
44
46
|
onRazorSplitAll: true,
|
|
45
47
|
onFreezeFrame: true,
|
|
48
|
+
onNotice: true,
|
|
46
49
|
onDeleteKeyframe: true,
|
|
47
50
|
onDeleteAllKeyframes: true,
|
|
48
51
|
onMoveKeyframeToPlayhead: true,
|