@hyperframes/studio 0.8.114 → 0.8.115
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-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
- package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
- package/dist/chunk-TXZMMOMA.js.map +1 -0
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
- package/dist/index.d.ts +43 -19
- package/dist/index.html +2 -2
- package/dist/index.js +2939 -2596
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -2
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.ts +4 -3
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +39 -15
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.ts +2 -20
- package/src/hooks/gsapDragPositionCommit.ts +18 -3
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapResizeIntercept.test.ts +33 -0
- package/src/hooks/gsapResizeIntercept.ts +1 -0
- package/src/hooks/gsapScriptCommitTypes.ts +5 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.ts +6 -5
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
- package/src/hooks/useDomEditTextCommits.ts +5 -1
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +7 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEnableKeyframes.test.ts +2 -2
- package/src/hooks/useEnableKeyframes.ts +5 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
- package/src/hooks/useGsapAwareEditing.ts +96 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +21 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +49 -4
- package/src/webmcp/StudioAgentTools.tsx +2 -2
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
|
@@ -28,6 +28,17 @@ describe("applyRippleShifts", () => {
|
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
function stubRemoveElementsFetch(current: string, afterRemoval: string) {
|
|
32
|
+
vi.stubGlobal(
|
|
33
|
+
"fetch",
|
|
34
|
+
vi.fn(async (input: RequestInfo | URL) => {
|
|
35
|
+
const isRemoval = String(input).includes("/file-mutations/remove-elements/");
|
|
36
|
+
const content = isRemoval ? afterRemoval : current;
|
|
37
|
+
return new Response(JSON.stringify({ changed: isRemoval, content }), { status: 200 });
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
32
43
|
const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
33
44
|
<div data-hf-id="hf-stage" data-hf-root data-duration="6">
|
|
@@ -38,16 +49,7 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
|
38
49
|
</body></html>`;
|
|
39
50
|
|
|
40
51
|
beforeEach(() => {
|
|
41
|
-
|
|
42
|
-
"fetch",
|
|
43
|
-
vi.fn(async (input: RequestInfo | URL) => {
|
|
44
|
-
const url = String(input);
|
|
45
|
-
if (url.includes("/file-mutations/remove-element/")) {
|
|
46
|
-
return new Response(JSON.stringify({ changed: true, content: html }), { status: 200 });
|
|
47
|
-
}
|
|
48
|
-
return new Response(JSON.stringify({ content: html }), { status: 200 });
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
52
|
+
stubRemoveElementsFetch(html, html);
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
afterEach(() => {
|
|
@@ -118,6 +120,23 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
|
118
120
|
);
|
|
119
121
|
});
|
|
120
122
|
|
|
123
|
+
it("removes a multi-clip selection with one request, however many clips it holds", async () => {
|
|
124
|
+
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
|
125
|
+
const { elements, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
|
|
126
|
+
|
|
127
|
+
await act(async () => {
|
|
128
|
+
await getHook().handleTimelineElementsDelete(elements);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const removals = vi
|
|
132
|
+
.mocked(fetch)
|
|
133
|
+
.mock.calls.filter(([url]) => String(url).includes("/file-mutations/remove-element"));
|
|
134
|
+
expect(removals).toHaveLength(1);
|
|
135
|
+
const [, init] = removals[0] as [unknown, RequestInit];
|
|
136
|
+
const body = JSON.parse(String(init.body));
|
|
137
|
+
expect(body.targets).toHaveLength(elements.length);
|
|
138
|
+
});
|
|
139
|
+
|
|
121
140
|
it("shows exactly one toast when a ripple fails to persist after a committed delete", async () => {
|
|
122
141
|
const handleTimelineGroupMove = vi.fn().mockRejectedValue(new Error("persist failed"));
|
|
123
142
|
const showToast = vi.fn();
|
|
@@ -162,18 +181,7 @@ describe("useTimelineDeleteOps: undo race", () => {
|
|
|
162
181
|
</body></html>`;
|
|
163
182
|
|
|
164
183
|
beforeEach(() => {
|
|
165
|
-
|
|
166
|
-
"fetch",
|
|
167
|
-
vi.fn(async (input: RequestInfo | URL) => {
|
|
168
|
-
const url = String(input);
|
|
169
|
-
if (url.includes("/file-mutations/remove-element/")) {
|
|
170
|
-
return new Response(JSON.stringify({ changed: true, content: htmlAfterDeletingC }), {
|
|
171
|
-
status: 200,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
return new Response(JSON.stringify({ content: html }), { status: 200 });
|
|
175
|
-
}),
|
|
176
|
-
);
|
|
184
|
+
stubRemoveElementsFetch(html, htmlAfterDeletingC);
|
|
177
185
|
});
|
|
178
186
|
|
|
179
187
|
afterEach(() => {
|
|
@@ -121,38 +121,32 @@ export function useTimelineDeleteOps({
|
|
|
121
121
|
writeFile: writeProjectFile,
|
|
122
122
|
recordEdit,
|
|
123
123
|
rewrite: async (originalContent) => {
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
// undo, rather than one per clip.
|
|
128
|
-
let removedContent = originalContent;
|
|
129
|
-
for (const target of sameFile) {
|
|
124
|
+
// One request removes every selected element and rewrites the file once, so a
|
|
125
|
+
// large selection is a single round trip and a single history entry.
|
|
126
|
+
const patchTargets = sameFile.map((target) => {
|
|
130
127
|
const patchTarget = buildPatchTarget(target);
|
|
131
128
|
if (!patchTarget) {
|
|
132
129
|
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
|
133
130
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
},
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const removeData = (await removeResponse.json()) as {
|
|
151
|
-
changed?: boolean;
|
|
152
|
-
content?: string;
|
|
153
|
-
};
|
|
154
|
-
if (typeof removeData.content === "string") removedContent = removeData.content;
|
|
131
|
+
return patchTarget;
|
|
132
|
+
});
|
|
133
|
+
const removeResponse = await fetch(
|
|
134
|
+
buildProjectApiPath(
|
|
135
|
+
pid,
|
|
136
|
+
`/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
|
|
137
|
+
),
|
|
138
|
+
{
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
141
|
+
body: JSON.stringify({ targets: patchTargets }),
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
if (!removeResponse.ok) {
|
|
145
|
+
throw new Error(`Failed to delete ${sameFile.length} clips from ${targetPath}`);
|
|
155
146
|
}
|
|
147
|
+
const removeData = (await removeResponse.json()) as { content?: string };
|
|
148
|
+
const removedContent =
|
|
149
|
+
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
156
150
|
// Shrink to the furthest remaining clip end, read from the post-removal source:
|
|
157
151
|
// store durations are runtime-truncated.
|
|
158
152
|
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
|
@@ -1296,7 +1296,7 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
|
|
|
1296
1296
|
if (url.includes("/api/projects/p1/files/")) {
|
|
1297
1297
|
return jsonResponse({ content: DELETE_SOURCE });
|
|
1298
1298
|
}
|
|
1299
|
-
if (url.includes("/api/projects/p1/file-mutations/remove-
|
|
1299
|
+
if (url.includes("/api/projects/p1/file-mutations/remove-elements/")) {
|
|
1300
1300
|
return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
|
|
1301
1301
|
}
|
|
1302
1302
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
@@ -8,6 +8,7 @@ import { memo } from "react";
|
|
|
8
8
|
import { createPortal } from "react-dom";
|
|
9
9
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
10
10
|
import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes";
|
|
11
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
11
12
|
|
|
12
13
|
interface AutomationSelectionMenuProps {
|
|
13
14
|
x: number;
|
|
@@ -45,7 +46,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
|
45
46
|
// portaled to `document.body`, but the ruler's sticky header sits at z-70
|
|
46
47
|
// in the SAME root stacking context, so a z-50 menu opened near the top of
|
|
47
48
|
// the timeline is painted through by the ruler and the playhead.
|
|
48
|
-
className=
|
|
49
|
+
className={`${menuClasses.panel} hf-automation-menu fixed z-200 min-w-[140px]`}
|
|
49
50
|
style={{ left: adjustedX, top: adjustedY }}
|
|
50
51
|
>
|
|
51
52
|
{AUTOMATION_SHAPES.map((shape) => (
|
|
@@ -61,7 +62,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
|
61
62
|
{shape.label}
|
|
62
63
|
</button>
|
|
63
64
|
))}
|
|
64
|
-
<div className=
|
|
65
|
+
<div className={menuClasses.divider} />
|
|
65
66
|
<button
|
|
66
67
|
type="button"
|
|
67
68
|
className={row}
|
|
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import type { TimelineElement } from "../store/playerStore";
|
|
6
6
|
import { ClipContextMenu } from "./ClipContextMenu";
|
|
7
7
|
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -117,6 +118,67 @@ describe("ClipContextMenu host items", () => {
|
|
|
117
118
|
});
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
describe("ClipContextMenu tools", () => {
|
|
122
|
+
const video = {
|
|
123
|
+
id: "talk",
|
|
124
|
+
domId: "talk",
|
|
125
|
+
tag: "video",
|
|
126
|
+
start: 0,
|
|
127
|
+
duration: 4,
|
|
128
|
+
track: 0,
|
|
129
|
+
hasAudio: true,
|
|
130
|
+
} as unknown as TimelineElement;
|
|
131
|
+
|
|
132
|
+
function menuWith(clipMenuTools: boolean | undefined) {
|
|
133
|
+
const host = document.createElement("div");
|
|
134
|
+
document.body.appendChild(host);
|
|
135
|
+
const root = createRoot(host);
|
|
136
|
+
act(() =>
|
|
137
|
+
root.render(
|
|
138
|
+
<TimelineEditProvider
|
|
139
|
+
value={{
|
|
140
|
+
onFreezeFrame: vi.fn(),
|
|
141
|
+
onSetElementAttributeQuiet: vi.fn(),
|
|
142
|
+
onLinkEdit: vi.fn(),
|
|
143
|
+
...(clipMenuTools === undefined ? {} : { clipMenuTools }),
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<ClipContextMenu
|
|
147
|
+
x={10}
|
|
148
|
+
y={10}
|
|
149
|
+
element={video}
|
|
150
|
+
currentTime={1}
|
|
151
|
+
onClose={vi.fn()}
|
|
152
|
+
onSplit={vi.fn()}
|
|
153
|
+
onDelete={vi.fn()}
|
|
154
|
+
/>
|
|
155
|
+
</TimelineEditProvider>,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
const labels = () =>
|
|
159
|
+
Array.from(document.body.querySelectorAll("[role=menuitem]")).map((item) => item.textContent);
|
|
160
|
+
return { labels, unmount: () => act(() => root.unmount()) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it("offers Freeze frame and the attribute tools by default", () => {
|
|
164
|
+
const { labels, unmount } = menuWith(undefined);
|
|
165
|
+
expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(true);
|
|
166
|
+
expect(labels().some((label) => label?.startsWith("Look"))).toBe(true);
|
|
167
|
+
expect(labels().some((label) => label?.startsWith("Voice"))).toBe(true);
|
|
168
|
+
unmount();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("hides every tool when the host turns them off, keeping Split and Delete", () => {
|
|
172
|
+
const { labels, unmount } = menuWith(false);
|
|
173
|
+
expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(false);
|
|
174
|
+
expect(labels().some((label) => label?.startsWith("Look"))).toBe(false);
|
|
175
|
+
expect(labels().some((label) => label?.startsWith("Voice"))).toBe(false);
|
|
176
|
+
expect(labels().some((label) => label?.startsWith("Split"))).toBe(true);
|
|
177
|
+
expect(labels().some((label) => label?.startsWith("Delete"))).toBe(true);
|
|
178
|
+
unmount();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
120
182
|
/** A clip button that opens the real menu, whose Ask moves the focus the way `focus` says. */
|
|
121
183
|
function FocusHost({ focus }: { focus: "existing" | "mounted" }) {
|
|
122
184
|
const [open, setOpen] = useState(false);
|
|
@@ -186,3 +248,14 @@ describe("ClipContextMenu focus on close", () => {
|
|
|
186
248
|
unmount();
|
|
187
249
|
});
|
|
188
250
|
});
|
|
251
|
+
|
|
252
|
+
describe("ClipContextMenu row fill", () => {
|
|
253
|
+
it("leaves no padding or margin between its rows and the panel edge or a divider", () => {
|
|
254
|
+
renderMenu([{ id: "ask", label: "Ask", onSelect: vi.fn() }]);
|
|
255
|
+
const menu = document.body.querySelector<HTMLElement>("[role=menu]")!;
|
|
256
|
+
const spacing = /(^| )(p|py|pt|pb|m|my|mt|mb)-\d/;
|
|
257
|
+
expect(menu.className).not.toMatch(spacing);
|
|
258
|
+
for (const group of menu.querySelectorAll<HTMLElement>("[role=group]"))
|
|
259
|
+
expect(group.className).not.toMatch(spacing);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -8,10 +8,10 @@ import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
|
8
8
|
import { ClipMenuToolItems } from "./clipMenuToolItems";
|
|
9
9
|
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
10
10
|
import { ClipMenuLinkItems } from "./clipMenuLinkItems";
|
|
11
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
12
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
11
13
|
|
|
12
14
|
const MENU_MARGIN = 8;
|
|
13
|
-
// Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
|
|
14
|
-
const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
|
|
15
15
|
|
|
16
16
|
function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
|
|
17
17
|
const [height, setHeight] = useState(0);
|
|
@@ -42,13 +42,8 @@ interface ClipContextMenuProps {
|
|
|
42
42
|
hostItems?: readonly TimelineClipMenuItem[] | undefined;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
46
45
|
const itemClass = (enabled: boolean) =>
|
|
47
|
-
|
|
48
|
-
enabled
|
|
49
|
-
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
50
|
-
: " text-neutral-600 cursor-not-allowed"
|
|
51
|
-
}`;
|
|
46
|
+
`${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
|
|
52
47
|
|
|
53
48
|
/** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
|
|
54
49
|
function HostItems({
|
|
@@ -103,6 +98,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
103
98
|
}: ClipContextMenuProps) {
|
|
104
99
|
const menuRef = useContextMenuDismiss(onClose);
|
|
105
100
|
useMenuKeyboardNav(menuRef);
|
|
101
|
+
const tools = useTimelineEditContextOptional().clipMenuTools !== false;
|
|
106
102
|
// The right-clicked clip's own id: a member of the live multi-selection
|
|
107
103
|
// means Copy/Duplicate act on the whole group, matching onContextMenuClip's
|
|
108
104
|
// selection-preserving behaviour for a right-click inside it.
|
|
@@ -134,15 +130,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
134
130
|
ref={menuRef}
|
|
135
131
|
role="menu"
|
|
136
132
|
aria-label="Clip actions"
|
|
137
|
-
className=
|
|
133
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
|
|
138
134
|
style={{ left: adjustedX, top: adjustedY }}
|
|
139
135
|
>
|
|
140
136
|
{hostItems.length > 0 && (
|
|
141
|
-
<div role="group" aria-label="Host" className={
|
|
137
|
+
<div role="group" aria-label="Host" className={menuClasses.group}>
|
|
142
138
|
<HostItems items={hostItems} onClose={onClose} />
|
|
143
139
|
</div>
|
|
144
140
|
)}
|
|
145
|
-
<div role="group" aria-label="Time" className={
|
|
141
|
+
<div role="group" aria-label="Time" className={menuClasses.group}>
|
|
146
142
|
{splitLabel && (
|
|
147
143
|
<button
|
|
148
144
|
type="button"
|
|
@@ -164,7 +160,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
164
160
|
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
165
161
|
</button>
|
|
166
162
|
)}
|
|
167
|
-
{splitLabel && (
|
|
163
|
+
{tools && splitLabel && (
|
|
168
164
|
<ClipMenuToolItems
|
|
169
165
|
group="time"
|
|
170
166
|
element={element}
|
|
@@ -174,28 +170,34 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
174
170
|
)}
|
|
175
171
|
</div>
|
|
176
172
|
|
|
177
|
-
<div role="group" aria-label="Sound" className={
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
173
|
+
<div role="group" aria-label="Sound" className={menuClasses.group}>
|
|
174
|
+
{tools && (
|
|
175
|
+
<>
|
|
176
|
+
<ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
|
|
177
|
+
<ClipMenuToolItems
|
|
178
|
+
group="sound"
|
|
179
|
+
element={element}
|
|
180
|
+
currentTime={currentTime}
|
|
181
|
+
onClose={onClose}
|
|
182
|
+
/>
|
|
183
|
+
<ClipMenuLinkItems part="link" element={element} onClose={onClose} />
|
|
184
|
+
<ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
187
|
</div>
|
|
188
188
|
|
|
189
|
-
<div role="group" aria-label="Picture" className={
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
189
|
+
<div role="group" aria-label="Picture" className={menuClasses.group}>
|
|
190
|
+
{tools && (
|
|
191
|
+
<ClipMenuToolItems
|
|
192
|
+
group="picture"
|
|
193
|
+
element={element}
|
|
194
|
+
currentTime={currentTime}
|
|
195
|
+
onClose={onClose}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
196
198
|
</div>
|
|
197
199
|
|
|
198
|
-
<div role="group" aria-label="Clipboard" className={
|
|
200
|
+
<div role="group" aria-label="Clipboard" className={menuClasses.group}>
|
|
199
201
|
{onCopy && (
|
|
200
202
|
<button
|
|
201
203
|
type="button"
|
|
@@ -255,7 +257,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
255
257
|
<span>Delete</span>
|
|
256
258
|
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
257
259
|
</button>
|
|
258
|
-
<ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
|
|
260
|
+
{tools && <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />}
|
|
259
261
|
</div>
|
|
260
262
|
</div>,
|
|
261
263
|
document.body,
|
|
@@ -4,6 +4,7 @@ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
|
4
4
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
5
5
|
import type { TimelineElement } from "../store/playerStore";
|
|
6
6
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
7
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
7
8
|
|
|
8
9
|
export interface KeyframeDiamondContextMenuState {
|
|
9
10
|
x: number;
|
|
@@ -39,10 +40,8 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
39
40
|
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
const ITEM_CLS =
|
|
43
|
-
|
|
44
|
-
const DESTRUCTIVE_ITEM_CLS =
|
|
45
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
|
|
43
|
+
const ITEM_CLS = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`;
|
|
44
|
+
const DESTRUCTIVE_ITEM_CLS = `${menuClasses.row} text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer flex items-center gap-2`;
|
|
46
45
|
|
|
47
46
|
export function KeyframeDiamondContextMenu({
|
|
48
47
|
state,
|
|
@@ -96,7 +95,7 @@ export function KeyframeDiamondContextMenu({
|
|
|
96
95
|
ref={menuRef}
|
|
97
96
|
role="menu"
|
|
98
97
|
aria-label="Keyframe actions"
|
|
99
|
-
className=
|
|
98
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[180px] overflow-y-auto`}
|
|
100
99
|
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
|
|
101
100
|
>
|
|
102
101
|
{onMoveToPlayhead && (
|
|
@@ -165,14 +164,14 @@ export function KeyframeDiamondContextMenu({
|
|
|
165
164
|
|
|
166
165
|
{/* Deleting every keyframe sat adjacent to the single delete and styled
|
|
167
166
|
identically. Separate and mark it so the two cannot be misread. */}
|
|
168
|
-
<div className=
|
|
169
|
-
|
|
170
|
-
<div className=
|
|
167
|
+
<div className={menuClasses.divider} role="separator" />
|
|
168
|
+
<div className="h-1" aria-hidden="true" />
|
|
169
|
+
<div className={menuClasses.divider} role="separator" />
|
|
171
170
|
|
|
172
171
|
<button
|
|
173
172
|
type="button"
|
|
174
173
|
role="menuitem"
|
|
175
|
-
className=
|
|
174
|
+
className={`${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2`}
|
|
176
175
|
onClick={() => {
|
|
177
176
|
onDeleteAll(state.element, state.animationId);
|
|
178
177
|
onClose();
|
|
@@ -7,11 +7,11 @@ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
|
7
7
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
8
|
import { clipSyncState, type ClipSyncState } from "./clipSync";
|
|
9
9
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
10
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
10
11
|
|
|
11
12
|
const stop = (e: SyntheticEvent) => e.stopPropagation();
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
-
"block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
|
|
14
|
+
const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} block disabled:cursor-not-allowed disabled:opacity-40`;
|
|
15
15
|
|
|
16
16
|
function SyncMenu({
|
|
17
17
|
x,
|
|
@@ -39,13 +39,13 @@ function SyncMenu({
|
|
|
39
39
|
ref={menuRef}
|
|
40
40
|
role="menu"
|
|
41
41
|
aria-label="Out of sync"
|
|
42
|
-
className=
|
|
42
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[170px]`}
|
|
43
43
|
style={{ left, top }}
|
|
44
44
|
>
|
|
45
45
|
<button
|
|
46
46
|
type="button"
|
|
47
47
|
role="menuitem"
|
|
48
|
-
className={
|
|
48
|
+
className={ROW}
|
|
49
49
|
disabled={!onLinkEdit || moveStart === null}
|
|
50
50
|
title={moveStart === null ? "Would move the clip before 0:00" : undefined}
|
|
51
51
|
onClick={() => {
|
|
@@ -59,7 +59,7 @@ function SyncMenu({
|
|
|
59
59
|
<button
|
|
60
60
|
type="button"
|
|
61
61
|
role="menuitem"
|
|
62
|
-
className={
|
|
62
|
+
className={ROW}
|
|
63
63
|
disabled={!onLinkEdit || slipMediaStart === null}
|
|
64
64
|
title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
|
|
65
65
|
onClick={() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { textClipBackground } from "./TextClipContent";
|
|
3
|
+
|
|
4
|
+
describe("textClipBackground", () => {
|
|
5
|
+
it("puts dark text on a light strip and light text on a dark one", () => {
|
|
6
|
+
expect(textClipBackground({ value: "a", color: "rgb(16, 20, 24)" })).toBe(
|
|
7
|
+
"var(--timeline-text-clip-light-bg)",
|
|
8
|
+
);
|
|
9
|
+
expect(textClipBackground({ value: "a", color: "rgb(255, 255, 255)" })).toBe(
|
|
10
|
+
"var(--timeline-text-clip-dark-bg)",
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("uses the layer's own background when it paints one", () => {
|
|
15
|
+
expect(
|
|
16
|
+
textClipBackground({ value: "a", color: "rgb(16, 20, 24)", background: "rgb(230, 57, 70)" }),
|
|
17
|
+
).toBe("rgb(230, 57, 70)");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { parseCssColor } from "../../components/editor/colorValue";
|
|
2
|
+
import type { TimelineText } from "../store/timelineElement";
|
|
3
|
+
|
|
4
|
+
/** The layer's own background, or a strip the text reads on: light behind dark text, dark otherwise. */
|
|
5
|
+
export function textClipBackground(text: TimelineText): string {
|
|
6
|
+
if (text.background) return text.background;
|
|
7
|
+
const color = parseCssColor(text.color ?? "");
|
|
8
|
+
const light = color ? 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue : 255;
|
|
9
|
+
return light < 128 ? "var(--timeline-text-clip-light-bg)" : "var(--timeline-text-clip-dark-bg)";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A text layer's row: its own words in its own font and colour, live, on one clipped line. */
|
|
13
|
+
export function TextClipContent({ text }: { text: TimelineText }) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className="absolute inset-0 flex items-center overflow-hidden px-2"
|
|
17
|
+
style={{ background: textClipBackground(text) }}
|
|
18
|
+
>
|
|
19
|
+
<span
|
|
20
|
+
className="whitespace-nowrap text-[15px] leading-none"
|
|
21
|
+
style={{ fontFamily: text.fontFamily, fontWeight: text.fontWeight, color: text.color }}
|
|
22
|
+
>
|
|
23
|
+
{text.value}
|
|
24
|
+
</span>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
4
5
|
|
|
5
6
|
interface TrackGapContextMenuProps {
|
|
6
7
|
x: number;
|
|
@@ -53,11 +54,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
|
|
|
53
54
|
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
54
55
|
|
|
55
56
|
const itemClass = (enabled: boolean) =>
|
|
56
|
-
|
|
57
|
-
enabled
|
|
58
|
-
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
59
|
-
: "text-neutral-600 cursor-not-allowed"
|
|
60
|
-
}`;
|
|
57
|
+
`${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
|
|
61
58
|
|
|
62
59
|
// Disabled reasons: no gap under the pointer beats the lock reason — a
|
|
63
60
|
// pointer not on a gap has nothing to close regardless of movability.
|
|
@@ -75,7 +72,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
|
|
|
75
72
|
return createPortal(
|
|
76
73
|
<div
|
|
77
74
|
ref={menuRef}
|
|
78
|
-
className=
|
|
75
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
|
|
79
76
|
style={{ left: adjustedX, top: adjustedY }}
|
|
80
77
|
onPointerLeave={() => onHoverAction(null)}
|
|
81
78
|
>
|
|
@@ -99,6 +99,29 @@ describe("ClipMenuToolItems", () => {
|
|
|
99
99
|
expect(onClose).toHaveBeenCalled();
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it("opens its submenu at the row's edge in window pixels, and flips left near the window's right edge", () => {
|
|
103
|
+
const rowAt = (rect: { top: number; left: number; right: number }) =>
|
|
104
|
+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
|
|
105
|
+
const spy = rowAt({ top: 50, left: 100, right: 300 });
|
|
106
|
+
renderItems("sound", video);
|
|
107
|
+
openSubmenu("Voice");
|
|
108
|
+
let menu = document.querySelector<HTMLElement>('[role="menu"]')!;
|
|
109
|
+
expect(menu.className).toContain("fixed");
|
|
110
|
+
expect([menu.style.top, menu.style.left]).toEqual(["50px", "300px"]);
|
|
111
|
+
act(() => root?.unmount());
|
|
112
|
+
document.body.innerHTML = "";
|
|
113
|
+
spy.mockReturnValue({
|
|
114
|
+
top: 50,
|
|
115
|
+
left: window.innerWidth - 200,
|
|
116
|
+
right: window.innerWidth - 10,
|
|
117
|
+
} as DOMRect);
|
|
118
|
+
renderItems("sound", video);
|
|
119
|
+
openSubmenu("Voice");
|
|
120
|
+
menu = document.querySelector<HTMLElement>('[role="menu"]')!;
|
|
121
|
+
expect([menu.style.top, menu.style.right, menu.style.left]).toEqual(["50px", "200px", ""]);
|
|
122
|
+
spy.mockRestore();
|
|
123
|
+
});
|
|
124
|
+
|
|
102
125
|
it("Voice None drops the attribute", () => {
|
|
103
126
|
const { setQuiet } = renderItems("sound", video);
|
|
104
127
|
openSubmenu("Voice");
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ClipToolChoice,
|
|
16
16
|
} from "./clipToolAttrs";
|
|
17
17
|
import { useClipToolState } from "./useClipToolState";
|
|
18
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
18
19
|
|
|
19
20
|
export type ClipMenuToolGroup = "time" | "sound" | "picture";
|
|
20
21
|
|
|
@@ -25,10 +26,8 @@ interface ClipMenuToolItemsProps {
|
|
|
25
26
|
onClose: () => void;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const ROW_CLASS =
|
|
29
|
-
|
|
30
|
-
const DISABLED_ROW_CLASS =
|
|
31
|
-
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
|
|
29
|
+
const ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowEnabled}`;
|
|
30
|
+
const DISABLED_ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowDisabled}`;
|
|
32
31
|
const SUBMENU_WIDTH = 170;
|
|
33
32
|
const HOVER_PREVIEW_DELAY_MS = 80;
|
|
34
33
|
|
|
@@ -98,7 +97,7 @@ function ChoiceSubmenu({
|
|
|
98
97
|
preview?: HoverPreview;
|
|
99
98
|
}) {
|
|
100
99
|
const [open, setOpen] = useState(false);
|
|
101
|
-
const [
|
|
100
|
+
const [anchor, setAnchor] = useState<{ top: number; left?: number; right?: number }>({ top: 0 });
|
|
102
101
|
const rowRef = useRef<HTMLButtonElement | null>(null);
|
|
103
102
|
const submenuRef = useRef<HTMLDivElement | null>(null);
|
|
104
103
|
const hover = useHoverPreview(preview);
|
|
@@ -111,7 +110,12 @@ function ChoiceSubmenu({
|
|
|
111
110
|
|
|
112
111
|
const show = (focusFirst: boolean) => {
|
|
113
112
|
const rect = rowRef.current?.getBoundingClientRect();
|
|
114
|
-
|
|
113
|
+
if (rect)
|
|
114
|
+
setAnchor(
|
|
115
|
+
rect.right + SUBMENU_WIDTH > window.innerWidth
|
|
116
|
+
? { top: rect.top, right: window.innerWidth - rect.left }
|
|
117
|
+
: { top: rect.top, left: rect.right },
|
|
118
|
+
);
|
|
115
119
|
setOpen(true);
|
|
116
120
|
if (!focusFirst) return;
|
|
117
121
|
openingFocusRef.current = true;
|
|
@@ -184,8 +188,8 @@ function ChoiceSubmenu({
|
|
|
184
188
|
ref={submenuRef}
|
|
185
189
|
role="menu"
|
|
186
190
|
aria-label={label}
|
|
187
|
-
className=
|
|
188
|
-
style={{ width: SUBMENU_WIDTH, ...
|
|
191
|
+
className={`${menuClasses.panel} fixed z-10`}
|
|
192
|
+
style={{ width: SUBMENU_WIDTH, ...anchor }}
|
|
189
193
|
onKeyDown={onSubmenuKeyDown}
|
|
190
194
|
>
|
|
191
195
|
{choiceRow(null, "None")}
|
|
@@ -193,7 +197,7 @@ function ChoiceSubmenu({
|
|
|
193
197
|
<div key={section.heading ?? index}>
|
|
194
198
|
{section.heading && (
|
|
195
199
|
<>
|
|
196
|
-
<div className=
|
|
200
|
+
<div className={menuClasses.divider} />
|
|
197
201
|
<div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
|
|
198
202
|
{section.heading}
|
|
199
203
|
</div>
|