@hyperframes/studio 0.8.10 → 0.8.12
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-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +6440 -5514
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +184 -7
- package/src/player/components/TimelineFxButton.tsx +171 -23
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-C6m2nHiX.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { waitForMediaJob } from "../components/studioMediaJobs";
|
|
3
|
+
import type { BackgroundRemovalProgress } from "../components/editor/propertyPanelTypes";
|
|
4
|
+
|
|
5
|
+
interface RemoveBackgroundOptions {
|
|
6
|
+
createBackgroundPlate?: boolean;
|
|
7
|
+
quality?: "fast" | "balanced" | "best";
|
|
8
|
+
onProgress?: (progress: BackgroundRemovalProgress) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One removal in flight at a time: starting a second one aborts whichever job
|
|
13
|
+
* is still running, so a stale progress callback can't overwrite a newer
|
|
14
|
+
* result. Unmounting aborts too, or the job would keep running against a
|
|
15
|
+
* panel that is no longer there to show its progress.
|
|
16
|
+
*/
|
|
17
|
+
export function useRemoveBackground(
|
|
18
|
+
projectId: string,
|
|
19
|
+
refreshFileTree: () => Promise<void>,
|
|
20
|
+
showToast: (message: string, kind?: "info" | "error") => void,
|
|
21
|
+
) {
|
|
22
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(
|
|
25
|
+
() => () => {
|
|
26
|
+
abortRef.current?.abort();
|
|
27
|
+
},
|
|
28
|
+
[],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return useCallback(
|
|
32
|
+
// fallow-ignore-next-line complexity
|
|
33
|
+
async (inputPath: string, options: RemoveBackgroundOptions) => {
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
|
|
36
|
+
{
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
inputPath,
|
|
41
|
+
createBackgroundPlate: options.createBackgroundPlate === true,
|
|
42
|
+
quality: options.quality ?? "balanced",
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
const data = (await response.json().catch(() => ({}))) as {
|
|
47
|
+
jobId?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
if (!response.ok || !data.jobId) {
|
|
51
|
+
throw new Error(data.error || `Background removal failed (${response.status})`);
|
|
52
|
+
}
|
|
53
|
+
showToast("Removing background...", "info");
|
|
54
|
+
abortRef.current?.abort();
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
abortRef.current = controller;
|
|
57
|
+
try {
|
|
58
|
+
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
|
|
59
|
+
await refreshFileTree();
|
|
60
|
+
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
|
|
61
|
+
return result;
|
|
62
|
+
} finally {
|
|
63
|
+
if (abortRef.current === controller) {
|
|
64
|
+
abortRef.current = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
[projectId, refreshFileTree, showToast],
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -20,25 +20,21 @@ import {
|
|
|
20
20
|
syncPreviewContentDuration,
|
|
21
21
|
} from "./timelineTimingSync";
|
|
22
22
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
23
|
-
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
24
23
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
25
|
-
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
26
24
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
25
|
+
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
26
|
+
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
27
27
|
import {
|
|
28
|
-
useAudioGroupCarveAssignment,
|
|
29
28
|
useTimelineElementVisibilityEditing,
|
|
30
29
|
useTimelineTrackVisibilityEditing,
|
|
31
30
|
} from "./timelineTrackVisibility";
|
|
32
31
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
32
|
+
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
33
33
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
34
34
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
35
|
-
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
35
|
+
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
36
36
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
37
37
|
|
|
38
|
-
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
|
39
|
-
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
38
|
export function useTimelineEditing({
|
|
43
39
|
projectId,
|
|
44
40
|
activeCompPath,
|
|
@@ -61,9 +57,7 @@ export function useTimelineEditing({
|
|
|
61
57
|
}: UseTimelineEditingOptions) {
|
|
62
58
|
const projectIdRef = useRef(projectId);
|
|
63
59
|
projectIdRef.current = projectId;
|
|
64
|
-
|
|
65
60
|
const editQueueRef = useRef(Promise.resolve());
|
|
66
|
-
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
67
61
|
|
|
68
62
|
const enqueueEdit = useCallback(
|
|
69
63
|
(
|
|
@@ -451,15 +445,7 @@ export function useTimelineEditing({
|
|
|
451
445
|
observeProjectFileVersion,
|
|
452
446
|
});
|
|
453
447
|
|
|
454
|
-
const handleBlockedTimelineEdit =
|
|
455
|
-
(_element: TimelineElement) => {
|
|
456
|
-
const now = Date.now();
|
|
457
|
-
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
458
|
-
lastBlockedTimelineToastAtRef.current = now;
|
|
459
|
-
showToast("This clip can't be moved or resized from the timeline yet.", "info");
|
|
460
|
-
},
|
|
461
|
-
[showToast],
|
|
462
|
-
);
|
|
448
|
+
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
|
463
449
|
|
|
464
450
|
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
|
465
451
|
projectId,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MutableRefObject, RefObject } from "react";
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
|
+
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
4
5
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
6
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
6
7
|
|
|
@@ -55,3 +56,9 @@ export type TimelineFileDropHandler = (
|
|
|
55
56
|
files: File[],
|
|
56
57
|
placement?: { start: number; track: number },
|
|
57
58
|
) => Promise<void>;
|
|
59
|
+
|
|
60
|
+
/** What a timeline move commits: the new start and track, plus the z-index
|
|
61
|
+
* reorder a vertical drag resolves to (absent for a pure horizontal move). */
|
|
62
|
+
export type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
|
63
|
+
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
64
|
+
};
|
|
@@ -41,6 +41,10 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
|
41
41
|
return createPortal(
|
|
42
42
|
<div
|
|
43
43
|
ref={menuRef}
|
|
44
|
+
// z-[200] for the same reason the timeline's FX popover uses it: this is
|
|
45
|
+
// portaled to `document.body`, but the ruler's sticky header sits at z-70
|
|
46
|
+
// in the SAME root stacking context, so a z-50 menu opened near the top of
|
|
47
|
+
// the timeline is painted through by the ruler and the playhead.
|
|
44
48
|
className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
|
|
45
49
|
style={{ left: adjustedX, top: adjustedY }}
|
|
46
50
|
>
|
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
import { TRACK_H } from "./timelineLayout";
|
|
2
2
|
import { TrackClipCount } from "./TrackClipCount";
|
|
3
3
|
|
|
4
|
-
// Layer row (
|
|
4
|
+
// Layer row (diamond, name, then the ∿ disclosure on the right edge) — the
|
|
5
|
+
// disclosure lives
|
|
5
6
|
// here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
|
|
6
7
|
// caret) because a group's own row keeps the caret for its structural
|
|
7
8
|
// disclosure (member rows) — this button only ever means "show this row's
|
|
8
9
|
// lanes", so it needs its own distinct glyph.
|
|
10
|
+
/**
|
|
11
|
+
* The `∿` that shows or hides a row's lanes.
|
|
12
|
+
*
|
|
13
|
+
* Shared, because two layouts need the identical control: the keyframe layer
|
|
14
|
+
* row below, and the plain track header — an audio track with automation keeps
|
|
15
|
+
* its own look (music glyph, indent) and gains this, rather than being
|
|
16
|
+
* re-rendered as a keyframe layer to get at the button.
|
|
17
|
+
*/
|
|
18
|
+
export function LaneToggleButton({
|
|
19
|
+
name,
|
|
20
|
+
isExpanded,
|
|
21
|
+
lanesId,
|
|
22
|
+
onToggle,
|
|
23
|
+
}: {
|
|
24
|
+
name: string;
|
|
25
|
+
isExpanded: boolean;
|
|
26
|
+
lanesId: string;
|
|
27
|
+
onToggle: () => void;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
|
|
33
|
+
tabIndex={-1}
|
|
34
|
+
aria-expanded={isExpanded}
|
|
35
|
+
aria-controls={lanesId}
|
|
36
|
+
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
|
|
37
|
+
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
|
38
|
+
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
|
39
|
+
// only the hit box grows.
|
|
40
|
+
className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
41
|
+
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
42
|
+
}`}
|
|
43
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
44
|
+
onClick={(event) => {
|
|
45
|
+
event.stopPropagation();
|
|
46
|
+
onToggle();
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<span aria-hidden="true">∿</span>
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
9
54
|
export function LayerDisclosureRow({
|
|
10
55
|
name,
|
|
11
56
|
clipCount,
|
|
@@ -45,37 +90,26 @@ export function LayerDisclosureRow({
|
|
|
45
90
|
background: gutterBackground,
|
|
46
91
|
}}
|
|
47
92
|
>
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
|
|
51
|
-
tabIndex={-1}
|
|
52
|
-
aria-expanded={isExpanded}
|
|
53
|
-
aria-controls={lanesId}
|
|
54
|
-
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
|
|
55
|
-
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
|
56
|
-
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
|
57
|
-
// only the hit box grows.
|
|
58
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
59
|
-
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
60
|
-
}`}
|
|
61
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
62
|
-
onClick={(event) => {
|
|
63
|
-
event.stopPropagation();
|
|
64
|
-
onToggleClipExpanded();
|
|
65
|
-
}}
|
|
66
|
-
>
|
|
67
|
-
<span aria-hidden="true">∿</span>
|
|
68
|
-
</button>
|
|
69
93
|
{/* Decorative: the disclosure button above already names the row's keyframe
|
|
70
94
|
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
|
71
95
|
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
|
72
96
|
◇
|
|
73
97
|
</span>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</span>
|
|
98
|
+
{/* Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
|
+
read, which a scanned column cannot rely on. */}
|
|
100
|
+
<span className="min-w-0 flex-1 break-words font-medium leading-tight">{name}</span>
|
|
77
101
|
<TrackClipCount clipCount={clipCount} />
|
|
78
102
|
{children}
|
|
103
|
+
{/* Anchored right, on every header that has one: the lane toggle is the
|
|
104
|
+
row's last word about itself, and a left-hand ∿ put it where the eye
|
|
105
|
+
looks for identity instead. `ml-auto` rather than a spacer so it holds
|
|
106
|
+
the edge whatever else the row grows. */}
|
|
107
|
+
<LaneToggleButton
|
|
108
|
+
name={name}
|
|
109
|
+
isExpanded={isExpanded}
|
|
110
|
+
lanesId={lanesId}
|
|
111
|
+
onToggle={onToggleClipExpanded}
|
|
112
|
+
/>
|
|
79
113
|
</div>
|
|
80
114
|
);
|
|
81
115
|
}
|
|
@@ -6,7 +6,6 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
|
6
6
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
7
7
|
import { Tooltip } from "../../components/ui";
|
|
8
8
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
9
|
-
import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
|
|
10
9
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
11
10
|
import { SpeedMenu } from "./SpeedMenu";
|
|
12
11
|
import { VolumeControl } from "./VolumeControl";
|
|
@@ -156,47 +155,12 @@ const FullscreenButton = memo(function FullscreenButton({
|
|
|
156
155
|
|
|
157
156
|
/* ── Main component ──────────────────────────────────────────────── */
|
|
158
157
|
|
|
159
|
-
/**
|
|
160
|
-
* "Hearing only this" notice. Solo is a PREVIEW-only gate — it never touches an
|
|
161
|
-
* attribute and never reaches the export — so the state has to say so out loud,
|
|
162
|
-
* or a soloed session reads as a broken mix.
|
|
163
|
-
*/
|
|
164
|
-
const SoloBanner = memo(function SoloBanner({
|
|
165
|
-
previewIframeRef,
|
|
166
|
-
}: {
|
|
167
|
-
previewIframeRef: { current: HTMLIFrameElement | null };
|
|
168
|
-
}) {
|
|
169
|
-
const bannerText = useSoloBannerText(previewIframeRef);
|
|
170
|
-
const clearSolo = usePlayerStore.getState().clearSolo;
|
|
171
|
-
if (bannerText === null) return null;
|
|
172
|
-
return (
|
|
173
|
-
<div
|
|
174
|
-
role="status"
|
|
175
|
-
className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
|
|
176
|
-
>
|
|
177
|
-
<span>
|
|
178
|
-
Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
|
|
179
|
-
export is not affected
|
|
180
|
-
</span>
|
|
181
|
-
<button
|
|
182
|
-
type="button"
|
|
183
|
-
onClick={() => clearSolo()}
|
|
184
|
-
className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
|
|
185
|
-
>
|
|
186
|
-
Clear
|
|
187
|
-
</button>
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
158
|
interface PlayerControlsProps {
|
|
193
159
|
onTogglePlay: () => void;
|
|
194
160
|
onSeek: (time: number) => void;
|
|
195
161
|
disabled?: boolean;
|
|
196
162
|
isFullscreen?: boolean;
|
|
197
163
|
onToggleFullscreen?: () => void;
|
|
198
|
-
/** Needed to read the soloed clips' labels out of the preview document. */
|
|
199
|
-
previewIframeRef?: { current: HTMLIFrameElement | null };
|
|
200
164
|
}
|
|
201
165
|
|
|
202
166
|
export const PlayerControls = memo(function PlayerControls({
|
|
@@ -205,7 +169,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
205
169
|
disabled = false,
|
|
206
170
|
isFullscreen = false,
|
|
207
171
|
onToggleFullscreen,
|
|
208
|
-
previewIframeRef,
|
|
209
172
|
}: PlayerControlsProps) {
|
|
210
173
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
211
174
|
const duration = usePlayerStore((s) => s.duration);
|
|
@@ -258,7 +221,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
258
221
|
|
|
259
222
|
return (
|
|
260
223
|
<div>
|
|
261
|
-
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
|
|
262
224
|
<div
|
|
263
225
|
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
|
264
226
|
aria-disabled={disabled || undefined}
|
|
@@ -299,6 +299,42 @@ describe("Timeline provider boundary", () => {
|
|
|
299
299
|
act(() => root.unmount());
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
// The same assertion, with a group present. It passed on an empty fixture
|
|
303
|
+
// while TimelineGroupRow called the THROWING context hook — one grouped clip
|
|
304
|
+
// and the whole timeline render died, not just the row.
|
|
305
|
+
it("renders without the provider even when a group row is on screen", () => {
|
|
306
|
+
const host = createSizedTimelineHost(640);
|
|
307
|
+
usePlayerStore.setState({
|
|
308
|
+
duration: 4,
|
|
309
|
+
timelineReady: true,
|
|
310
|
+
elements: [
|
|
311
|
+
{
|
|
312
|
+
id: "voice-1",
|
|
313
|
+
domId: "voice-1",
|
|
314
|
+
tag: "audio",
|
|
315
|
+
start: 0,
|
|
316
|
+
duration: 2,
|
|
317
|
+
track: 0,
|
|
318
|
+
audioGroup: "voiceover",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: "voice-2",
|
|
322
|
+
domId: "voice-2",
|
|
323
|
+
tag: "audio",
|
|
324
|
+
start: 2,
|
|
325
|
+
duration: 2,
|
|
326
|
+
track: 1,
|
|
327
|
+
audioGroup: "voiceover",
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
const root = createRoot(host);
|
|
332
|
+
act(() => root.render(React.createElement(Timeline)));
|
|
333
|
+
|
|
334
|
+
expect(host.querySelector('[role="treegrid"]')).not.toBeNull();
|
|
335
|
+
act(() => root.unmount());
|
|
336
|
+
});
|
|
337
|
+
|
|
302
338
|
it("renders the complete track list while row virtualization is gated off", () => {
|
|
303
339
|
const host = createSizedTimelineHost(640);
|
|
304
340
|
usePlayerStore.setState({
|
|
@@ -389,7 +425,10 @@ describe("Timeline provider boundary", () => {
|
|
|
389
425
|
button.click();
|
|
390
426
|
});
|
|
391
427
|
|
|
392
|
-
|
|
428
|
+
// Up from the rowheader rather than counting parents: the header now lays
|
|
429
|
+
// its name and its controls out on two lines, so the button sits one level
|
|
430
|
+
// deeper than it used to.
|
|
431
|
+
const row = button.closest('[role="rowheader"]')?.parentElement;
|
|
393
432
|
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
|
394
433
|
const trackContent = row?.children.item(1);
|
|
395
434
|
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
|
|
@@ -33,7 +33,7 @@ import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemet
|
|
|
33
33
|
import {
|
|
34
34
|
getEffectiveTimelineDuration,
|
|
35
35
|
getTimelinePreviewElement,
|
|
36
|
-
|
|
36
|
+
timelineNeedsLabelColumn,
|
|
37
37
|
} from "./timelineViewModel";
|
|
38
38
|
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
|
|
39
39
|
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
@@ -115,7 +115,10 @@ export const Timeline = memo(function Timeline({
|
|
|
115
115
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
116
116
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
117
117
|
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
118
|
-
const labelMode = useMemo(
|
|
118
|
+
const labelMode = useMemo(
|
|
119
|
+
() => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
|
|
120
|
+
[gsapAnimations, expandedElements],
|
|
121
|
+
);
|
|
119
122
|
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
120
123
|
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
121
124
|
const contentGutter = labelMode ? GUTTER : 0;
|
|
@@ -1747,3 +1747,70 @@ describe("TimelineAutomationLane stretch", () => {
|
|
|
1747
1747
|
expect(svg.style.cursor).not.toBe("col-resize");
|
|
1748
1748
|
});
|
|
1749
1749
|
});
|
|
1750
|
+
|
|
1751
|
+
describe("TimelineAutomationLane — a read-only lane offers nothing to grab", () => {
|
|
1752
|
+
/** Hover the lane the way a pointer entering it does. */
|
|
1753
|
+
function hover(container: HTMLElement): SVGSVGElement {
|
|
1754
|
+
const svg = container.querySelector("svg")!;
|
|
1755
|
+
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
|
1756
|
+
// React implements onPointerEnter through the delegated `pointerover`
|
|
1757
|
+
// event, not a native `pointerenter` — which does not bubble and so never
|
|
1758
|
+
// reaches its listener.
|
|
1759
|
+
fire(svg, "pointerover");
|
|
1760
|
+
return svg as SVGSVGElement;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const handles = (container: HTMLElement) =>
|
|
1764
|
+
[...container.querySelectorAll("circle[data-automation-point]")] as SVGCircleElement[];
|
|
1765
|
+
|
|
1766
|
+
// A grab handle raised on hover is an offer, and a carve lane cannot honour
|
|
1767
|
+
// it: the analysis rewrites these envelopes on every run, so a point moved
|
|
1768
|
+
// here is discarded rather than saved. Dimming alone did not say that — the
|
|
1769
|
+
// handles still came up under the cursor and the drag silently did nothing.
|
|
1770
|
+
it("keeps its point handles hidden on hover", () => {
|
|
1771
|
+
const { container } = render(
|
|
1772
|
+
<TimelineAutomationLane
|
|
1773
|
+
{...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
|
|
1774
|
+
/>,
|
|
1775
|
+
);
|
|
1776
|
+
hover(container);
|
|
1777
|
+
const drawn = handles(container);
|
|
1778
|
+
expect(drawn.length).toBeGreaterThan(0);
|
|
1779
|
+
for (const c of drawn) {
|
|
1780
|
+
expect(c.style.opacity).toBe("0");
|
|
1781
|
+
expect(c.style.cursor).toBe("default");
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
it("still raises them on an editable lane, so the gate is readOnly and not hover", () => {
|
|
1786
|
+
const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
|
|
1787
|
+
hover(container);
|
|
1788
|
+
const drawn = handles(container);
|
|
1789
|
+
expect(drawn.length).toBeGreaterThan(0);
|
|
1790
|
+
for (const c of drawn) {
|
|
1791
|
+
expect(c.style.opacity).toBe("1");
|
|
1792
|
+
expect(c.style.cursor).toBe("grab");
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
it("says why, in the lane, once hovered", () => {
|
|
1797
|
+
const { container } = render(
|
|
1798
|
+
<TimelineAutomationLane
|
|
1799
|
+
{...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
|
|
1800
|
+
/>,
|
|
1801
|
+
);
|
|
1802
|
+
expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
|
|
1803
|
+
hover(container);
|
|
1804
|
+
expect(container.querySelector("[data-automation-readonly-note]")?.textContent).toContain(
|
|
1805
|
+
"Owned by the carve.",
|
|
1806
|
+
);
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
it("says nothing when no reason was given", () => {
|
|
1810
|
+
const { container } = render(
|
|
1811
|
+
<TimelineAutomationLane {...laneProps({ automation: ramp, readOnly: true })} />,
|
|
1812
|
+
);
|
|
1813
|
+
hover(container);
|
|
1814
|
+
expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
@@ -86,6 +86,75 @@ function pointCircleStyle(
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** Pointer shape: a read-only lane can only be selected, a live one edited. */
|
|
89
|
+
/**
|
|
90
|
+
* Whether a point's grab handle is up.
|
|
91
|
+
*
|
|
92
|
+
* A raised handle is an offer to drag, so a read-only lane keeps them down: the
|
|
93
|
+
* carve rewrites its envelopes from the analysis on every run, and a point
|
|
94
|
+
* moved here is discarded rather than saved. Hidden rather than unmounted
|
|
95
|
+
* either way, so the hit area survives — a point dragged past the lane's edge
|
|
96
|
+
* fires pointerleave mid-gesture, and a handle that vanished then would drop
|
|
97
|
+
* the drag. A live drag and a selected range both keep them up regardless: the
|
|
98
|
+
* range is the subject of a pending Delete, and which points it caught cannot
|
|
99
|
+
* depend on where the mouse is.
|
|
100
|
+
*/
|
|
101
|
+
/** The svg's tooltip: what this lane's gestures actually are. */
|
|
102
|
+
function laneTitle(readOnly: boolean | undefined): string {
|
|
103
|
+
return readOnly
|
|
104
|
+
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
|
|
105
|
+
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Why this lane will not take an edit, in the lane itself.
|
|
110
|
+
*
|
|
111
|
+
* Dimming and a default cursor say "not editable" but not WHY, and the why is
|
|
112
|
+
* the part that matters: the carve rewrites these envelopes from its own
|
|
113
|
+
* analysis on every run, so a point moved here is discarded rather than saved.
|
|
114
|
+
* Only while hovered, so a stack of read-only lanes is not a stack of notices,
|
|
115
|
+
* and never over a selection the author is about to act on.
|
|
116
|
+
*
|
|
117
|
+
* Separate from the gesture `hint` slot, which belongs to handlers that do not
|
|
118
|
+
* run on a read-only lane at all.
|
|
119
|
+
*/
|
|
120
|
+
function ReadOnlyNote({
|
|
121
|
+
readOnly,
|
|
122
|
+
note,
|
|
123
|
+
hovered,
|
|
124
|
+
hasRange,
|
|
125
|
+
leftPx,
|
|
126
|
+
widthPx,
|
|
127
|
+
}: {
|
|
128
|
+
readOnly: boolean | undefined;
|
|
129
|
+
note: string | undefined;
|
|
130
|
+
hovered: boolean;
|
|
131
|
+
/** A selection is the subject of a pending action; do not cover it. */
|
|
132
|
+
hasRange: boolean;
|
|
133
|
+
leftPx: number;
|
|
134
|
+
widthPx: number;
|
|
135
|
+
}) {
|
|
136
|
+
if (!readOnly || !note || !hovered || hasRange) return null;
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
data-automation-readonly-note=""
|
|
140
|
+
className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-black/85 px-1.5 py-0.5 text-[9px] text-white/80"
|
|
141
|
+
style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
|
|
142
|
+
>
|
|
143
|
+
{note}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function pointHandleOpacity(args: {
|
|
149
|
+
readOnly: boolean | undefined;
|
|
150
|
+
hovered: boolean;
|
|
151
|
+
dragging: boolean;
|
|
152
|
+
hasRange: boolean;
|
|
153
|
+
}): number {
|
|
154
|
+
if (args.dragging || args.hasRange) return 1;
|
|
155
|
+
return !args.readOnly && args.hovered ? 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
89
158
|
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
|
|
90
159
|
// A stretch handle wins over everything it might also sit above: the handle is
|
|
91
160
|
// a few px wide and always overlaps whatever is under the selection edge, so
|
|
@@ -120,6 +189,9 @@ export interface TimelineAutomationLaneProps {
|
|
|
120
189
|
snapTimes?: readonly number[];
|
|
121
190
|
/** Editing writes to the selected element, so an unselected clip is read-only. */
|
|
122
191
|
readOnly?: boolean;
|
|
192
|
+
/** Why `readOnly`, shown in the lane on hover. Without it the lane only looks
|
|
193
|
+
* disabled; the author still has to guess what would let them edit it. */
|
|
194
|
+
readOnlyNote?: string;
|
|
123
195
|
/** Called when a read-only lane is pressed: selects the clip so it goes live. */
|
|
124
196
|
onSelect?(): void;
|
|
125
197
|
/** Active selection box on THIS lane, or null. */
|
|
@@ -142,6 +214,7 @@ export function TimelineAutomationLane({
|
|
|
142
214
|
onCommit,
|
|
143
215
|
snapTimes,
|
|
144
216
|
readOnly,
|
|
217
|
+
readOnlyNote,
|
|
145
218
|
onSelect,
|
|
146
219
|
rangeSelection,
|
|
147
220
|
onRangeSelect,
|
|
@@ -372,11 +445,7 @@ export function TimelineAutomationLane({
|
|
|
372
445
|
role="group"
|
|
373
446
|
aria-label={`${range.label} automation`}
|
|
374
447
|
>
|
|
375
|
-
<title>
|
|
376
|
-
{readOnly
|
|
377
|
-
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
|
|
378
|
-
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid."}
|
|
379
|
-
</title>
|
|
448
|
+
<title>{laneTitle(readOnly)}</title>
|
|
380
449
|
{/* No plate behind the envelope: the lane used to darken its clip's width,
|
|
381
450
|
which drew a box inside the row and made a stack of lanes read as tiles
|
|
382
451
|
rather than as rows of one timeline. The row background shows through, and
|
|
@@ -433,15 +502,14 @@ export function TimelineAutomationLane({
|
|
|
433
502
|
fill={accentColor}
|
|
434
503
|
stroke={stroke}
|
|
435
504
|
strokeWidth={strokeWidth}
|
|
436
|
-
// Hidden rather than unmounted, so the hit area survives: a point
|
|
437
|
-
// dragged past the lane's edge fires pointerleave mid-gesture, and a
|
|
438
|
-
// handle that vanishes then would drop the drag. A drag in progress
|
|
439
|
-
// and a selected range both keep them up for the same reason — the
|
|
440
|
-
// range is the subject of a pending Delete, and which points it
|
|
441
|
-
// caught cannot depend on where the mouse is.
|
|
442
505
|
style={{
|
|
443
506
|
cursor: readOnly ? "default" : "grab",
|
|
444
|
-
opacity:
|
|
507
|
+
opacity: pointHandleOpacity({
|
|
508
|
+
readOnly,
|
|
509
|
+
hovered,
|
|
510
|
+
dragging: dragIndex !== null,
|
|
511
|
+
hasRange: Boolean(rangeSelection),
|
|
512
|
+
}),
|
|
445
513
|
}}
|
|
446
514
|
onContextMenu={(e) => {
|
|
447
515
|
e.preventDefault();
|
|
@@ -484,6 +552,15 @@ export function TimelineAutomationLane({
|
|
|
484
552
|
</div>
|
|
485
553
|
) : null}
|
|
486
554
|
|
|
555
|
+
<ReadOnlyNote
|
|
556
|
+
readOnly={readOnly}
|
|
557
|
+
note={readOnlyNote}
|
|
558
|
+
hovered={hovered}
|
|
559
|
+
hasRange={Boolean(rangeSelection)}
|
|
560
|
+
leftPx={leftPx}
|
|
561
|
+
widthPx={widthPx}
|
|
562
|
+
/>
|
|
563
|
+
|
|
487
564
|
{menuAt && rangeSelection ? (
|
|
488
565
|
<AutomationSelectionMenu
|
|
489
566
|
x={menuAt.x}
|