@hyperframes/studio 0.8.107 → 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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.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-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -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/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- 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/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -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 +4 -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/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- 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 +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- 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 +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- 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 +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- 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 +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -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/useClipToolState.ts +92 -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/lib/timelineElementHelpers.ts +9 -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/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- 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/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -29,11 +29,11 @@ import {
|
|
|
29
29
|
resolveNextCarveSettings,
|
|
30
30
|
} from "./useFxCarveGrouping.js";
|
|
31
31
|
import { type HfAutomation } from "@hyperframes/core/audio-automation";
|
|
32
|
-
import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
|
|
33
32
|
import { trackCarveChanged } from "./audioFxTelemetry.js";
|
|
34
33
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
35
34
|
import { usePlayerStore } from "../../player";
|
|
36
|
-
import {
|
|
35
|
+
import { dropCarveOutput, resolveCarveVoices, withoutCarveLanes } from "./carveOutput.js";
|
|
36
|
+
import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
|
|
37
37
|
import { readClipClock } from "./clipAudioClock.js";
|
|
38
38
|
import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
|
|
39
39
|
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
@@ -55,16 +55,6 @@ function carveAction(
|
|
|
55
55
|
return "strength";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** Lanes belonging to nodes the carve generated, which a re-run replaces. */
|
|
59
|
-
function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
|
|
60
|
-
const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
|
|
61
|
-
if (prefixes.length === 0) return automation;
|
|
62
|
-
return {
|
|
63
|
-
version: automation.version,
|
|
64
|
-
lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
58
|
/**
|
|
69
59
|
* Every setting here describes the filters, so changing one rebuilds them.
|
|
70
60
|
* There is no apply button: a carve naming a voice with no filters behind it
|
|
@@ -86,54 +76,6 @@ function carveNeedsReanalysis(
|
|
|
86
76
|
);
|
|
87
77
|
}
|
|
88
78
|
|
|
89
|
-
/**
|
|
90
|
-
* Every named voice that is actually there with something to decode. A source
|
|
91
|
-
* naming a deleted track is skipped rather than failing the whole analysis.
|
|
92
|
-
*
|
|
93
|
-
* Read out to plain values here rather than carrying elements around: it is
|
|
94
|
-
* what lets the src and the start be non-null by construction downstream
|
|
95
|
-
* instead of by assertion.
|
|
96
|
-
*/
|
|
97
|
-
function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
|
|
98
|
-
const voices: CarveClip[] = [];
|
|
99
|
-
for (const id of sources) {
|
|
100
|
-
const el = doc.getElementById(id);
|
|
101
|
-
// By tag name, not `instanceof HTMLAudioElement`: these elements belong to
|
|
102
|
-
// the composition's iframe document, so the constructor they were made
|
|
103
|
-
// from is not this realm's and the instanceof is false for every one.
|
|
104
|
-
if (el?.tagName !== "AUDIO") continue;
|
|
105
|
-
const src = el.getAttribute("src");
|
|
106
|
-
if (!src) continue;
|
|
107
|
-
const clock = readClipClock((name) => el.getAttribute(name));
|
|
108
|
-
voices.push({ src, start: el.getAttribute("data-start"), clock });
|
|
109
|
-
}
|
|
110
|
-
return voices;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* What the carve generated is only justified by the voices it was measured
|
|
115
|
-
* from: switched off, or left naming none — every source deleted, say — there
|
|
116
|
-
* is nothing those filters are making room for. Left behind they keep dipping
|
|
117
|
-
* the bed with nothing in the panel to explain them.
|
|
118
|
-
*/
|
|
119
|
-
async function dropCarveOutput(
|
|
120
|
-
chain: HfAudioFxChain,
|
|
121
|
-
automation: HfAutomation,
|
|
122
|
-
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
123
|
-
): Promise<void> {
|
|
124
|
-
const carriedOver = withoutCarveLanes(automation, chain);
|
|
125
|
-
if (carriedOver.lanes.length !== automation.lanes.length) {
|
|
126
|
-
await onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(carriedOver) || null);
|
|
127
|
-
}
|
|
128
|
-
const kept = chain.nodes.filter((n) => !n.fromCarve);
|
|
129
|
-
if (kept.length !== chain.nodes.length) {
|
|
130
|
-
await onSetAttributeQuiet(
|
|
131
|
-
HF_AUDIO_FX_ATTR,
|
|
132
|
-
kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
79
|
export function useFxCarve(
|
|
138
80
|
element: DomEditSelection,
|
|
139
81
|
chain: HfAudioFxChain,
|
|
@@ -179,7 +121,7 @@ export function useFxCarve(
|
|
|
179
121
|
} => {
|
|
180
122
|
const doc = element.element?.ownerDocument;
|
|
181
123
|
if (!doc || !couldBeBed) return { sourceOptions: [], autoSourceIds: [] };
|
|
182
|
-
const others = Array.from(doc.querySelectorAll
|
|
124
|
+
const others = Array.from(doc.querySelectorAll("audio[id], video[id]")).filter(
|
|
183
125
|
(a) => a.id !== element.id,
|
|
184
126
|
);
|
|
185
127
|
// Only tracks that are actually playing while this bed is. A voice somewhere
|
|
@@ -54,6 +54,19 @@ describe("collectCarveCandidates", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
describe("collectCarveCandidates with video", () => {
|
|
58
|
+
it("offers an ungrouped audible video, not a muted one", () => {
|
|
59
|
+
const doc = previewDoc(`
|
|
60
|
+
<video id="a-roll"></video>
|
|
61
|
+
<video id="b-roll" muted></video>
|
|
62
|
+
<audio id="bed"></audio>
|
|
63
|
+
`);
|
|
64
|
+
const others = Array.from(doc.querySelectorAll("video[id]"));
|
|
65
|
+
const ids = collectCarveCandidates(doc, others, () => true, "bed").map((c) => c.id);
|
|
66
|
+
expect(ids).toEqual(["a-roll"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
describe("carverAgainst", () => {
|
|
58
71
|
// The far-end guard has to see through a GROUP source. A plural carve names a
|
|
59
72
|
// group — that is what the lint rule pushes authors toward — so matching raw
|
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
resolveAudioGroups,
|
|
21
21
|
resolveCarveSourceIds,
|
|
22
22
|
} from "@hyperframes/core/audio-groups";
|
|
23
|
+
import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
|
|
24
|
+
|
|
25
|
+
export function isCarveVoiceElement(el: Element | null): el is Element {
|
|
26
|
+
return el !== null && (el.tagName === "AUDIO" || isAudibleVideoNode(el));
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* An id for a new voiceover group, de-duped against every id already in the
|
|
@@ -130,7 +135,7 @@ function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string)
|
|
|
130
135
|
|
|
131
136
|
export function collectCarveCandidates(
|
|
132
137
|
doc: Document,
|
|
133
|
-
others: readonly
|
|
138
|
+
others: readonly Element[],
|
|
134
139
|
overlapsBed: (a: Element) => boolean,
|
|
135
140
|
/** The bed's own id, so neither it nor the group it belongs to is offered. */
|
|
136
141
|
bedId?: string,
|
|
@@ -141,7 +146,7 @@ export function collectCarveCandidates(
|
|
|
141
146
|
const excluded = excludedFor(groupByMember, bedId);
|
|
142
147
|
const offeredGroupIds = new Set<string>();
|
|
143
148
|
const described: CarveCandidate[] = [];
|
|
144
|
-
for (const a of others) {
|
|
149
|
+
for (const a of others.filter(isCarveVoiceElement)) {
|
|
145
150
|
const group = groupByMember.get(a.id);
|
|
146
151
|
if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
|
|
147
152
|
if (!group) {
|
|
@@ -161,7 +166,7 @@ export function collectCarveCandidates(
|
|
|
161
166
|
// iframe document, so the constructor is a different realm's and the
|
|
162
167
|
// instanceof is false for every one (mirrors resolveCarveVoices in
|
|
163
168
|
// useFxCarve.ts).
|
|
164
|
-
.filter((el): el is HTMLElement => el
|
|
169
|
+
.filter((el): el is HTMLElement => isCarveVoiceElement(el));
|
|
165
170
|
if (!members.some(overlapsBed)) continue;
|
|
166
171
|
offeredGroupIds.add(group.id);
|
|
167
172
|
described.push({
|
|
@@ -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
|
};
|
|
@@ -12,6 +12,8 @@ import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
|
|
|
12
12
|
import {
|
|
13
13
|
AudioMeterStrip,
|
|
14
14
|
type AudioMeterStripProps,
|
|
15
|
+
CLIP_LATCH_PEAK,
|
|
16
|
+
stepClipLatch,
|
|
15
17
|
evictGoneMeterState,
|
|
16
18
|
followMeterHook,
|
|
17
19
|
stepAndPaintStrips,
|
|
@@ -416,3 +418,54 @@ describe("stepAndPaintStrips", () => {
|
|
|
416
418
|
expect(state.has("loud")).toBe(true);
|
|
417
419
|
});
|
|
418
420
|
});
|
|
421
|
+
|
|
422
|
+
describe("CLIP light", () => {
|
|
423
|
+
const hookAt = (peak: number) => ({
|
|
424
|
+
start: vi.fn(),
|
|
425
|
+
stop: vi.fn(),
|
|
426
|
+
read: vi.fn(() => ({ master: { l: peak, r: 0 }, groups: {} })),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("latches at the -1 dBFS export ceiling, stays lit when the level drops, and resets on click", () => {
|
|
430
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
431
|
+
const hook = hookAt(1);
|
|
432
|
+
setHook(hook);
|
|
433
|
+
const { host } = mount();
|
|
434
|
+
const light = host.querySelector<HTMLElement>("[data-testid=meter-clip-light]");
|
|
435
|
+
expect(light?.dataset.lit).toBe("false");
|
|
436
|
+
tick();
|
|
437
|
+
expect(light?.dataset.lit).toBe("true");
|
|
438
|
+
hook.read.mockReturnValue({ master: { l: 0.1, r: 0.1 }, groups: {} });
|
|
439
|
+
tick();
|
|
440
|
+
expect(light?.dataset.lit).toBe("true");
|
|
441
|
+
act(() => light?.click());
|
|
442
|
+
expect(light?.dataset.lit).toBe("false");
|
|
443
|
+
tick();
|
|
444
|
+
expect(light?.dataset.lit).toBe("false");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("stays dark in the red band just under the ceiling", () => {
|
|
448
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
449
|
+
setHook(hookAt(10 ** (-1.2 / 20)));
|
|
450
|
+
const { host } = mount();
|
|
451
|
+
tick();
|
|
452
|
+
expect(host.querySelector<HTMLElement>("[data-testid=meter-clip-light]")?.dataset.lit).toBe(
|
|
453
|
+
"false",
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe("stepClipLatch", () => {
|
|
459
|
+
it("lights exactly at -1 dBFS on either channel, not below", () => {
|
|
460
|
+
expect(CLIP_LATCH_PEAK).toBeCloseTo(0.891251, 6);
|
|
461
|
+
expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK, r: 0 })).toBe(true);
|
|
462
|
+
expect(stepClipLatch(false, { l: 0, r: CLIP_LATCH_PEAK })).toBe(true);
|
|
463
|
+
expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK - 1e-6, r: 0 })).toBe(false);
|
|
464
|
+
expect(stepClipLatch(false, undefined)).toBe(false);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("holds once lit, whatever comes next", () => {
|
|
468
|
+
expect(stepClipLatch(true, { l: 0, r: 0 })).toBe(true);
|
|
469
|
+
expect(stepClipLatch(true, undefined)).toBe(true);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
useMemo,
|
|
6
6
|
useRef,
|
|
7
7
|
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
type ReactNode,
|
|
8
9
|
type Ref,
|
|
9
10
|
type RefObject,
|
|
10
11
|
} from "react";
|
|
@@ -20,6 +21,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
|
|
|
20
21
|
import { usePlayerStore } from "../../player";
|
|
21
22
|
import { clampNumber } from "../../utils/studioHelpers";
|
|
22
23
|
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
24
|
+
import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
|
|
23
25
|
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
24
26
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
25
27
|
import {
|
|
@@ -49,6 +51,24 @@ type StripBars = [Bars, Bars];
|
|
|
49
51
|
|
|
50
52
|
const MONITOR_LABEL = "Monitor";
|
|
51
53
|
|
|
54
|
+
/** The CLIP light latches at the export ceiling, stricter than RED_AT. */
|
|
55
|
+
export const CLIP_LATCH_PEAK = EXPORT_PEAK_CEILING;
|
|
56
|
+
|
|
57
|
+
export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean {
|
|
58
|
+
if (latched || !master) return latched;
|
|
59
|
+
return Math.max(master.l, master.r) >= CLIP_LATCH_PEAK;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function paintClipLight(el: HTMLElement | null, lit: boolean): void {
|
|
63
|
+
if (!el) return;
|
|
64
|
+
el.dataset.lit = String(lit);
|
|
65
|
+
el.style.setProperty(
|
|
66
|
+
"background-color",
|
|
67
|
+
lit ? "var(--color-red-500)" : "var(--color-neutral-800)",
|
|
68
|
+
);
|
|
69
|
+
el.style.setProperty("color", lit ? "var(--color-white)" : "var(--color-neutral-500)");
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
/** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
|
|
53
73
|
const AMBER_AT = markFraction(-6);
|
|
54
74
|
const RED_AT = markFraction(-3);
|
|
@@ -178,10 +198,16 @@ export function stepAndPaintStrips(
|
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
|
|
201
|
+
interface ClipLatch {
|
|
202
|
+
lit: boolean;
|
|
203
|
+
light: HTMLElement | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
181
206
|
function useMeterLoop(
|
|
182
207
|
strips: Strip[],
|
|
183
208
|
bars: RefObject<Map<string | null, StripBars>>,
|
|
184
209
|
iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
|
|
210
|
+
clip: RefObject<ClipLatch>,
|
|
185
211
|
) {
|
|
186
212
|
const shell = useStudioShellContextOptional();
|
|
187
213
|
const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
|
|
@@ -200,6 +226,11 @@ function useMeterLoop(
|
|
|
200
226
|
last = now;
|
|
201
227
|
evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
|
|
202
228
|
stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
|
|
229
|
+
const lit = stepClipLatch(clip.current.lit, levels?.master);
|
|
230
|
+
if (lit !== clip.current.lit) {
|
|
231
|
+
clip.current.lit = lit;
|
|
232
|
+
paintClipLight(clip.current.light, lit);
|
|
233
|
+
}
|
|
203
234
|
};
|
|
204
235
|
raf = requestAnimationFrame(tick);
|
|
205
236
|
return () => {
|
|
@@ -210,7 +241,29 @@ function useMeterLoop(
|
|
|
210
241
|
// Preview iframe already gone.
|
|
211
242
|
}
|
|
212
243
|
};
|
|
213
|
-
}, [previewIframeRef, bars]);
|
|
244
|
+
}, [previewIframeRef, bars, clip]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ClipLight({ clip }: { clip: RefObject<ClipLatch> }) {
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
data-testid="meter-clip-light"
|
|
252
|
+
data-lit="false"
|
|
253
|
+
title="Lights when the preview reaches −1 dBFS, the export ceiling. Click to reset."
|
|
254
|
+
ref={(el) => {
|
|
255
|
+
clip.current.light = el;
|
|
256
|
+
paintClipLight(el, clip.current.lit);
|
|
257
|
+
}}
|
|
258
|
+
onClick={() => {
|
|
259
|
+
clip.current.lit = false;
|
|
260
|
+
paintClipLight(clip.current.light, false);
|
|
261
|
+
}}
|
|
262
|
+
className="rounded-[2px] px-1 font-mono text-[9px] leading-[14px] font-bold tracking-wide"
|
|
263
|
+
>
|
|
264
|
+
CLIP
|
|
265
|
+
</button>
|
|
266
|
+
);
|
|
214
267
|
}
|
|
215
268
|
|
|
216
269
|
function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
|
|
@@ -341,11 +394,13 @@ function MeterStrip({
|
|
|
341
394
|
register,
|
|
342
395
|
onLive,
|
|
343
396
|
onCommit,
|
|
397
|
+
clipLight,
|
|
344
398
|
}: {
|
|
345
399
|
strip: Strip;
|
|
346
400
|
register: (id: string | null, bars: StripBars | null) => void;
|
|
347
401
|
onLive: (id: string | null, volume: number) => void;
|
|
348
402
|
onCommit: (id: string | null, volume: number) => void;
|
|
403
|
+
clipLight?: ReactNode;
|
|
349
404
|
}) {
|
|
350
405
|
const refs = [
|
|
351
406
|
useRef<HTMLDivElement>(null),
|
|
@@ -364,6 +419,7 @@ function MeterStrip({
|
|
|
364
419
|
}, [strip.id, register]);
|
|
365
420
|
return (
|
|
366
421
|
<div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
|
|
422
|
+
{clipLight}
|
|
367
423
|
<div
|
|
368
424
|
className="flex min-h-0 flex-1 items-stretch gap-1.5"
|
|
369
425
|
aria-label={`${strip.label} level`}
|
|
@@ -421,7 +477,8 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
|
421
477
|
else bars.current.delete(id);
|
|
422
478
|
}).current;
|
|
423
479
|
const { onLive, onCommit } = useVolumeHandlers();
|
|
424
|
-
|
|
480
|
+
const clip = useRef<ClipLatch>({ lit: false, light: null });
|
|
481
|
+
useMeterLoop(strips, bars, previewIframeRef, clip);
|
|
425
482
|
return (
|
|
426
483
|
<div
|
|
427
484
|
data-testid="audio-meter-strip"
|
|
@@ -434,6 +491,7 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
|
434
491
|
register={register}
|
|
435
492
|
onLive={onLive}
|
|
436
493
|
onCommit={onCommit}
|
|
494
|
+
clipLight={strip.id === null ? <ClipLight clip={clip} /> : undefined}
|
|
437
495
|
/>
|
|
438
496
|
))}
|
|
439
497
|
</div>
|
|
@@ -53,6 +53,9 @@ export interface TimelineEditCallbackDeps {
|
|
|
53
53
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
54
54
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
55
55
|
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
56
|
+
handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
57
|
+
handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
58
|
+
handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
|
|
56
59
|
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
|
57
60
|
handleGroupClips?: (
|
|
58
61
|
clipIds: readonly string[],
|
|
@@ -132,6 +135,9 @@ export function useTimelineEditCallbacks({
|
|
|
132
135
|
handleTimelineElementSplit,
|
|
133
136
|
handleRazorSplit,
|
|
134
137
|
handleRazorSplitAll,
|
|
138
|
+
handleFreezeFrame,
|
|
139
|
+
handleLinkEdit,
|
|
140
|
+
handleTimelineElementDeleteOnly,
|
|
135
141
|
handleGroupClips,
|
|
136
142
|
setElementFxAttribute,
|
|
137
143
|
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
|
@@ -227,6 +233,9 @@ export function useTimelineEditCallbacks({
|
|
|
227
233
|
onSplitElement: handleTimelineElementSplit,
|
|
228
234
|
onRazorSplit: handleRazorSplit,
|
|
229
235
|
onRazorSplitAll: handleRazorSplitAll,
|
|
236
|
+
onFreezeFrame: handleFreezeFrame,
|
|
237
|
+
onLinkEdit: handleLinkEdit,
|
|
238
|
+
onDeleteElementOnly: handleTimelineElementDeleteOnly,
|
|
230
239
|
onDeleteAllKeyframes: (element, animationId) => {
|
|
231
240
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
232
241
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
@@ -423,6 +432,9 @@ export function useTimelineEditCallbacks({
|
|
|
423
432
|
handleTimelineElementSplit,
|
|
424
433
|
handleRazorSplit,
|
|
425
434
|
handleRazorSplitAll,
|
|
435
|
+
handleFreezeFrame,
|
|
436
|
+
handleLinkEdit,
|
|
437
|
+
handleTimelineElementDeleteOnly,
|
|
426
438
|
handleGsapRemoveAllKeyframes,
|
|
427
439
|
resolveElementAnimations,
|
|
428
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;
|