@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
package/src/index.ts
CHANGED
|
@@ -1,35 +1,5 @@
|
|
|
1
1
|
// UI primitives
|
|
2
|
-
export
|
|
3
|
-
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
|
|
4
|
-
export { HyperframesLogo } from "./components/StudioHeader";
|
|
5
|
-
export { IconButton } from "./components/ui/IconButton";
|
|
6
|
-
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
|
|
7
|
-
export { HyperframesLoader } from "./components/ui/HyperframesLoader";
|
|
8
|
-
export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
|
|
9
|
-
export { Tooltip } from "./components/ui/Tooltip";
|
|
10
|
-
export { cn } from "./components/ui/cn";
|
|
11
|
-
export {
|
|
12
|
-
ContextMenu,
|
|
13
|
-
Menu,
|
|
14
|
-
MenuItem,
|
|
15
|
-
MenuRadioGroup,
|
|
16
|
-
MenuRadioItem,
|
|
17
|
-
MenuSeparator,
|
|
18
|
-
MenuShortcut,
|
|
19
|
-
popupSurface,
|
|
20
|
-
} from "./components/ui/Menu";
|
|
21
|
-
export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
|
|
22
|
-
export { Popover } from "./components/ui/Popover";
|
|
23
|
-
export { Input, fieldBase, fieldText } from "./components/ui/Input";
|
|
24
|
-
export type { InputProps } from "./components/ui/Input";
|
|
25
|
-
export { NumberField } from "./components/ui/NumberField";
|
|
26
|
-
export type { NumberFieldProps } from "./components/ui/NumberField";
|
|
27
|
-
export { Select } from "./components/ui/Select";
|
|
28
|
-
export type { SelectOption, SelectProps } from "./components/ui/Select";
|
|
29
|
-
export { Slider } from "./components/ui/Slider";
|
|
30
|
-
export type { SliderProps } from "./components/ui/Slider";
|
|
31
|
-
export { Toggle } from "./components/ui/Toggle";
|
|
32
|
-
export type { ToggleProps } from "./components/ui/Toggle";
|
|
2
|
+
export * from "./ui";
|
|
33
3
|
|
|
34
4
|
// NLE Layout
|
|
35
5
|
export { EditorShell } from "./components/EditorShell";
|
|
@@ -154,7 +124,10 @@ export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/h
|
|
|
154
124
|
export { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
155
125
|
export type { UsePersistentEditHistoryOptions } from "./hooks/usePersistentEditHistory";
|
|
156
126
|
export { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
157
|
-
export type {
|
|
127
|
+
export type {
|
|
128
|
+
TimelineZIndexReorderCommit,
|
|
129
|
+
UseTimelineEditingOptions,
|
|
130
|
+
} from "./hooks/useTimelineEditingTypes";
|
|
158
131
|
// A host's own waitForPendingDomEditSaves must also call this, or undo/redo
|
|
159
132
|
// can race a write still in flight (see useTrackPendingTimelineEdit.ts).
|
|
160
133
|
export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
|
|
@@ -184,6 +157,25 @@ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
|
|
|
184
157
|
export { TimelineEditProvider } from "./contexts/TimelineEditContext";
|
|
185
158
|
export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
|
|
186
159
|
export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
|
|
160
|
+
export { TimelineToolbar } from "./components/TimelineToolbar";
|
|
161
|
+
export type { TimelineToolbarProps } from "./components/TimelineToolbar";
|
|
162
|
+
export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
|
|
163
|
+
export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
|
|
164
|
+
export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
|
|
165
|
+
export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
|
|
166
|
+
export { useClipboard } from "./hooks/useClipboard";
|
|
167
|
+
export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
168
|
+
|
|
169
|
+
// DOM editing: the canvas session, persistence and overlay, for a host outside EditorShell.
|
|
170
|
+
export { useDomEditSession } from "./hooks/useDomEditSession";
|
|
171
|
+
export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
|
|
172
|
+
export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
173
|
+
export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
|
|
174
|
+
export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
|
|
175
|
+
export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
|
|
176
|
+
export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
|
|
177
|
+
export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
|
|
178
|
+
export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
|
|
187
179
|
|
|
188
180
|
export {
|
|
189
181
|
playSeamTransitionLoop,
|
|
@@ -191,3 +183,32 @@ export {
|
|
|
191
183
|
type SeamTransitionLoopHandle,
|
|
192
184
|
type SeamTransitionLoopOptions,
|
|
193
185
|
} from "@hyperframes/shader-transitions";
|
|
186
|
+
|
|
187
|
+
// Editor gestures for a host's own preview overlay: snapping and the marquee
|
|
188
|
+
export {
|
|
189
|
+
SNAP_THRESHOLD_PX,
|
|
190
|
+
snapEngagedForTravel,
|
|
191
|
+
extractSnapTargets,
|
|
192
|
+
buildCompositionSnapTarget,
|
|
193
|
+
buildGridSnapEdges,
|
|
194
|
+
resolveSnapAdjustment,
|
|
195
|
+
resolveGuideLineRect,
|
|
196
|
+
resolveEquidistanceGuides,
|
|
197
|
+
} from "./components/editor/snapEngine";
|
|
198
|
+
export type {
|
|
199
|
+
SnapEdge,
|
|
200
|
+
SnapTarget,
|
|
201
|
+
SnapGuide,
|
|
202
|
+
SpacingGuide,
|
|
203
|
+
SnapResult,
|
|
204
|
+
Rect as SnapRect,
|
|
205
|
+
} from "./components/editor/snapEngine";
|
|
206
|
+
export { collectSnapContext } from "./components/editor/snapTargetCollection";
|
|
207
|
+
export type { SnapContext } from "./components/editor/snapTargetCollection";
|
|
208
|
+
export { SnapGuideOverlay } from "./components/editor/SnapGuideOverlay";
|
|
209
|
+
export type { SnapGuideOverlayProps, SnapGuidesState } from "./components/editor/SnapGuideOverlay";
|
|
210
|
+
export { useMarqueeGestures } from "./components/editor/marqueeCommit";
|
|
211
|
+
export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/marqueeCommit";
|
|
212
|
+
export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
|
|
213
|
+
export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
|
|
214
|
+
export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const packageDir = resolve(import.meta.dirname, "..");
|
|
7
|
+
const { subpaths } = JSON.parse(readFileSync(resolve(packageDir, "package-subpaths.json"), "utf8"));
|
|
8
|
+
|
|
9
|
+
function refuse(name: string): never {
|
|
10
|
+
throw new Error(`a light entry loaded ${name}`);
|
|
11
|
+
}
|
|
12
|
+
vi.mock("@hyperframes/core", () => refuse("@hyperframes/core"));
|
|
13
|
+
vi.mock("@hyperframes/shader-transitions", () => refuse("@hyperframes/shader-transitions"));
|
|
14
|
+
vi.mock("@codemirror/view", () => refuse("@codemirror/view"));
|
|
15
|
+
vi.mock("@codemirror/state", () => refuse("@codemirror/state"));
|
|
16
|
+
vi.mock("mediabunny", () => refuse("mediabunny"));
|
|
17
|
+
vi.mock("gsap", () => refuse("gsap"));
|
|
18
|
+
vi.mock("@hyperframes/parsers", () => refuse("@hyperframes/parsers"));
|
|
19
|
+
vi.mock("@hyperframes/parsers/hf-ids", () => refuse("@hyperframes/parsers/hf-ids"));
|
|
20
|
+
vi.mock("@hyperframes/sdk", () => refuse("@hyperframes/sdk"));
|
|
21
|
+
vi.mock("dockview-react", () => refuse("dockview-react"));
|
|
22
|
+
|
|
23
|
+
describe("entries a host imports for its first screen", () => {
|
|
24
|
+
it.each(["./ui", "./player"])(
|
|
25
|
+
"%s loads without the editor, the parsers, gsap or media decoding",
|
|
26
|
+
async (entry) => {
|
|
27
|
+
await expect(import(resolve(packageDir, subpaths[entry].source))).resolves.toBeDefined();
|
|
28
|
+
},
|
|
29
|
+
// The icon set alone is thousands of modules to transform on a cold run.
|
|
30
|
+
30_000,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
|
|
6
7
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
7
8
|
import { buildCompositionThumbnailUrl, CompositionThumbnail } from "./CompositionThumbnail";
|
|
8
9
|
|
|
@@ -11,12 +12,6 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
|
11
12
|
value: true,
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
class MockResizeObserver {
|
|
15
|
-
observe() {}
|
|
16
|
-
disconnect() {}
|
|
17
|
-
unobserve() {}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
15
|
class MockImage {
|
|
21
16
|
static instances: MockImage[] = [];
|
|
22
17
|
onload: (() => void) | null = null;
|
|
@@ -146,6 +141,45 @@ describe("CompositionThumbnail", () => {
|
|
|
146
141
|
);
|
|
147
142
|
});
|
|
148
143
|
|
|
144
|
+
it.each([
|
|
145
|
+
{ name: "a wide", width: 2700, height: 1000, tileWidth: 108 },
|
|
146
|
+
{ name: "a square", width: 1000, height: 1000, tileWidth: 48 },
|
|
147
|
+
{ name: "a portrait", width: 1080, height: 1920, tileWidth: 48 },
|
|
148
|
+
])(
|
|
149
|
+
"shows $name picture whole at the clip's measured height",
|
|
150
|
+
async ({ width, height, tileWidth }) => {
|
|
151
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, value: 500 });
|
|
152
|
+
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
|
|
153
|
+
const probe = await renderThumbnail();
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
probe.naturalWidth = width;
|
|
157
|
+
probe.naturalHeight = height;
|
|
158
|
+
probe.onload?.();
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const img = host.querySelector("img")!;
|
|
163
|
+
expect(img.parentElement?.style.width).toBe(`${tileWidth}px`);
|
|
164
|
+
// A tile held at its minimum width letterboxes the picture instead of cropping it.
|
|
165
|
+
expect(img.classList.contains("object-contain")).toBe(true);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
it("re-tiles at the height the resize observer reports", async () => {
|
|
170
|
+
const probe = await renderThumbnail();
|
|
171
|
+
await act(async () => {
|
|
172
|
+
probe.naturalWidth = 2700;
|
|
173
|
+
probe.naturalHeight = 1000;
|
|
174
|
+
probe.onload?.();
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
act(() => reportResize(500, 40));
|
|
179
|
+
|
|
180
|
+
expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
|
|
181
|
+
});
|
|
182
|
+
|
|
149
183
|
it("aborts its scheduled off-DOM image probe when unmounted", async () => {
|
|
150
184
|
const probe = await renderThumbnail();
|
|
151
185
|
expect(host.querySelector("img")).toBeNull();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { memo,
|
|
2
|
-
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
1
|
+
import { memo, useMemo, type CSSProperties } from "react";
|
|
3
2
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
3
|
+
import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
5
|
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
|
6
6
|
import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
|
|
@@ -22,7 +22,6 @@ interface CompositionThumbnailProps {
|
|
|
22
22
|
rich?: boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const CLIP_HEIGHT = 66;
|
|
26
25
|
const THUMBNAIL_URL_VERSION = "v3";
|
|
27
26
|
export const THUMBNAIL_SEEK_TIME_SECONDS = 3;
|
|
28
27
|
|
|
@@ -122,8 +121,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
122
121
|
contentRevision = 0,
|
|
123
122
|
priority = "visible",
|
|
124
123
|
}: CompositionThumbnailProps) {
|
|
125
|
-
const [
|
|
126
|
-
const observerRef = useRef<ResizeObserver | null>(null);
|
|
124
|
+
const [container, setContainerRef] = useThumbnailStripSize();
|
|
127
125
|
const url = buildCompositionThumbnailUrl({
|
|
128
126
|
previewUrl,
|
|
129
127
|
seekTime,
|
|
@@ -149,25 +147,12 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
149
147
|
const value =
|
|
150
148
|
snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value : null;
|
|
151
149
|
const { frameW, frameCount } = computeThumbnailStrip(
|
|
152
|
-
|
|
150
|
+
container.width,
|
|
153
151
|
value?.aspect ?? 16 / 9,
|
|
154
|
-
|
|
152
|
+
container.height,
|
|
155
153
|
48,
|
|
156
154
|
);
|
|
157
155
|
|
|
158
|
-
const setContainerRef = useCallback((element: HTMLDivElement | null) => {
|
|
159
|
-
observerRef.current?.disconnect();
|
|
160
|
-
if (!element) return;
|
|
161
|
-
const target = element.parentElement ?? element;
|
|
162
|
-
setContainerWidth(target.clientWidth);
|
|
163
|
-
observerRef.current = new ResizeObserver(([entry]) =>
|
|
164
|
-
setContainerWidth(entry.contentRect.width),
|
|
165
|
-
);
|
|
166
|
-
observerRef.current.observe(target);
|
|
167
|
-
}, []);
|
|
168
|
-
|
|
169
|
-
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
170
|
-
|
|
171
156
|
return (
|
|
172
157
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
173
158
|
{value && (
|
|
@@ -189,7 +174,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
189
174
|
src={value.url}
|
|
190
175
|
alt=""
|
|
191
176
|
draggable={false}
|
|
192
|
-
className="absolute inset-0 h-full w-full object-
|
|
177
|
+
className="absolute inset-0 h-full w-full object-contain"
|
|
193
178
|
style={{ opacity: "var(--timeline-composition-thumbnail-opacity)" }}
|
|
194
179
|
/>
|
|
195
180
|
</div>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
|
|
6
7
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
7
8
|
import { ImageThumbnail } from "./ImageThumbnail";
|
|
8
9
|
|
|
@@ -30,12 +31,6 @@ class MockIntersectionObserver {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
class MockResizeObserver {
|
|
34
|
-
observe() {}
|
|
35
|
-
disconnect() {}
|
|
36
|
-
unobserve() {}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
34
|
// --- Image stub: captures instances so tests fire load/error deterministically ---
|
|
40
35
|
class MockImage {
|
|
41
36
|
static instances: MockImage[] = [];
|
|
@@ -171,6 +166,32 @@ describe("ImageThumbnail", () => {
|
|
|
171
166
|
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
172
167
|
});
|
|
173
168
|
|
|
169
|
+
it("tiles a wide picture at the clip's measured height, whole", async () => {
|
|
170
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, value: 500 });
|
|
171
|
+
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
|
|
172
|
+
render({ imageSrc: "/api/projects/p/preview/assets/wide.png" });
|
|
173
|
+
await resolveProbe((probe) => {
|
|
174
|
+
probe.naturalWidth = 2700;
|
|
175
|
+
probe.naturalHeight = 1000;
|
|
176
|
+
probe.onload?.();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("re-tiles at the height the resize observer reports", async () => {
|
|
183
|
+
render({ imageSrc: "/api/projects/p/preview/assets/wide.png" });
|
|
184
|
+
await resolveProbe((probe) => {
|
|
185
|
+
probe.naturalWidth = 2700;
|
|
186
|
+
probe.naturalHeight = 1000;
|
|
187
|
+
probe.onload?.();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
act(() => reportResize(500, 40));
|
|
191
|
+
|
|
192
|
+
expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
|
|
193
|
+
});
|
|
194
|
+
|
|
174
195
|
it("renders the label above the strip when provided", async () => {
|
|
175
196
|
render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
|
|
176
197
|
await resolveProbe((probe) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { memo,
|
|
2
|
-
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
1
|
+
import { memo, useMemo } from "react";
|
|
3
2
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
3
|
+
import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
5
|
import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder";
|
|
6
6
|
import { computeThumbnailStrip } from "./thumbnailUtils";
|
|
@@ -25,8 +25,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
25
25
|
priority = "visible",
|
|
26
26
|
rich = false,
|
|
27
27
|
}: ImageThumbnailProps) {
|
|
28
|
-
const [
|
|
29
|
-
const observerRef = useRef<ResizeObserver | null>(null);
|
|
28
|
+
const [container, setContainerRef] = useThumbnailStripSize();
|
|
30
29
|
const request = useMemo(
|
|
31
30
|
() => ({
|
|
32
31
|
key: createThumbnailKey({ kind: "image", source: imageSrc, rich: Number(rich) }),
|
|
@@ -42,20 +41,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
42
41
|
const snapshot = useThumbnailLease(request);
|
|
43
42
|
const value = snapshot.status === "ready" ? snapshot.value : null;
|
|
44
43
|
const aspect = value?.kind === "image" ? value.aspect : 16 / 9;
|
|
45
|
-
const { frameW, frameCount } = computeThumbnailStrip(
|
|
46
|
-
|
|
47
|
-
const setContainerRef = useCallback((element: HTMLDivElement | null) => {
|
|
48
|
-
observerRef.current?.disconnect();
|
|
49
|
-
if (!element) return;
|
|
50
|
-
const target = element.parentElement ?? element;
|
|
51
|
-
setContainerWidth(target.clientWidth);
|
|
52
|
-
observerRef.current = new ResizeObserver(([entry]) =>
|
|
53
|
-
setContainerWidth(entry.contentRect.width),
|
|
54
|
-
);
|
|
55
|
-
observerRef.current.observe(target);
|
|
56
|
-
}, []);
|
|
57
|
-
|
|
58
|
-
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
44
|
+
const { frameW, frameCount } = computeThumbnailStrip(container.width, aspect, container.height);
|
|
59
45
|
|
|
60
46
|
return (
|
|
61
47
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { PlayerControls } from "./PlayerControls";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
|
|
11
|
+
let host: HTMLDivElement;
|
|
12
|
+
let root: Root;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
usePlayerStore.setState({
|
|
16
|
+
isPlaying: true,
|
|
17
|
+
currentTime: 2,
|
|
18
|
+
duration: 30,
|
|
19
|
+
timeDisplayMode: "time",
|
|
20
|
+
});
|
|
21
|
+
host = document.createElement("div");
|
|
22
|
+
document.body.append(host);
|
|
23
|
+
root = createRoot(host);
|
|
24
|
+
await act(async () =>
|
|
25
|
+
root.render(React.createElement(PlayerControls, { onTogglePlay: () => {}, onSeek: () => {} })),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await act(async () => root.unmount());
|
|
31
|
+
host.remove();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("PlayerControls timecode", () => {
|
|
35
|
+
it("rewrites the timecode only when the shown text changes", async () => {
|
|
36
|
+
const timecode = host.querySelector("button.font-mono > span");
|
|
37
|
+
expect(timecode?.textContent).toBe("00:02");
|
|
38
|
+
const writes: MutationRecord[] = [];
|
|
39
|
+
const observer = new MutationObserver((records) => writes.push(...records));
|
|
40
|
+
observer.observe(timecode as Node, { childList: true, characterData: true, subtree: true });
|
|
41
|
+
|
|
42
|
+
for (let frame = 1; frame <= 30; frame++) liveTime.notify(2 + frame / 120);
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
expect(writes).toHaveLength(0);
|
|
45
|
+
|
|
46
|
+
liveTime.notify(3.01);
|
|
47
|
+
await Promise.resolve();
|
|
48
|
+
expect(timecode?.textContent).toBe("00:03");
|
|
49
|
+
expect(writes.length).toBeGreaterThan(0);
|
|
50
|
+
observer.disconnect();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -213,10 +213,11 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
213
213
|
currentTimeRef.current = time;
|
|
214
214
|
if (!timeDisplayRef.current) return;
|
|
215
215
|
const currentDuration = durationRef.current;
|
|
216
|
-
|
|
216
|
+
const text =
|
|
217
217
|
timeDisplayModeRef.current === "frame"
|
|
218
218
|
? formatFrameTime(time, currentDuration)
|
|
219
219
|
: formatTime(time);
|
|
220
|
+
if (timeDisplayRef.current.textContent !== text) timeDisplayRef.current.textContent = text;
|
|
220
221
|
};
|
|
221
222
|
const unsubscribe = liveTime.subscribe(updateTime);
|
|
222
223
|
updateTime(usePlayerStore.getState().currentTime);
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "./timelineLayout";
|
|
40
40
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
41
41
|
import { formatTime } from "../lib/time";
|
|
42
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
42
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
43
43
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
44
44
|
|
|
45
45
|
vi.mock("./timelineRowVirtualizationFlag", () => ({
|
|
@@ -153,6 +153,27 @@ function renderSharedAutomationTimeline(selectedElementId?: string) {
|
|
|
153
153
|
return { host, root };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
describe("Timeline playhead motion", () => {
|
|
157
|
+
it("moves by fractional pixels while playing and snaps to device pixels once paused", () => {
|
|
158
|
+
usePlayerStore.setState({
|
|
159
|
+
duration: 11,
|
|
160
|
+
timelineReady: true,
|
|
161
|
+
currentTime: 10,
|
|
162
|
+
isPlaying: true,
|
|
163
|
+
zoomMode: "manual",
|
|
164
|
+
manualZoomPercent: 100,
|
|
165
|
+
elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }],
|
|
166
|
+
});
|
|
167
|
+
const { root, playhead } = renderTimelineGeometry("clip-1");
|
|
168
|
+
const wrapperLeft = GUTTER + TRACKS_LEFT_PAD + 1000.3 - PLAYHEAD_HEAD_W / 2;
|
|
169
|
+
act(() => liveTime.notify(10.003));
|
|
170
|
+
expect(playhead.style.transform).toBe(`translateX(${wrapperLeft}px)`);
|
|
171
|
+
act(() => usePlayerStore.setState({ isPlaying: false }));
|
|
172
|
+
expect(playhead.style.transform).toBe(`translateX(${Math.round(wrapperLeft)}px)`);
|
|
173
|
+
act(() => root.unmount());
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
156
177
|
describe("Timeline provider boundary", () => {
|
|
157
178
|
it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
|
|
158
179
|
usePlayerStore.setState({
|
|
@@ -173,7 +194,9 @@ describe("Timeline provider boundary", () => {
|
|
|
173
194
|
expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
|
|
174
195
|
expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
|
175
196
|
expect(rulerTick.style.left).toBe("999.5px");
|
|
176
|
-
expect(playhead.style.
|
|
197
|
+
expect(playhead.style.transform).toBe(
|
|
198
|
+
`translateX(${Math.round(GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2)}px)`,
|
|
199
|
+
);
|
|
177
200
|
expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
|
|
178
201
|
expect(
|
|
179
202
|
resolveTimelineAssetDrop(
|
|
@@ -283,7 +306,9 @@ describe("Timeline provider boundary", () => {
|
|
|
283
306
|
expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
|
|
284
307
|
expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
|
|
285
308
|
expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
|
|
286
|
-
expect(playhead.style.
|
|
309
|
+
expect(playhead.style.transform).toBe(
|
|
310
|
+
`translateX(${Math.round(LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2)}px)`,
|
|
311
|
+
);
|
|
287
312
|
expect(diamondX).toBe(rulerX);
|
|
288
313
|
expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
|
|
289
314
|
expect(collapsedHeader.textContent).toContain("Outro");
|
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
TRACKS_BOTTOM_PAD,
|
|
10
10
|
TRACK_H,
|
|
11
11
|
PLAYHEAD_HEAD_W,
|
|
12
|
-
getTimelinePlayheadLeft,
|
|
13
12
|
getTimelineRowTop,
|
|
14
13
|
getTimelineRowHeight,
|
|
15
14
|
} from "./timelineLayout";
|
|
15
|
+
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
|
|
16
16
|
import { TimelineLanes } from "./TimelineLanes";
|
|
17
17
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
18
18
|
import { useTimelineContext } from "./TimelineProvider";
|
|
@@ -234,7 +234,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
234
234
|
|
|
235
235
|
{/* Playhead — hidden while dragging a beat so its guideline doesn't
|
|
236
236
|
track the scrub and clutter the beat being moved. Explicit width +
|
|
237
|
-
the half-head offset baked into
|
|
237
|
+
the half-head offset baked into getTimelinePlayheadTransform keep the
|
|
238
238
|
inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
|
|
239
239
|
ticks' center), instead of relying on shrink-wrap sizing. */}
|
|
240
240
|
<TimelinePlayheadLayer scrollRef={props.scrollRef} contentOrigin={props.contentOrigin}>
|
|
@@ -242,7 +242,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
242
242
|
ref={props.playheadRef}
|
|
243
243
|
className="absolute top-0 bottom-0 pointer-events-none"
|
|
244
244
|
style={{
|
|
245
|
-
left:
|
|
245
|
+
left: 0,
|
|
246
|
+
transform: getTimelinePlayheadTransform(0, 0, props.contentOrigin, true),
|
|
247
|
+
willChange: "transform",
|
|
246
248
|
width: PLAYHEAD_HEAD_W,
|
|
247
249
|
display: beatDragging ? "none" : undefined,
|
|
248
250
|
}}
|
|
@@ -71,8 +71,8 @@ function render(
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function pointer(type: string, clientX: number, pointerId = 1) {
|
|
75
|
-
const event = new MouseEvent(type, { bubbles: true, clientX, button: 0 });
|
|
74
|
+
function pointer(type: string, clientX: number, pointerId = 1, clientY = 0) {
|
|
75
|
+
const event = new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 });
|
|
76
76
|
Object.defineProperty(event, "pointerId", { value: pointerId });
|
|
77
77
|
return event;
|
|
78
78
|
}
|
|
@@ -134,14 +134,14 @@ describe("TimelineClipFades", () => {
|
|
|
134
134
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
135
135
|
if (!handle) throw new Error("expected a fade-out handle");
|
|
136
136
|
armCapture(handle);
|
|
137
|
-
//
|
|
138
|
-
act(() => handle.dispatchEvent(pointer("pointerdown",
|
|
139
|
-
act(() => handle.dispatchEvent(pointer("pointermove",
|
|
137
|
+
// Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
|
|
138
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
|
|
139
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 700)));
|
|
140
140
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
|
|
141
141
|
// Way past the start: clamps to duration − fadeIn = 9 s.
|
|
142
|
-
act(() => handle.dispatchEvent(pointer("pointermove",
|
|
142
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 0)));
|
|
143
143
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "9");
|
|
144
|
-
act(() => handle.dispatchEvent(pointer("pointerup",
|
|
144
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 0)));
|
|
145
145
|
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-out", "9", "Fade out");
|
|
146
146
|
act(() => root.unmount());
|
|
147
147
|
});
|
|
@@ -151,9 +151,9 @@ describe("TimelineClipFades", () => {
|
|
|
151
151
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
152
152
|
if (!handle) throw new Error("expected a fade-in handle");
|
|
153
153
|
armCapture(handle);
|
|
154
|
-
act(() => handle.dispatchEvent(pointer("pointerdown",
|
|
155
|
-
act(() => handle.dispatchEvent(pointer("pointermove",
|
|
156
|
-
act(() => handle.dispatchEvent(pointer("pointerup",
|
|
154
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 200)));
|
|
155
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 0)));
|
|
156
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 0)));
|
|
157
157
|
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
|
|
158
158
|
act(() => root.unmount());
|
|
159
159
|
});
|
|
@@ -179,6 +179,27 @@ describe("TimelineClipFades", () => {
|
|
|
179
179
|
act(() => root.unmount());
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
it.each([
|
|
183
|
+
["above the window", 300, -40],
|
|
184
|
+
["on the window's right edge", window.innerWidth, 10],
|
|
185
|
+
])("restores the fade and saves nothing when released %s", (_, clientX, clientY) => {
|
|
186
|
+
const { host, root, onSetElementAttributeLive, onRevertElementAttributeLive, ...rest } =
|
|
187
|
+
render(clip);
|
|
188
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
189
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
190
|
+
armCapture(handle);
|
|
191
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
192
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
193
|
+
act(() => handle.dispatchEvent(pointer("pointerup", clientX, 1, clientY)));
|
|
194
|
+
expect(rest.onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
195
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
|
|
196
|
+
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
|
|
197
|
+
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
|
|
198
|
+
"0,0 100,0 0,100",
|
|
199
|
+
);
|
|
200
|
+
act(() => root.unmount());
|
|
201
|
+
});
|
|
202
|
+
|
|
182
203
|
it("does not let a press on the dot start the clip's own move gesture", () => {
|
|
183
204
|
const outer = vi.fn();
|
|
184
205
|
const { host, root } = render(clip, { onClipPointerDown: outer });
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@hyperframes/core/audio-fade";
|
|
15
15
|
import type { TimelineElement } from "../store/playerStore";
|
|
16
16
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
|
+
import { releasedOutsideWindow } from "./timelinePointerRelease";
|
|
17
18
|
|
|
18
19
|
type FadeEdge = "in" | "out";
|
|
19
20
|
|
|
@@ -136,7 +137,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
136
137
|
const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
|
|
137
138
|
const g = endGesture(e);
|
|
138
139
|
if (!g) return;
|
|
139
|
-
if (cancelled || !g.moved) return revertGesture(g);
|
|
140
|
+
if (cancelled || !g.moved || releasedOutsideWindow(e)) return revertGesture(g);
|
|
140
141
|
setDraft({ edge: g.edge, seconds: g.last });
|
|
141
142
|
void onSetElementAttributeQuiet?.(
|
|
142
143
|
el,
|
|
@@ -4,18 +4,20 @@ import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
|
|
|
4
4
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
5
5
|
import type { TimelineTheme } from "./timelineTheme";
|
|
6
6
|
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
|
7
|
+
import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
|
|
7
8
|
|
|
8
9
|
export interface TimelineClipRenderContext {
|
|
9
10
|
priority: "overscan" | "visible" | "interaction";
|
|
10
11
|
rich: boolean;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export interface TimelineProps
|
|
14
|
+
export interface TimelineProps
|
|
15
|
+
extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
|
|
14
16
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
15
17
|
sessionEpoch?: number;
|
|
16
|
-
/** keepPlaying: true preserves the current play state across the seek. */
|
|
17
18
|
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
18
19
|
onDrillDown?: (element: TimelineElement) => void;
|
|
20
|
+
/** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
|
|
19
21
|
renderClipContent?: (
|
|
20
22
|
element: TimelineElement,
|
|
21
23
|
style: { clip: string; label: string },
|