@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
1
2
|
/**
|
|
2
3
|
* An open effect's knobs, with whatever automation surface applies to them.
|
|
3
4
|
*
|
|
@@ -69,6 +70,7 @@ export function FxNodeParams({
|
|
|
69
70
|
index: number;
|
|
70
71
|
disabled: boolean;
|
|
71
72
|
}) {
|
|
73
|
+
const trackInput = useTrackDesignInput();
|
|
72
74
|
const nodeId = node.id;
|
|
73
75
|
// Lanes address a node by id; the controls know their own parameter keys. This
|
|
74
76
|
// is the one place that translation belongs.
|
|
@@ -97,14 +99,25 @@ export function FxNodeParams({
|
|
|
97
99
|
if (before[key] === value) continue;
|
|
98
100
|
if (typeof value !== "number" && typeof value !== "string") continue;
|
|
99
101
|
trackParamCommitted(node.type, key, value, "details", { trackKind });
|
|
102
|
+
trackInput(typeof value === "number" ? "slider" : "select", key);
|
|
100
103
|
}
|
|
101
104
|
onUpdate(index, { params: next });
|
|
102
105
|
}}
|
|
103
106
|
automatedKeys={automatedKeysOf(node, def.params, automatedTargets)}
|
|
104
|
-
onAutomate={
|
|
107
|
+
onAutomate={
|
|
108
|
+
nodeId && onAutomateParam
|
|
109
|
+
? (key) => {
|
|
110
|
+
onAutomateParam(nodeId, key);
|
|
111
|
+
trackInput("button", "automate-param");
|
|
112
|
+
}
|
|
113
|
+
: undefined
|
|
114
|
+
}
|
|
105
115
|
onRemoveAutomation={
|
|
106
116
|
nodeId && onRemoveParamAutomation
|
|
107
|
-
? (key) =>
|
|
117
|
+
? (key) => {
|
|
118
|
+
onRemoveParamAutomation(nodeId, key);
|
|
119
|
+
trackInput("button", "remove-param-automation");
|
|
120
|
+
}
|
|
108
121
|
: undefined
|
|
109
122
|
}
|
|
110
123
|
/>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
1
2
|
/**
|
|
2
3
|
* One effect in the FX rack: its header controls, and its knobs when open.
|
|
3
4
|
*
|
|
@@ -246,6 +247,7 @@ export function FxNodeRow({
|
|
|
246
247
|
onPreview,
|
|
247
248
|
trackKind,
|
|
248
249
|
}: FxNodeRowProps) {
|
|
250
|
+
const trackInput = useTrackDesignInput();
|
|
249
251
|
const registryDef = getAudioFxDef(node.type);
|
|
250
252
|
const def = useMemo(() => (registryDef ? plainDef(registryDef) : null), [registryDef]);
|
|
251
253
|
const primary = registryDef ? primaryParamOf(registryDef) : null;
|
|
@@ -306,6 +308,7 @@ export function FxNodeRow({
|
|
|
306
308
|
onToggleBypass={() => {
|
|
307
309
|
trackNodeBypassed(node.type, !bypassed, { trackKind });
|
|
308
310
|
onUpdate(index, { enabled: bypassed });
|
|
311
|
+
trackInput("toggle", "effect-bypass");
|
|
309
312
|
}}
|
|
310
313
|
onMove={(delta) => onMove(index, delta)}
|
|
311
314
|
onRemove={() => onRemove(index)}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
1
2
|
/**
|
|
2
3
|
* One preset's bracket in the rack: its own header, on/off switch, amount lane
|
|
3
4
|
* and the rows it wraps — or, for a run with no preset, just the rows.
|
|
@@ -93,6 +94,7 @@ export function FxPresetRun({
|
|
|
93
94
|
onAutomate,
|
|
94
95
|
onRemoveAutomation,
|
|
95
96
|
}: FxPresetRunProps) {
|
|
97
|
+
const trackInput = useTrackDesignInput();
|
|
96
98
|
const rows = run.items.map(({ node, i }) => (
|
|
97
99
|
<FxNodeRow
|
|
98
100
|
// Keyed by id, as the carve module's list above already is. On
|
|
@@ -183,7 +185,10 @@ export function FxPresetRun({
|
|
|
183
185
|
aria-pressed={runOn}
|
|
184
186
|
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
|
|
185
187
|
disabled={disabled}
|
|
186
|
-
onClick={() =>
|
|
188
|
+
onClick={() => {
|
|
189
|
+
onSetAmount(runOn ? 0 : 1);
|
|
190
|
+
trackInput("toggle", "preset-amount");
|
|
191
|
+
}}
|
|
187
192
|
>
|
|
188
193
|
{runOn ? "On" : "Off"}
|
|
189
194
|
</button>
|
|
@@ -205,7 +210,10 @@ export function FxPresetRun({
|
|
|
205
210
|
disabled={disabled || automated}
|
|
206
211
|
automated={automated}
|
|
207
212
|
onChange={(_k, v) => onSetAmount(Number(v), false)}
|
|
208
|
-
onCommit={(_k, v) =>
|
|
213
|
+
onCommit={(_k, v) => {
|
|
214
|
+
onSetAmount(Number(v));
|
|
215
|
+
trackInput("slider", "preset-amount");
|
|
216
|
+
}}
|
|
209
217
|
onAutomate={onAutomate}
|
|
210
218
|
onRemoveAutomation={onRemoveAutomation}
|
|
211
219
|
/>
|
|
@@ -1787,3 +1787,59 @@ describe("voiceover carve visibility", () => {
|
|
|
1787
1787
|
expect(carveBlock(host)).toBeTruthy();
|
|
1788
1788
|
});
|
|
1789
1789
|
});
|
|
1790
|
+
|
|
1791
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
1792
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
1793
|
+
import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
|
|
1794
|
+
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
|
1795
|
+
function renderTrackedFx(props: Partial<Parameters<typeof FxSection>[0]> = {}) {
|
|
1796
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
1797
|
+
__resetDesignInputThrottle();
|
|
1798
|
+
return renderInto(
|
|
1799
|
+
<DesignPanelInputProvider ui="flat" section="media">
|
|
1800
|
+
<FxSection
|
|
1801
|
+
chain={chainOf("lowpass", "gain")}
|
|
1802
|
+
onChainChange={vi.fn()}
|
|
1803
|
+
carve={null}
|
|
1804
|
+
onCarveChange={vi.fn()}
|
|
1805
|
+
sourceOptions={[]}
|
|
1806
|
+
{...props}
|
|
1807
|
+
/>
|
|
1808
|
+
</DesignPanelInputProvider>,
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
describe("custom FX inspector input usage", () => {
|
|
1813
|
+
it.each([
|
|
1814
|
+
[".hf-fx-bypass", "toggle", "effect-bypass"],
|
|
1815
|
+
['.hf-fx-move[title="Move down"]', "button", "move-effect"],
|
|
1816
|
+
[".hf-fx-remove", "button", "remove-effect"],
|
|
1817
|
+
])("tracks %s through design_input once", (selector, control, name) => {
|
|
1818
|
+
const onChainChange = vi.fn();
|
|
1819
|
+
const { host, root } = renderTrackedFx({ onChainChange });
|
|
1820
|
+
click(host.querySelector(selector));
|
|
1821
|
+
expect(onChainChange).toHaveBeenCalledTimes(1);
|
|
1822
|
+
expect(
|
|
1823
|
+
vi.mocked(trackStudioEvent).mock.calls.filter(([event]) => event === "design_input"),
|
|
1824
|
+
).toEqual([["design_input", { ui: "flat", section: "media", control, name }]]);
|
|
1825
|
+
expect(vi.mocked(trackStudioEvent).mock.calls.some(([event]) => event === "feature_used")).toBe(
|
|
1826
|
+
false,
|
|
1827
|
+
);
|
|
1828
|
+
act(() => root.unmount());
|
|
1829
|
+
});
|
|
1830
|
+
it.each([false, true])("tracks a levelling button once (already levelled: %s)", (levelled) => {
|
|
1831
|
+
const action = vi.fn();
|
|
1832
|
+
const { host, root } = renderTrackedFx({ levelled, onLevel: action, onRemoveLevel: action });
|
|
1833
|
+
click(byText(host, "button", "+ effect"));
|
|
1834
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
1835
|
+
click(byText(host, "button", levelled ? "Remove levelling" : "Even Out Levels"));
|
|
1836
|
+
expect(action).toHaveBeenCalledTimes(1);
|
|
1837
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("design_input", {
|
|
1838
|
+
ui: "flat",
|
|
1839
|
+
section: "media",
|
|
1840
|
+
control: "button",
|
|
1841
|
+
name: levelled ? "remove-levelling" : "add-levelling",
|
|
1842
|
+
});
|
|
1843
|
+
act(() => root.unmount());
|
|
1844
|
+
});
|
|
1845
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
1
2
|
/**
|
|
2
3
|
* The FX section for an audio element: the chain, plus the voiceover carve.
|
|
3
4
|
*
|
|
@@ -120,13 +121,17 @@ export function FxSection({
|
|
|
120
121
|
// could only name a source it must not.
|
|
121
122
|
const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null);
|
|
122
123
|
|
|
124
|
+
const trackInput = useTrackDesignInput();
|
|
123
125
|
const [adding, setAdding] = useState(false);
|
|
124
126
|
const [picking, setPicking] = useState(false);
|
|
125
127
|
const [openNode, setOpenNode] = useState<number | null>(0);
|
|
126
128
|
|
|
127
129
|
const mutate = useCallback(
|
|
128
|
-
(nodes: HfAudioFxNode[]
|
|
129
|
-
|
|
130
|
+
(nodes: HfAudioFxNode[], input?: string, control = "button") => {
|
|
131
|
+
onChainChange({ ...chain, nodes });
|
|
132
|
+
if (input) trackInput(control, input);
|
|
133
|
+
},
|
|
134
|
+
[chain, onChainChange, trackInput],
|
|
130
135
|
);
|
|
131
136
|
|
|
132
137
|
// Dragging a knob previews without persisting; releasing it commits once.
|
|
@@ -156,7 +161,7 @@ export function FxSection({
|
|
|
156
161
|
// old chain back over the write that just landed is a race the author
|
|
157
162
|
// hears as the preset arriving and then leaving again.
|
|
158
163
|
clearAudition();
|
|
159
|
-
mutate(next.nodes);
|
|
164
|
+
mutate(next.nodes, "apply-preset");
|
|
160
165
|
// Land on the first node the preset wrote, so the author can hear what
|
|
161
166
|
// arrived and immediately see what it is made of.
|
|
162
167
|
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === id));
|
|
@@ -169,7 +174,7 @@ export function FxSection({
|
|
|
169
174
|
(job: HfAudioFxJob) => {
|
|
170
175
|
clearAudition();
|
|
171
176
|
trackNodeAdded(job.type, "job", job.id, { trackKind });
|
|
172
|
-
mutate(withJob(chain, job).nodes);
|
|
177
|
+
mutate(withJob(chain, job).nodes, "add-job");
|
|
173
178
|
setOpenNode(chain.nodes.length);
|
|
174
179
|
setAdding(false);
|
|
175
180
|
},
|
|
@@ -180,7 +185,7 @@ export function FxSection({
|
|
|
180
185
|
(type: string) => {
|
|
181
186
|
clearAudition();
|
|
182
187
|
trackNodeAdded(type, "effect", null, { trackKind });
|
|
183
|
-
mutate(withEffect(chain, type).nodes);
|
|
188
|
+
mutate(withEffect(chain, type).nodes, "add-effect");
|
|
184
189
|
setOpenNode(chain.nodes.length);
|
|
185
190
|
setAdding(false);
|
|
186
191
|
},
|
|
@@ -218,26 +223,18 @@ export function FxSection({
|
|
|
218
223
|
[chain, mutate, onChainPreview],
|
|
219
224
|
);
|
|
220
225
|
|
|
221
|
-
|
|
222
|
-
* Take a preset back out whole, lanes and all.
|
|
223
|
-
*
|
|
224
|
-
* Same contract as removing one node — an orphaned lane keeps driving a
|
|
225
|
-
* parameter that is no longer in the graph, and with ids minted lowest-free
|
|
226
|
-
* the next effect added inherits it.
|
|
227
|
-
*/
|
|
226
|
+
// Remove the run and its lanes together so new nodes cannot inherit orphaned automation.
|
|
228
227
|
const removeRun = useCallback(
|
|
229
228
|
(items: { node: HfAudioFxNode; i: number }[], presetId?: string) => {
|
|
230
|
-
//
|
|
231
|
-
// replaces the whole attribute, so a loop kept only its last write and left
|
|
232
|
-
// the other nodes' lanes behind as orphans. The preset id goes with it —
|
|
233
|
-
// the `fx.preset.<id>` amount lane belongs to the preset, not to any node,
|
|
234
|
-
// so nothing else would ever collect it, and re-applying the preset later
|
|
235
|
-
// resurrected the old ramp.
|
|
229
|
+
// Remove node and preset automation together before replacing the chain.
|
|
236
230
|
const ids = items.map(({ node }) => node.id).filter((id): id is string => Boolean(id));
|
|
237
231
|
if (ids.length > 0 || presetId) onRemoveNodesAutomation?.(ids, presetId);
|
|
238
232
|
if (presetId) trackPresetRemoved(presetId, { trackKind });
|
|
239
233
|
const slots = new Set(items.map((item) => item.i));
|
|
240
|
-
mutate(
|
|
234
|
+
mutate(
|
|
235
|
+
chain.nodes.filter((_, i) => !slots.has(i)),
|
|
236
|
+
"remove-preset",
|
|
237
|
+
);
|
|
241
238
|
setOpenNode(null);
|
|
242
239
|
},
|
|
243
240
|
[chain.nodes, mutate, onRemoveNodesAutomation, trackKind],
|
|
@@ -254,7 +251,10 @@ export function FxSection({
|
|
|
254
251
|
const removedId = removed?.id;
|
|
255
252
|
if (removedId) onRemoveNodeAutomation?.(removedId);
|
|
256
253
|
if (removed) trackNodeRemoved(removed.type, nodeOrigin(removed), { trackKind });
|
|
257
|
-
mutate(
|
|
254
|
+
mutate(
|
|
255
|
+
chain.nodes.filter((_, i) => i !== index),
|
|
256
|
+
"remove-effect",
|
|
257
|
+
);
|
|
258
258
|
setOpenNode(null);
|
|
259
259
|
},
|
|
260
260
|
[chain.nodes, mutate, onRemoveNodeAutomation, trackKind],
|
|
@@ -372,7 +372,7 @@ export function FxSection({
|
|
|
372
372
|
clearAudition();
|
|
373
373
|
const { chain: next, eqId } = addAudioEq(chain);
|
|
374
374
|
trackNodeAdded("eq", "eq", null, { trackKind });
|
|
375
|
-
mutate(next.nodes);
|
|
375
|
+
mutate(next.nodes, "add-eq");
|
|
376
376
|
setOpenEq(eqId);
|
|
377
377
|
setAdding(false);
|
|
378
378
|
}, [chain, mutate, clearAudition, trackKind]);
|
|
@@ -386,20 +386,18 @@ export function FxSection({
|
|
|
386
386
|
);
|
|
387
387
|
const commitEqBand = useCallback(
|
|
388
388
|
(eqId: string, band: string, gain: number) =>
|
|
389
|
-
mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes),
|
|
389
|
+
mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes, "eq-gain", "slider"),
|
|
390
390
|
[chain, mutate],
|
|
391
391
|
);
|
|
392
392
|
const removeEq = useCallback(
|
|
393
393
|
(eqId: string) => {
|
|
394
|
-
//
|
|
395
|
-
// last-write-wins bug and was only unreachable because an EQ band row
|
|
396
|
-
// offers no automation toggle today.
|
|
394
|
+
// Remove the EQ automation in one write.
|
|
397
395
|
const ids = chain.nodes
|
|
398
396
|
.filter((node) => node.fromEq === eqId)
|
|
399
397
|
.map((node) => node.id)
|
|
400
398
|
.filter((id): id is string => Boolean(id));
|
|
401
399
|
if (ids.length > 0) onRemoveNodesAutomation?.(ids);
|
|
402
|
-
mutate(removeAudioEq(chain, eqId).nodes);
|
|
400
|
+
mutate(removeAudioEq(chain, eqId).nodes, "remove-eq");
|
|
403
401
|
},
|
|
404
402
|
[chain, mutate, onRemoveNodesAutomation],
|
|
405
403
|
);
|
|
@@ -412,7 +410,7 @@ export function FxSection({
|
|
|
412
410
|
const [moved] = next.splice(index, 1);
|
|
413
411
|
next.splice(target, 0, moved!);
|
|
414
412
|
if (moved) trackNodeMoved(moved.type, delta < 0 ? "up" : "down", { trackKind });
|
|
415
|
-
mutate(next);
|
|
413
|
+
mutate(next, "move-effect");
|
|
416
414
|
setOpenNode(target);
|
|
417
415
|
},
|
|
418
416
|
[chain.nodes, mutate, trackKind],
|
|
@@ -448,6 +446,7 @@ export function FxSection({
|
|
|
448
446
|
return () => {
|
|
449
447
|
trackPresetAutomated(presetId, true, { trackKind });
|
|
450
448
|
onAutomatePreset(presetId, amount);
|
|
449
|
+
trackInput("button", "automate-preset");
|
|
451
450
|
};
|
|
452
451
|
};
|
|
453
452
|
|
|
@@ -456,7 +455,10 @@ export function FxSection({
|
|
|
456
455
|
presetId: string | undefined,
|
|
457
456
|
): (() => void) | undefined => {
|
|
458
457
|
if (!presetId || !onRemovePresetAutomation || !presetAutomated.has(presetId)) return undefined;
|
|
459
|
-
return () =>
|
|
458
|
+
return () => {
|
|
459
|
+
onRemovePresetAutomation(presetId);
|
|
460
|
+
trackInput("button", "remove-preset-automation");
|
|
461
|
+
};
|
|
460
462
|
};
|
|
461
463
|
|
|
462
464
|
return (
|
|
@@ -82,12 +82,15 @@ export interface PropertyPanelProps {
|
|
|
82
82
|
element: DomEditSelection,
|
|
83
83
|
next: { x: number; y: number },
|
|
84
84
|
route?: { plainTranslate: boolean },
|
|
85
|
-
) => void | Promise<void>;
|
|
85
|
+
) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
|
|
86
86
|
onSetManualSize: (
|
|
87
87
|
element: DomEditSelection,
|
|
88
88
|
next: { width: number; height: number },
|
|
89
|
-
) => void | Promise<void>;
|
|
90
|
-
onSetManualRotation: (
|
|
89
|
+
) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
|
|
90
|
+
onSetManualRotation: (
|
|
91
|
+
element: DomEditSelection,
|
|
92
|
+
next: { angle: number },
|
|
93
|
+
) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
|
|
91
94
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
92
95
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
93
96
|
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
|
|
4
4
|
import {
|
|
5
|
+
STUDIO_OFFSET_X_PROP,
|
|
6
|
+
STUDIO_OFFSET_Y_PROP,
|
|
5
7
|
STUDIO_PATH_OFFSET_ATTR,
|
|
6
8
|
STUDIO_ROTATION_ATTR,
|
|
7
9
|
STUDIO_ROTATION_PROP,
|
|
@@ -14,6 +16,29 @@ describe("reapplyPositionEditsAfterSeek", () => {
|
|
|
14
16
|
vi.restoreAllMocks();
|
|
15
17
|
});
|
|
16
18
|
|
|
19
|
+
it("keeps an old offset off an element GSAP moves by object-of-arrays keyframes, not off its neighbour", () => {
|
|
20
|
+
const offset = `${STUDIO_OFFSET_X_PROP}: 300px; ${STUDIO_OFFSET_Y_PROP}: 100px; translate: none`;
|
|
21
|
+
document.body.innerHTML = ["moved", "still"]
|
|
22
|
+
.map((id) => `<div id="${id}" ${STUDIO_PATH_OFFSET_ATTR}="true" style="${offset}"></div>`)
|
|
23
|
+
.join("");
|
|
24
|
+
const moved = document.getElementById("moved") as HTMLElement;
|
|
25
|
+
const tween = { targets: () => [moved], vars: { keyframes: { x: [0, 200] } } };
|
|
26
|
+
const win = window as unknown as { __timelines?: unknown; gsap?: unknown };
|
|
27
|
+
const gsap = { set: vi.fn(), getProperty: () => 0 };
|
|
28
|
+
Object.assign(win, { __timelines: { main: { getChildren: () => [tween] } }, gsap });
|
|
29
|
+
try {
|
|
30
|
+
reapplyPositionEditsAfterSeek(document);
|
|
31
|
+
} finally {
|
|
32
|
+
delete win.__timelines;
|
|
33
|
+
delete win.gsap;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
expect(moved.style.getPropertyValue("translate")).toBe("none");
|
|
37
|
+
expect(gsap.set).not.toHaveBeenCalled();
|
|
38
|
+
const still = document.getElementById("still") as HTMLElement;
|
|
39
|
+
expect(still.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
40
|
+
});
|
|
41
|
+
|
|
17
42
|
it("does no per-edit work on a film Studio never edited", () => {
|
|
18
43
|
document.body.innerHTML = '<div id="a"></div><div id="b"></div>';
|
|
19
44
|
const queryAll = vi.spyOn(document, "querySelectorAll");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
1
2
|
// @vitest-environment happy-dom
|
|
2
3
|
import React, { act } from "react";
|
|
3
4
|
import { createRoot } from "react-dom/client";
|
|
@@ -423,3 +424,39 @@ describe("useDomEditNudge — a commit that throws", () => {
|
|
|
423
424
|
act(() => root.unmount());
|
|
424
425
|
});
|
|
425
426
|
});
|
|
427
|
+
|
|
428
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
429
|
+
describe("nudge usage", () => {
|
|
430
|
+
beforeEach(() => {
|
|
431
|
+
vi.clearAllMocks();
|
|
432
|
+
vi.useFakeTimers();
|
|
433
|
+
__resetForTests();
|
|
434
|
+
});
|
|
435
|
+
afterEach(() => vi.useRealTimers());
|
|
436
|
+
it.each([true, false])(
|
|
437
|
+
"counts a whole key burst only when the writer changed source (%s)",
|
|
438
|
+
async (changed) => {
|
|
439
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
440
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
441
|
+
const commit = vi.fn().mockResolvedValue({ ok: true, changed });
|
|
442
|
+
act(() =>
|
|
443
|
+
root.render(
|
|
444
|
+
<Harness selection={makeSelection("Dot", element)} onPathOffsetCommit={commit} />,
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
act(() => {
|
|
448
|
+
dispatchArrowRight();
|
|
449
|
+
dispatchArrowRight();
|
|
450
|
+
});
|
|
451
|
+
await act(async () => vi.advanceTimersByTimeAsync(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
|
|
452
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
453
|
+
expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
|
|
454
|
+
changed
|
|
455
|
+
? [["feature_used", { feature: "nudge", surface: "preview", method: "keyboard" }]]
|
|
456
|
+
: [],
|
|
457
|
+
);
|
|
458
|
+
act(() => root.unmount());
|
|
459
|
+
element.remove();
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
1
2
|
/**
|
|
2
3
|
* Canvas arrow-key nudge for DomEditOverlay: arrows move the selected
|
|
3
4
|
* element(s) 1 composition px, Shift = 10. Each keypress previews through the
|
|
@@ -173,6 +174,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
173
174
|
for (const member of session.members)
|
|
174
175
|
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
175
176
|
saved = Promise.resolve(commit)
|
|
177
|
+
.then((result) => trackPreviewEditResult("nudge", "keyboard", result))
|
|
176
178
|
.catch(() => {
|
|
177
179
|
for (const member of session.members) {
|
|
178
180
|
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
1
2
|
// fallow-ignore-file code-duplication
|
|
2
3
|
/**
|
|
3
4
|
* Gesture handling for DomEditOverlay.
|
|
@@ -353,6 +354,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
353
354
|
const groupSaved = Promise.resolve(
|
|
354
355
|
groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
|
|
355
356
|
)
|
|
357
|
+
.then((result) => trackPreviewEditResult("move", "drag", result))
|
|
356
358
|
.catch(() => {
|
|
357
359
|
for (const member of groupG.members) {
|
|
358
360
|
if (
|
|
@@ -442,6 +444,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
442
444
|
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
|
|
443
445
|
const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
|
|
444
446
|
void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
|
|
447
|
+
.then((result) => trackPreviewEditResult("rotate", "drag", result))
|
|
445
448
|
.catch((error) => {
|
|
446
449
|
logGestureCommitFailure("rotate commit failed", error);
|
|
447
450
|
if (
|
|
@@ -488,6 +491,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
488
491
|
}),
|
|
489
492
|
),
|
|
490
493
|
)
|
|
494
|
+
.then((result) => trackPreviewEditResult("move", "drag", result))
|
|
491
495
|
.catch(() => {
|
|
492
496
|
if (
|
|
493
497
|
g.pathOffsetMember?.gestureToken &&
|
|
@@ -534,6 +538,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
534
538
|
void Promise.resolve(
|
|
535
539
|
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
|
|
536
540
|
)
|
|
541
|
+
.then((result) => trackPreviewEditResult("resize", "drag", result))
|
|
537
542
|
.catch((error) => {
|
|
538
543
|
logGestureCommitFailure("resize commit failed", error);
|
|
539
544
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
|
|
1
2
|
import { useCallback, useMemo } from "react";
|
|
2
3
|
import { useDomEditActionsContext } from "../../contexts/DomEditContext";
|
|
3
4
|
import { readHfId, type DomEditSelection } from "./domEditing";
|
|
@@ -156,8 +157,13 @@ export function useDomEditZOrder(): DomEditZOrder {
|
|
|
156
157
|
// One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
|
|
157
158
|
// no second gesture interleaves (see runZLaneGesture). A failed z commit has already
|
|
158
159
|
// toasted and rolled back, so the catch only keeps its rejection from going unhandled.
|
|
160
|
+
let changed = false;
|
|
159
161
|
runZLaneGesture({
|
|
160
|
-
commitZ: () =>
|
|
162
|
+
commitZ: async () => {
|
|
163
|
+
const result = await handleDomZIndexReorderCommit(entries, coalesceKey, action);
|
|
164
|
+
changed = result?.durable === true && result.changed;
|
|
165
|
+
return result;
|
|
166
|
+
},
|
|
161
167
|
mirror: () =>
|
|
162
168
|
mirrorZOrderToTimeline({
|
|
163
169
|
selectionKey: entries.find((e) => e.element === sel.element)?.key,
|
|
@@ -166,7 +172,11 @@ export function useDomEditZOrder(): DomEditZOrder {
|
|
|
166
172
|
sourceFile: sel.sourceFile,
|
|
167
173
|
coalesceKey,
|
|
168
174
|
}),
|
|
169
|
-
})
|
|
175
|
+
})
|
|
176
|
+
.then(() => {
|
|
177
|
+
if (changed) trackPreviewFeatureUsed("z_order", "button");
|
|
178
|
+
})
|
|
179
|
+
.catch(() => undefined);
|
|
170
180
|
return true;
|
|
171
181
|
},
|
|
172
182
|
[activeCompPath, handleDomZIndexReorderCommit, mirrorZOrderToTimeline, readOnly],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
|
2
|
+
import { trackKeyframeUsage } from "../../utils/keyframeUsage";
|
|
2
3
|
import { useCallback, useMemo } from "react";
|
|
3
4
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
5
|
import type { TimelineElement } from "../../player";
|
|
@@ -259,7 +260,12 @@ export function useTimelineEditCallbacks({
|
|
|
259
260
|
if (!selection) return;
|
|
260
261
|
// Serial: each removal rewrites the same source file, so dispatching
|
|
261
262
|
// them together would have the later writes read a pre-edit document.
|
|
262
|
-
|
|
263
|
+
let changed = false;
|
|
264
|
+
for (const anim of anims) {
|
|
265
|
+
const result = await handleGsapRemoveAllKeyframes(anim.id, selection, false);
|
|
266
|
+
changed = result || changed;
|
|
267
|
+
}
|
|
268
|
+
if (changed) trackKeyframeUsage("remove_all");
|
|
263
269
|
});
|
|
264
270
|
},
|
|
265
271
|
onDeleteKeyframe: (elId, keyframe) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { filename } from "./assetHelpers";
|
|
3
|
+
import { menuClasses } from "../ui/menuStyle";
|
|
3
4
|
|
|
4
5
|
/** Reject names that would escape the asset directory or break paths. */
|
|
5
6
|
function isValidAssetName(name: string): boolean {
|
|
@@ -94,8 +95,7 @@ export function ContextMenu({
|
|
|
94
95
|
onClose();
|
|
95
96
|
}, [renameDraft, asset, onRename, onClose]);
|
|
96
97
|
|
|
97
|
-
const itemCls =
|
|
98
|
-
"w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden active:bg-neutral-700/70 transition-colors";
|
|
98
|
+
const itemCls = `${menuClasses.row} ${menuClasses.rowEnabled} active:bg-neutral-700/70 transition-colors`;
|
|
99
99
|
|
|
100
100
|
return (
|
|
101
101
|
<div
|
|
@@ -110,7 +110,7 @@ export function ContextMenu({
|
|
|
110
110
|
ref={menuRef}
|
|
111
111
|
role="menu"
|
|
112
112
|
aria-label={`Actions for ${filename(asset)}`}
|
|
113
|
-
className=
|
|
113
|
+
className={`${menuClasses.panel} absolute min-w-[160px] text-xs`}
|
|
114
114
|
style={{ left: pos.x, top: pos.y }}
|
|
115
115
|
onClick={(e) => e.stopPropagation()}
|
|
116
116
|
>
|
|
@@ -147,7 +147,7 @@ export function ContextMenu({
|
|
|
147
147
|
<button
|
|
148
148
|
role="menuitem"
|
|
149
149
|
onClick={() => setMode("confirm-delete")}
|
|
150
|
-
className={`${
|
|
150
|
+
className={`${menuClasses.row} ${menuClasses.rowDanger} active:bg-neutral-700/70 transition-colors`}
|
|
151
151
|
>
|
|
152
152
|
Delete
|
|
153
153
|
</button>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { menuClasses } from "./menuStyle";
|
|
5
|
+
|
|
6
|
+
const spacing = /(^| )-?(p|py|pt|pb|m|my|mt|mb)-\d/;
|
|
7
|
+
const root = join(__dirname, "../..");
|
|
8
|
+
|
|
9
|
+
/** Panels that are not a column of rows: a dialog, a crop toolbar, a preset card and a bare speed list. */
|
|
10
|
+
const NOT_ROW_MENUS = new Set([
|
|
11
|
+
"player/components/AudioGainDialog.tsx",
|
|
12
|
+
"components/editor/CropPresetBar.tsx",
|
|
13
|
+
"components/editor/EaseCurveSection.tsx",
|
|
14
|
+
"player/components/SpeedMenu.tsx",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function sources(dir: string): string[] {
|
|
18
|
+
return readdirSync(dir).flatMap((name) => {
|
|
19
|
+
const path = join(dir, name);
|
|
20
|
+
if (statSync(path).isDirectory()) return sources(path);
|
|
21
|
+
return /\.tsx$/.test(name) && !/\.test\./.test(name) ? [path] : [];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const files = sources(root).map((path) => ({
|
|
26
|
+
name: relative(root, path).replaceAll("\\", "/"),
|
|
27
|
+
text: readFileSync(path, "utf8"),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
describe("menu style", () => {
|
|
31
|
+
it("adds no padding or margin around a menu's rows", () => {
|
|
32
|
+
for (const key of ["panel", "group", "divider"] as const)
|
|
33
|
+
expect(menuClasses[key], key).not.toMatch(spacing);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clips the panel to its own rounded corners, so a highlight never pokes out of them", () => {
|
|
37
|
+
expect(menuClasses.panel).toContain("overflow-hidden");
|
|
38
|
+
expect(menuClasses.panel).toMatch(/(^| )rounded-/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("is the only place a menu panel's fill, border and shadow are written", () => {
|
|
42
|
+
const handBuilt = files
|
|
43
|
+
.filter(({ name }) => !NOT_ROW_MENUS.has(name))
|
|
44
|
+
.filter(({ text }) =>
|
|
45
|
+
[...text.matchAll(/["'`]([^"'`]*)["'`]/g)].some(
|
|
46
|
+
([, literal]) =>
|
|
47
|
+
/shadow-(lg|xl)/.test(literal!) && /bg-(neutral-900|panel-bg-2)\b/.test(literal!),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
.map(({ name }) => name);
|
|
51
|
+
expect(handBuilt).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("is the panel of every role=menu, wherever its element is written", () => {
|
|
55
|
+
const unowned = files
|
|
56
|
+
.filter(({ text }) => text.includes('role="menu"'))
|
|
57
|
+
.filter(({ name, text }) => !NOT_ROW_MENUS.has(name) && !text.includes("menuClasses.panel"))
|
|
58
|
+
.map(({ name }) => name);
|
|
59
|
+
expect(unowned).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The one owner of context menu spacing: rows touch the panel, which clips to its rounded corners. */
|
|
2
|
+
export const menuClasses = {
|
|
3
|
+
panel: "overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 shadow-lg",
|
|
4
|
+
group: "empty:hidden border-b border-neutral-700/60",
|
|
5
|
+
divider: "border-t border-neutral-700/60",
|
|
6
|
+
row: "w-full px-3 py-1.5 text-left text-xs outline-hidden",
|
|
7
|
+
rowEnabled: "text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer",
|
|
8
|
+
rowDanger: "text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 cursor-pointer",
|
|
9
|
+
rowDisabled: "text-neutral-600 cursor-not-allowed",
|
|
10
|
+
} as const;
|
|
@@ -251,7 +251,7 @@ export function DomEditProvider({
|
|
|
251
251
|
commitMutationRef.current = commitMutation;
|
|
252
252
|
|
|
253
253
|
const stableCommitMutation = useCallback<DomEditActionsValue["commitMutation"]>(
|
|
254
|
-
(mutation, options) => commitMutationRef.current(mutation, options),
|
|
254
|
+
(mutation, options, selection) => commitMutationRef.current(mutation, options, selection),
|
|
255
255
|
[],
|
|
256
256
|
);
|
|
257
257
|
|