@hyperframes/studio 0.8.113 → 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-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
- package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/index-t1LH6H5H.css +1 -0
- 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 +46 -22
- package/dist/index.html +2 -2
- package/dist/index.js +3899 -3487
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/App.tsx +84 -86
- package/src/components/EditorShell.tsx +3 -3
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftPanels.tsx +3 -3
- package/src/components/StudioOverlays.tsx +4 -4
- package/src/components/StudioRightPanels.tsx +14 -9
- package/src/components/StudioToast.tsx +1 -1
- package/src/components/TimelineToolbar.tsx +3 -3
- 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/InlineTextToolbar.tsx +11 -27
- 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/colorBrowserCanvas.test.ts +116 -0
- package/src/components/editor/colorValue.test.ts +16 -0
- package/src/components/editor/colorValue.ts +100 -24
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.test.ts +11 -0
- package/src/components/editor/cropResize.ts +6 -6
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/gradientValue.ts +28 -36
- package/src/components/editor/gsapAnimatesProperty.ts +0 -54
- package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
- package/src/components/editor/inlineTextStyleRange.ts +5 -2
- package/src/components/editor/inlineTextStyleRead.ts +9 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +2 -2
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualEditsSeekReapply.ts +2 -2
- 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/propertyPanelColor.tsx +1 -1
- 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/reapplyPositionEditsAfterSeek.test.ts +25 -0
- 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/gsapRuntimeKeyframes.ts +1 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
- package/src/hooks/gsapRuntimeReaders.ts +3 -90
- 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/useAppHotkeys.ts +11 -5
- 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/useFrameCapture.ts +15 -8
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +90 -56
- package/src/hooks/useGestureTrailOverlay.tsx +33 -0
- 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/usePanelLayout.ts +5 -1
- 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/useShellSlices.ts +53 -0
- package/src/hooks/useStableHandlers.test.tsx +77 -0
- package/src/hooks/useStableHandlers.ts +44 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -1
- package/src/hooks/useStudioSidePanels.test.tsx +44 -0
- package/src/hooks/useStudioSidePanels.tsx +23 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
- 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/hooks/useTimelineEditing.ts +33 -27
- 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/frameCapture.ts +3 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
- 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/assets/index-DbfKVTId.css +0 -1
- 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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { EnableKeyframesSession } from "./useEnableKeyframes";
|
|
3
|
+
|
|
4
|
+
interface HistoryFlags {
|
|
5
|
+
canUndo: boolean;
|
|
6
|
+
canRedo: boolean;
|
|
7
|
+
undoLabel: string | undefined;
|
|
8
|
+
redoLabel: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The four history fields the shell context carries, as one object that changes only when they do. */
|
|
12
|
+
export function useHistoryFlags({ canUndo, canRedo, undoLabel, redoLabel }: HistoryFlags) {
|
|
13
|
+
return useMemo(
|
|
14
|
+
() => ({ canUndo, canRedo, undoLabel, redoLabel }),
|
|
15
|
+
[canUndo, canRedo, undoLabel, redoLabel],
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The session fields the timeline toolbar reads, so a hover or any other session change leaves it alone. */
|
|
20
|
+
export function useToolbarSession(session: EnableKeyframesSession): EnableKeyframesSession {
|
|
21
|
+
const {
|
|
22
|
+
domEditSelection,
|
|
23
|
+
selectedGsapAnimations,
|
|
24
|
+
previewIframeRef,
|
|
25
|
+
commitMutation,
|
|
26
|
+
handleGsapAddAnimation,
|
|
27
|
+
handleGsapAddKeyframeBatch,
|
|
28
|
+
handleGsapConvertToKeyframes,
|
|
29
|
+
handleGsapRemoveKeyframe,
|
|
30
|
+
} = session;
|
|
31
|
+
return useMemo(
|
|
32
|
+
() => ({
|
|
33
|
+
domEditSelection,
|
|
34
|
+
selectedGsapAnimations,
|
|
35
|
+
previewIframeRef,
|
|
36
|
+
commitMutation,
|
|
37
|
+
handleGsapAddAnimation,
|
|
38
|
+
handleGsapAddKeyframeBatch,
|
|
39
|
+
handleGsapConvertToKeyframes,
|
|
40
|
+
handleGsapRemoveKeyframe,
|
|
41
|
+
}),
|
|
42
|
+
[
|
|
43
|
+
domEditSelection,
|
|
44
|
+
selectedGsapAnimations,
|
|
45
|
+
previewIframeRef,
|
|
46
|
+
commitMutation,
|
|
47
|
+
handleGsapAddAnimation,
|
|
48
|
+
handleGsapAddKeyframeBatch,
|
|
49
|
+
handleGsapConvertToKeyframes,
|
|
50
|
+
handleGsapRemoveKeyframe,
|
|
51
|
+
],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
interface Result {
|
|
11
|
+
count: number;
|
|
12
|
+
read: () => number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let root: Root | null = null;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
act(() => root?.unmount());
|
|
18
|
+
root = null;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function renderStable(seen: Result[]) {
|
|
22
|
+
function Probe({ count, base }: { count: number; base: number }) {
|
|
23
|
+
seen.push(useStableHandlers({ count, read: () => base }, "project"));
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
root = createRoot(document.createElement("div"));
|
|
27
|
+
return (count: number, base: number) =>
|
|
28
|
+
act(() => root!.render(<Probe count={count} base={base} />));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("useStableHandlers", () => {
|
|
32
|
+
it("keeps the object while only its handlers change, and the handler calls the latest one", () => {
|
|
33
|
+
const seen: Result[] = [];
|
|
34
|
+
const render = renderStable(seen);
|
|
35
|
+
render(1, 10);
|
|
36
|
+
render(1, 20);
|
|
37
|
+
expect(seen[1]).toBe(seen[0]);
|
|
38
|
+
expect(seen[1]!.read()).toBe(20);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("gives a new object when a data field changes, with the same handler identity", () => {
|
|
42
|
+
const seen: Result[] = [];
|
|
43
|
+
const render = renderStable(seen);
|
|
44
|
+
render(1, 10);
|
|
45
|
+
render(2, 10);
|
|
46
|
+
expect(seen[1]).not.toBe(seen[0]);
|
|
47
|
+
expect(seen[1]!.count).toBe(2);
|
|
48
|
+
expect(seen[1]!.read).toBe(seen[0]!.read);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps a wrapper from an earlier scope calling that scope's handler", () => {
|
|
52
|
+
const seen: { write: () => string }[] = [];
|
|
53
|
+
function Scoped({ project }: { project: string }) {
|
|
54
|
+
seen.push(useStableHandlers({ write: () => project }, project));
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
root = createRoot(document.createElement("div"));
|
|
58
|
+
act(() => root!.render(<Scoped project="a" />));
|
|
59
|
+
const kept = seen[0]!.write;
|
|
60
|
+
act(() => root!.render(<Scoped project="b" />));
|
|
61
|
+
expect(kept()).toBe("a");
|
|
62
|
+
expect(seen[1]!.write).not.toBe(kept);
|
|
63
|
+
expect(seen[1]!.write()).toBe("b");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("gives a new object when a key is swapped for another with the same value", () => {
|
|
67
|
+
const seen: Record<string, unknown>[] = [];
|
|
68
|
+
function Keys({ name }: { name: string }) {
|
|
69
|
+
seen.push(useStableHandlers({ [name]: undefined }, "project"));
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
root = createRoot(document.createElement("div"));
|
|
73
|
+
act(() => root!.render(<Keys name="old" />));
|
|
74
|
+
act(() => root!.render(<Keys name="fresh" />));
|
|
75
|
+
expect(Object.keys(seen[1]!)).toEqual(["fresh"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
|
|
3
|
+
type Handler = (...args: never[]) => unknown;
|
|
4
|
+
type Epoch = {
|
|
5
|
+
scope: unknown;
|
|
6
|
+
latest: object;
|
|
7
|
+
wrappers: Map<string, Handler>;
|
|
8
|
+
previous: object | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// The same object while its non-function fields are unchanged, and one identity per function field calling the
|
|
12
|
+
// latest one; a new `scope` (the project) starts new identities, and a wrapper kept from before calls its own scope's.
|
|
13
|
+
export function useStableHandlers<T extends object>(value: T, scope: unknown): T {
|
|
14
|
+
const epoch = useRef<Epoch | null>(null);
|
|
15
|
+
if (!epoch.current || !Object.is(epoch.current.scope, scope))
|
|
16
|
+
epoch.current = { scope, latest: value, wrappers: new Map(), previous: null };
|
|
17
|
+
const current = epoch.current;
|
|
18
|
+
current.latest = value;
|
|
19
|
+
|
|
20
|
+
const next: Record<string, unknown> = {};
|
|
21
|
+
for (const [key, field] of Object.entries(value)) {
|
|
22
|
+
if (typeof field !== "function") {
|
|
23
|
+
next[key] = field;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
let wrapper = current.wrappers.get(key);
|
|
27
|
+
if (!wrapper) {
|
|
28
|
+
wrapper = (...args: never[]) =>
|
|
29
|
+
(current.latest as Record<string, (...a: never[]) => unknown>)[key]!(...args);
|
|
30
|
+
current.wrappers.set(key, wrapper);
|
|
31
|
+
}
|
|
32
|
+
next[key] = wrapper;
|
|
33
|
+
}
|
|
34
|
+
const prev = current.previous as Record<string, unknown> | null;
|
|
35
|
+
const keys = Object.keys(next);
|
|
36
|
+
if (
|
|
37
|
+
prev &&
|
|
38
|
+
keys.length === Object.keys(prev).length &&
|
|
39
|
+
keys.every((k) => k in prev && prev[k] === next[k])
|
|
40
|
+
)
|
|
41
|
+
return prev as T;
|
|
42
|
+
current.previous = next;
|
|
43
|
+
return next as T;
|
|
44
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { useSdkSession } from "./useSdkSession";
|
|
3
3
|
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|
4
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Open the studio's SDK session with master-view semantics.
|
|
@@ -37,5 +38,5 @@ export function useStudioSdkSessions(
|
|
|
37
38
|
useEffect(() => {
|
|
38
39
|
usePreviewVariablesStore.getState().setValues(null);
|
|
39
40
|
}, [projectId, activeCompPath]);
|
|
40
|
-
return { sdkHandle, editFlowSdkSession };
|
|
41
|
+
return { sdkHandle: useStableHandlers(sdkHandle, projectId), editFlowSdkSession };
|
|
41
42
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, type ComponentProps } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { StudioLeftPanels } from "../components/StudioLeftPanels";
|
|
7
|
+
import type { StudioRightPanels } from "../components/StudioRightPanels";
|
|
8
|
+
import { useStudioSidePanels } from "./useStudioSidePanels";
|
|
9
|
+
|
|
10
|
+
const rightProps = vi.hoisted(() => [] as { recordEdit: (project: string) => string }[]);
|
|
11
|
+
vi.mock("../components/StudioLeftPanels", () => ({ StudioLeftPanels: () => null }));
|
|
12
|
+
vi.mock("../components/StudioRightPanels", () => ({
|
|
13
|
+
StudioRightPanels: (props: { recordEdit: (project: string) => string }) => {
|
|
14
|
+
rightProps.push(props);
|
|
15
|
+
return null;
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
let root: Root | null = null;
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
act(() => root?.unmount());
|
|
24
|
+
root = null;
|
|
25
|
+
rightProps.length = 0;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function Panels({ project }: { project: string }) {
|
|
29
|
+
const right = { recordEdit: () => project } as unknown as ComponentProps<
|
|
30
|
+
typeof StudioRightPanels
|
|
31
|
+
>;
|
|
32
|
+
return useStudioSidePanels({} as ComponentProps<typeof StudioLeftPanels>, right, project);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("useStudioSidePanels", () => {
|
|
36
|
+
it("keeps a panel handler from one project calling that project after a switch", () => {
|
|
37
|
+
root = createRoot(document.createElement("div"));
|
|
38
|
+
act(() => root!.render(<Panels project="a" />));
|
|
39
|
+
const kept = rightProps.at(-1)!.recordEdit;
|
|
40
|
+
act(() => root!.render(<Panels project="b" />));
|
|
41
|
+
expect(kept("")).toBe("a");
|
|
42
|
+
expect(rightProps.at(-1)!.recordEdit("")).toBe("b");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useMemo, type ComponentProps } from "react";
|
|
2
|
+
import { StudioLeftPanels } from "../components/StudioLeftPanels";
|
|
3
|
+
import { StudioRightPanels } from "../components/StudioRightPanels";
|
|
4
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
5
|
+
|
|
6
|
+
/** Both side panels as one element that changes only when a panel's data does, never for a new handler. */
|
|
7
|
+
export function useStudioSidePanels(
|
|
8
|
+
left: ComponentProps<typeof StudioLeftPanels>,
|
|
9
|
+
right: ComponentProps<typeof StudioRightPanels>,
|
|
10
|
+
projectId: string | null,
|
|
11
|
+
) {
|
|
12
|
+
const stableLeft = useStableHandlers(left, projectId);
|
|
13
|
+
const stableRight = useStableHandlers(right, projectId);
|
|
14
|
+
return useMemo(
|
|
15
|
+
() => (
|
|
16
|
+
<>
|
|
17
|
+
<StudioLeftPanels {...stableLeft} />
|
|
18
|
+
<StudioRightPanels {...stableRight} />
|
|
19
|
+
</>
|
|
20
|
+
),
|
|
21
|
+
[stableLeft, stableRight],
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
3
4
|
|
|
4
5
|
type AddAtPlacement = (path: string, placement: { start: number; track: number }) => unknown;
|
|
5
6
|
|
|
6
|
-
export function useTimelineAddAtPlayhead(
|
|
7
|
+
export function useTimelineAddAtPlayhead(
|
|
8
|
+
addAsset: AddAtPlacement,
|
|
9
|
+
addComposition: AddAtPlacement,
|
|
10
|
+
projectId: string | null,
|
|
11
|
+
) {
|
|
7
12
|
const placement = () => ({ start: usePlayerStore.getState().currentTime, track: 0 });
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
return useStableHandlers(
|
|
14
|
+
{
|
|
15
|
+
addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
|
|
16
|
+
addCompositionAtPlayhead: useCallback(
|
|
17
|
+
(path: string) => addComposition(path, placement()),
|
|
18
|
+
[addComposition],
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
projectId,
|
|
22
|
+
);
|
|
15
23
|
}
|
|
@@ -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}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// fallow-ignore-file complexity
|
|
2
2
|
import { useCallback, useRef } from "react";
|
|
3
|
+
import { useStableHandlers } from "./useStableHandlers";
|
|
3
4
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
5
|
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
|
5
6
|
import { useRazorSplit } from "./useRazorSplit";
|
|
@@ -508,13 +509,41 @@ export function useTimelineEditing({
|
|
|
508
509
|
return [...flatMembers, ...domMembers];
|
|
509
510
|
};
|
|
510
511
|
|
|
512
|
+
const audioGroupAttribute = {
|
|
513
|
+
...setAudioGroupAttribute,
|
|
514
|
+
// Same two-array member lookup syncStoredGroupAttribute mirrors into
|
|
515
|
+
// (timelineAudioGroupVolume.ts): a sub-composition's group members have
|
|
516
|
+
// no flat twin, only a domClipChildren entry, so both are checked.
|
|
517
|
+
setQuiet: track(
|
|
518
|
+
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
|
519
|
+
setAudioGroupAttribute.revertLive(groupId, attr);
|
|
520
|
+
return refused(reason);
|
|
521
|
+
}),
|
|
522
|
+
),
|
|
523
|
+
};
|
|
524
|
+
const stableAudioGroupAttribute = useStableHandlers(audioGroupAttribute, projectId);
|
|
525
|
+
const elementFxAttribute = {
|
|
526
|
+
...setElementFxAttribute,
|
|
527
|
+
setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
|
|
528
|
+
setQuiet: track(
|
|
529
|
+
guard(
|
|
530
|
+
(element) => [element],
|
|
531
|
+
setElementFxAttribute.setQuiet,
|
|
532
|
+
(reason, element, attr) => {
|
|
533
|
+
setElementFxAttribute.revertLive(element, attr);
|
|
534
|
+
return refused(reason);
|
|
535
|
+
},
|
|
536
|
+
),
|
|
537
|
+
),
|
|
538
|
+
};
|
|
539
|
+
const stableElementFxAttribute = useStableHandlers(elementFxAttribute, projectId);
|
|
511
540
|
// Every write-handler is tracked here, the one place all hand edits
|
|
512
541
|
// converge, so undo never races a write; canEdit gates the same point.
|
|
513
542
|
// Coverage boundary: see the PR body, not every kind resolves an element.
|
|
514
543
|
const trackedRazorSplit = track(
|
|
515
544
|
guard((element) => withLinkPartners([element]), handleRazorSplit),
|
|
516
545
|
);
|
|
517
|
-
|
|
546
|
+
const editing = {
|
|
518
547
|
handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
|
|
519
548
|
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
520
549
|
handleToggleTrackHidden: track(
|
|
@@ -530,32 +559,8 @@ export function useTimelineEditing({
|
|
|
530
559
|
}, handleToggleElementHidden),
|
|
531
560
|
),
|
|
532
561
|
handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
|
|
533
|
-
setAudioGroupAttribute:
|
|
534
|
-
|
|
535
|
-
// Same two-array member lookup syncStoredGroupAttribute mirrors into
|
|
536
|
-
// (timelineAudioGroupVolume.ts): a sub-composition's group members have
|
|
537
|
-
// no flat twin, only a domClipChildren entry, so both are checked.
|
|
538
|
-
setQuiet: track(
|
|
539
|
-
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
|
540
|
-
setAudioGroupAttribute.revertLive(groupId, attr);
|
|
541
|
-
return refused(reason);
|
|
542
|
-
}),
|
|
543
|
-
),
|
|
544
|
-
},
|
|
545
|
-
setElementFxAttribute: {
|
|
546
|
-
...setElementFxAttribute,
|
|
547
|
-
setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
|
|
548
|
-
setQuiet: track(
|
|
549
|
-
guard(
|
|
550
|
-
(element) => [element],
|
|
551
|
-
setElementFxAttribute.setQuiet,
|
|
552
|
-
(reason, element, attr) => {
|
|
553
|
-
setElementFxAttribute.revertLive(element, attr);
|
|
554
|
-
return refused(reason);
|
|
555
|
-
},
|
|
556
|
-
),
|
|
557
|
-
),
|
|
558
|
-
},
|
|
562
|
+
setAudioGroupAttribute: stableAudioGroupAttribute,
|
|
563
|
+
setElementFxAttribute: stableElementFxAttribute,
|
|
559
564
|
handleTimelineElementDelete: track(
|
|
560
565
|
guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
|
|
561
566
|
),
|
|
@@ -591,4 +596,5 @@ export function useTimelineEditing({
|
|
|
591
596
|
setAudioGroupAttribute.restoreLive(restore);
|
|
592
597
|
},
|
|
593
598
|
};
|
|
599
|
+
return useStableHandlers(editing, projectId);
|
|
594
600
|
}
|
|
@@ -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
|
+
});
|