@hyperframes/studio 0.7.54 → 0.7.56
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/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
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 { ImageThumbnail } from "./ImageThumbnail";
|
|
7
|
+
|
|
8
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
9
|
+
configurable: true,
|
|
10
|
+
value: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// --- Observer stubs: fire "intersecting" immediately on observe ---
|
|
14
|
+
class MockIntersectionObserver {
|
|
15
|
+
private cb: IntersectionObserverCallback;
|
|
16
|
+
constructor(cb: IntersectionObserverCallback) {
|
|
17
|
+
this.cb = cb;
|
|
18
|
+
}
|
|
19
|
+
observe() {
|
|
20
|
+
this.cb(
|
|
21
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
22
|
+
this as unknown as IntersectionObserver,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
disconnect() {}
|
|
26
|
+
unobserve() {}
|
|
27
|
+
takeRecords(): IntersectionObserverEntry[] {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class MockResizeObserver {
|
|
33
|
+
observe() {}
|
|
34
|
+
disconnect() {}
|
|
35
|
+
unobserve() {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Image stub: captures instances so tests fire load/error deterministically ---
|
|
39
|
+
class MockImage {
|
|
40
|
+
static instances: MockImage[] = [];
|
|
41
|
+
onload: (() => void) | null = null;
|
|
42
|
+
onerror: (() => void) | null = null;
|
|
43
|
+
naturalWidth = 0;
|
|
44
|
+
naturalHeight = 0;
|
|
45
|
+
src = "";
|
|
46
|
+
constructor() {
|
|
47
|
+
MockImage.instances.push(this);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const originalIO = globalThis.IntersectionObserver;
|
|
52
|
+
const originalRO = globalThis.ResizeObserver;
|
|
53
|
+
const originalImage = globalThis.Image;
|
|
54
|
+
|
|
55
|
+
let host: HTMLDivElement;
|
|
56
|
+
let root: Root | null = null;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
globalThis.IntersectionObserver =
|
|
60
|
+
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
61
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
62
|
+
globalThis.Image = MockImage as unknown as typeof Image;
|
|
63
|
+
MockImage.instances = [];
|
|
64
|
+
host = document.createElement("div");
|
|
65
|
+
document.body.append(host);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
act(() => root?.unmount());
|
|
70
|
+
root = null;
|
|
71
|
+
globalThis.IntersectionObserver = originalIO;
|
|
72
|
+
globalThis.ResizeObserver = originalRO;
|
|
73
|
+
globalThis.Image = originalImage;
|
|
74
|
+
document.body.innerHTML = "";
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function render(props: { imageSrc: string; label?: string; labelColor?: string }) {
|
|
78
|
+
root = createRoot(host);
|
|
79
|
+
act(() => {
|
|
80
|
+
root!.render(
|
|
81
|
+
<ImageThumbnail
|
|
82
|
+
imageSrc={props.imageSrc}
|
|
83
|
+
label={props.label ?? ""}
|
|
84
|
+
labelColor={props.labelColor ?? "#fff"}
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function lastProbe(): MockImage {
|
|
91
|
+
const probe = MockImage.instances.at(-1);
|
|
92
|
+
expect(probe).toBeDefined();
|
|
93
|
+
return probe!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
|
|
97
|
+
function expectFirstTileSrc(expectedSrc: string): void {
|
|
98
|
+
const imgs = [...host.querySelectorAll("img")];
|
|
99
|
+
expect(imgs.length).toBeGreaterThanOrEqual(1);
|
|
100
|
+
expect(imgs[0].getAttribute("src")).toBe(expectedSrc);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("ImageThumbnail", () => {
|
|
104
|
+
it("shows the loading shimmer before the image resolves", () => {
|
|
105
|
+
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
|
|
106
|
+
expect(host.querySelector(".animate-pulse")).not.toBeNull();
|
|
107
|
+
expect(host.querySelectorAll("img").length).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("probes the resolved src and renders repeated object-cover tiles on load", () => {
|
|
111
|
+
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
|
|
112
|
+
const probe = lastProbe();
|
|
113
|
+
expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");
|
|
114
|
+
|
|
115
|
+
act(() => {
|
|
116
|
+
probe.naturalWidth = 1920;
|
|
117
|
+
probe.naturalHeight = 1080;
|
|
118
|
+
probe.onload?.();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const imgs = [...host.querySelectorAll("img")];
|
|
122
|
+
expect(imgs.length).toBeGreaterThanOrEqual(1);
|
|
123
|
+
for (const img of imgs) {
|
|
124
|
+
expect(img.getAttribute("src")).toBe("/api/projects/p/preview/assets/pic.png");
|
|
125
|
+
expect(img.className).toContain("object-cover");
|
|
126
|
+
}
|
|
127
|
+
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
|
|
131
|
+
render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
|
|
132
|
+
act(() => lastProbe().onerror?.());
|
|
133
|
+
expect(host.querySelectorAll("img").length).toBe(0);
|
|
134
|
+
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
|
|
138
|
+
render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
|
|
139
|
+
const probe = lastProbe();
|
|
140
|
+
|
|
141
|
+
act(() => {
|
|
142
|
+
// naturalWidth stays 0 — SVG with no width/height attribute
|
|
143
|
+
probe.onload?.();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expectFirstTileSrc("/api/projects/p/preview/assets/logo.svg");
|
|
147
|
+
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
|
|
151
|
+
// Some browser/sandbox environments fire onerror for SVGs even though the
|
|
152
|
+
// <img> element itself can render the file — we must not blank the strip.
|
|
153
|
+
render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });
|
|
154
|
+
|
|
155
|
+
act(() => lastProbe().onerror?.());
|
|
156
|
+
|
|
157
|
+
expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
|
|
158
|
+
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("renders the label above the strip when provided", () => {
|
|
162
|
+
render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
|
|
163
|
+
act(() => {
|
|
164
|
+
const probe = lastProbe();
|
|
165
|
+
probe.naturalWidth = 100;
|
|
166
|
+
probe.naturalHeight = 100;
|
|
167
|
+
probe.onload?.();
|
|
168
|
+
});
|
|
169
|
+
const label = [...host.querySelectorAll("span")].find((s) => s.textContent === "hero");
|
|
170
|
+
expect(label).toBeDefined();
|
|
171
|
+
expect(label!.closest(".z-10")).not.toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import { computeThumbnailStrip } from "./thumbnailUtils";
|
|
4
|
+
|
|
5
|
+
interface ImageThumbnailProps {
|
|
6
|
+
imageSrc: string;
|
|
7
|
+
label: string;
|
|
8
|
+
labelColor: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renders a film-strip of a still image for a timeline clip. The image is a
|
|
13
|
+
* fixed-width tile (sized by its natural aspect ratio) repeated to fill the
|
|
14
|
+
* clip width — matching VideoThumbnail's visual pattern. Loading is lazy
|
|
15
|
+
* (IntersectionObserver) with the same shimmer fallback while decoding.
|
|
16
|
+
*/
|
|
17
|
+
export const ImageThumbnail = memo(function ImageThumbnail({
|
|
18
|
+
imageSrc,
|
|
19
|
+
label,
|
|
20
|
+
labelColor,
|
|
21
|
+
}: ImageThumbnailProps) {
|
|
22
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
23
|
+
const [visible, setVisible] = useState(false);
|
|
24
|
+
const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
|
|
25
|
+
const [aspect, setAspect] = useState(16 / 9);
|
|
26
|
+
const ioRef = useRef<IntersectionObserver | null>(null);
|
|
27
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
28
|
+
|
|
29
|
+
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
30
|
+
ioRef.current?.disconnect();
|
|
31
|
+
roRef.current?.disconnect();
|
|
32
|
+
if (!el) return;
|
|
33
|
+
|
|
34
|
+
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
|
35
|
+
setContainerWidth(measured);
|
|
36
|
+
|
|
37
|
+
ioRef.current = new IntersectionObserver(
|
|
38
|
+
([entry]) => {
|
|
39
|
+
if (entry.isIntersecting) {
|
|
40
|
+
setVisible(true);
|
|
41
|
+
ioRef.current?.disconnect();
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{ rootMargin: "200px" },
|
|
45
|
+
);
|
|
46
|
+
// fallow-ignore-next-line code-duplication
|
|
47
|
+
ioRef.current.observe(el);
|
|
48
|
+
|
|
49
|
+
const target = el.parentElement || el;
|
|
50
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
51
|
+
setContainerWidth(entry.contentRect.width);
|
|
52
|
+
});
|
|
53
|
+
roRef.current.observe(target);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useMountEffect(() => () => {
|
|
57
|
+
ioRef.current?.disconnect();
|
|
58
|
+
roRef.current?.disconnect();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Probe the image once visible — measures the natural aspect ratio so the
|
|
62
|
+
// tile width matches, and flips to the error state (plain clip background)
|
|
63
|
+
// if the src can't load. The browser cache makes the tile <img>s free.
|
|
64
|
+
//
|
|
65
|
+
// SVG handling: SVGs without intrinsic width/height report naturalWidth=0 on
|
|
66
|
+
// load (treat as success with the 16:9 default aspect) and may fire onerror
|
|
67
|
+
// in some environments even though the file is valid and can be displayed —
|
|
68
|
+
// fall back to loaded-at-16:9 rather than hiding the strip entirely.
|
|
69
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!visible) return;
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
setStatus("loading");
|
|
74
|
+
|
|
75
|
+
const isSvg = /\.svg($|\?)/i.test(imageSrc);
|
|
76
|
+
|
|
77
|
+
const probe = new Image();
|
|
78
|
+
probe.onload = () => {
|
|
79
|
+
if (cancelled) return;
|
|
80
|
+
if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
|
|
81
|
+
setAspect(probe.naturalWidth / probe.naturalHeight);
|
|
82
|
+
}
|
|
83
|
+
// naturalWidth===0 (e.g. SVG with no intrinsic dimensions) falls through
|
|
84
|
+
// to "loaded" with the default 16:9 aspect already set in state.
|
|
85
|
+
setStatus("loaded");
|
|
86
|
+
};
|
|
87
|
+
probe.onerror = () => {
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
// SVGs can fail the probe in certain browser/sandbox environments even
|
|
90
|
+
// though the <img> tiles themselves render fine (different security
|
|
91
|
+
// context). Show the strip at the 16:9 fallback rather than blanking.
|
|
92
|
+
if (isSvg) {
|
|
93
|
+
setStatus("loaded");
|
|
94
|
+
} else {
|
|
95
|
+
setStatus("error");
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
probe.src = imageSrc;
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
probe.onload = null;
|
|
103
|
+
probe.onerror = null;
|
|
104
|
+
probe.src = "";
|
|
105
|
+
};
|
|
106
|
+
}, [visible, imageSrc]);
|
|
107
|
+
|
|
108
|
+
const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
112
|
+
{visible && status === "loaded" && (
|
|
113
|
+
<div className="absolute inset-0 flex">
|
|
114
|
+
{Array.from({ length: frameCount }).map((_, i) => (
|
|
115
|
+
<div
|
|
116
|
+
key={i}
|
|
117
|
+
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
|
|
118
|
+
style={{ width: frameW }}
|
|
119
|
+
>
|
|
120
|
+
<img
|
|
121
|
+
src={imageSrc}
|
|
122
|
+
alt=""
|
|
123
|
+
draggable={false}
|
|
124
|
+
loading="lazy"
|
|
125
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{visible && status === "loading" && (
|
|
133
|
+
<div
|
|
134
|
+
className="absolute inset-0 animate-pulse"
|
|
135
|
+
style={{
|
|
136
|
+
background:
|
|
137
|
+
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{label && (
|
|
143
|
+
<div
|
|
144
|
+
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
145
|
+
style={{
|
|
146
|
+
background:
|
|
147
|
+
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<span
|
|
151
|
+
className="text-[9px] font-semibold truncate block leading-tight"
|
|
152
|
+
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
153
|
+
>
|
|
154
|
+
{label}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
@@ -2,20 +2,44 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Shared playhead visual used by TimelineCanvas (real playhead) and
|
|
4
4
|
* TimelineEditorNotice (animated illustration).
|
|
5
|
+
*
|
|
6
|
+
* The vertical line + glow span the full track height; the grab-handle HEAD is
|
|
7
|
+
* `position: sticky; top: 0` so it pins to the top of the (vertically) scrolling
|
|
8
|
+
* track area — the ruler is sticky too, so the head stays visible and grabbable
|
|
9
|
+
* no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
|
|
10
|
+
* FILLED while the playhead is actively held/scrubbed (`scrubbing`).
|
|
5
11
|
*/
|
|
6
12
|
interface PlayheadIndicatorProps {
|
|
7
13
|
/** CSS color, defaults to the HF accent variable */
|
|
8
14
|
color?: string;
|
|
9
15
|
/** Glow shadow color, defaults to translucent accent */
|
|
10
16
|
glowColor?: string;
|
|
17
|
+
/** Whether the playhead is being actively scrubbed — fills the head. */
|
|
18
|
+
scrubbing?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* When false, the head chip is rendered in normal flow (top:1) instead of the
|
|
21
|
+
* sticky pin — used by the static illustration where there is no scroll area.
|
|
22
|
+
*/
|
|
23
|
+
stickyHead?: boolean;
|
|
11
24
|
}
|
|
12
25
|
|
|
13
26
|
export function PlayheadIndicator({
|
|
14
27
|
color = "var(--hf-accent, #3CE6AC)",
|
|
15
28
|
glowColor = "rgba(60,230,172,0.14)",
|
|
29
|
+
scrubbing = false,
|
|
30
|
+
stickyHead = true,
|
|
16
31
|
}: PlayheadIndicatorProps) {
|
|
32
|
+
// Head chip dimensions — used to compute the centering offset and the
|
|
33
|
+
// point where the vertical line starts (so it begins at the head's bottom
|
|
34
|
+
// edge rather than running through the hollow diamond center).
|
|
35
|
+
const HEAD_W = 9;
|
|
36
|
+
const HEAD_H = 9;
|
|
37
|
+
// marginTop(1) + HEAD_H = where the line should start.
|
|
38
|
+
const HEAD_TOTAL_H = 1 + HEAD_H;
|
|
39
|
+
|
|
17
40
|
return (
|
|
18
41
|
<>
|
|
42
|
+
{/* Glow — spans full height, centered on the line. */}
|
|
19
43
|
<div
|
|
20
44
|
aria-hidden="true"
|
|
21
45
|
className="absolute top-0 bottom-0"
|
|
@@ -26,23 +50,44 @@ export function PlayheadIndicator({
|
|
|
26
50
|
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
|
|
27
51
|
}}
|
|
28
52
|
/>
|
|
53
|
+
{/* Vertical line — starts at the bottom edge of the head chip so nothing
|
|
54
|
+
shows through the hollow diamond center. */}
|
|
29
55
|
<div
|
|
30
|
-
className="absolute
|
|
56
|
+
className="absolute bottom-0"
|
|
31
57
|
style={{
|
|
32
58
|
left: "50%",
|
|
59
|
+
top: HEAD_TOTAL_H,
|
|
33
60
|
width: 1,
|
|
34
61
|
marginLeft: -0.5,
|
|
35
62
|
background: color,
|
|
36
63
|
boxShadow: `0 0 6px ${glowColor}`,
|
|
37
64
|
}}
|
|
38
65
|
/>
|
|
39
|
-
|
|
66
|
+
{/* Head chip — sticky so it pins to the ruler while tracks scroll.
|
|
67
|
+
Centering logic: wrapper width = HEAD_W (chip forces it). The line sits
|
|
68
|
+
at wrapper.left + HEAD_W/2 (left:"50%" of wrapper). The sticky element's
|
|
69
|
+
natural flow position is wrapper.left; so placing it there with no
|
|
70
|
+
horizontal translate puts its LEFT edge at wrapper.left and its CENTER
|
|
71
|
+
at wrapper.left + HEAD_W/2 — exactly on the line. */}
|
|
72
|
+
<div
|
|
73
|
+
className={stickyHead ? "sticky" : "absolute"}
|
|
74
|
+
style={{
|
|
75
|
+
left: 0,
|
|
76
|
+
top: stickyHead ? 0 : 1,
|
|
77
|
+
// Zero height keeps it from covering rows (sticky strip trick).
|
|
78
|
+
height: stickyHead ? 0 : undefined,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
40
81
|
<div
|
|
41
82
|
style={{
|
|
42
|
-
width:
|
|
43
|
-
height:
|
|
83
|
+
width: HEAD_W,
|
|
84
|
+
height: HEAD_H,
|
|
44
85
|
borderRadius: 2,
|
|
45
|
-
|
|
86
|
+
marginTop: 1,
|
|
87
|
+
// Outline-only at rest, filled while scrubbing.
|
|
88
|
+
background: scrubbing ? color : "transparent",
|
|
89
|
+
border: `1.5px solid ${color}`,
|
|
90
|
+
boxSizing: "border-box",
|
|
46
91
|
boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
|
|
47
92
|
transform: "rotate(45deg)",
|
|
48
93
|
}}
|
|
@@ -18,7 +18,14 @@ import {
|
|
|
18
18
|
shouldHandleTimelineDeleteKey,
|
|
19
19
|
shouldAutoScrollTimeline,
|
|
20
20
|
} from "./Timeline";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
GUTTER,
|
|
23
|
+
MIN_TIMELINE_EXTENT_S,
|
|
24
|
+
RULER_H,
|
|
25
|
+
TRACK_H,
|
|
26
|
+
getTimelineDisplayContentWidth,
|
|
27
|
+
getTimelineFitPps,
|
|
28
|
+
} from "./timelineLayout";
|
|
22
29
|
import { formatTime } from "../lib/time";
|
|
23
30
|
import { usePlayerStore } from "../store/playerStore";
|
|
24
31
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
@@ -323,30 +330,30 @@ describe("generateTicks", () => {
|
|
|
323
330
|
});
|
|
324
331
|
|
|
325
332
|
describe("formatTime", () => {
|
|
326
|
-
it("formats 0 seconds as
|
|
327
|
-
expect(formatTime(0)).toBe("
|
|
333
|
+
it("formats 0 seconds as 00:00", () => {
|
|
334
|
+
expect(formatTime(0)).toBe("00:00");
|
|
328
335
|
});
|
|
329
336
|
|
|
330
337
|
// fallow-ignore-next-line code-duplication
|
|
331
338
|
it("formats seconds below a minute", () => {
|
|
332
|
-
expect(formatTime(5)).toBe("
|
|
333
|
-
expect(formatTime(30)).toBe("
|
|
334
|
-
expect(formatTime(59)).toBe("
|
|
339
|
+
expect(formatTime(5)).toBe("00:05");
|
|
340
|
+
expect(formatTime(30)).toBe("00:30");
|
|
341
|
+
expect(formatTime(59)).toBe("00:59");
|
|
335
342
|
});
|
|
336
343
|
|
|
337
344
|
it("formats exactly one minute", () => {
|
|
338
|
-
expect(formatTime(60)).toBe("
|
|
345
|
+
expect(formatTime(60)).toBe("01:00");
|
|
339
346
|
});
|
|
340
347
|
|
|
341
348
|
it("formats minutes and seconds", () => {
|
|
342
|
-
expect(formatTime(90)).toBe("
|
|
343
|
-
expect(formatTime(125)).toBe("
|
|
349
|
+
expect(formatTime(90)).toBe("01:30");
|
|
350
|
+
expect(formatTime(125)).toBe("02:05");
|
|
344
351
|
});
|
|
345
352
|
|
|
346
353
|
it("floors fractional seconds", () => {
|
|
347
|
-
expect(formatTime(5.7)).toBe("
|
|
348
|
-
expect(formatTime(59.9)).toBe("
|
|
349
|
-
expect(formatTime(90.5)).toBe("
|
|
354
|
+
expect(formatTime(5.7)).toBe("00:05");
|
|
355
|
+
expect(formatTime(59.9)).toBe("00:59");
|
|
356
|
+
expect(formatTime(90.5)).toBe("01:30");
|
|
350
357
|
});
|
|
351
358
|
|
|
352
359
|
it("handles large values", () => {
|
|
@@ -354,16 +361,16 @@ describe("formatTime", () => {
|
|
|
354
361
|
expect(formatTime(3661)).toBe("61:01");
|
|
355
362
|
});
|
|
356
363
|
|
|
357
|
-
it("zero-pads seconds to two digits", () => {
|
|
358
|
-
expect(formatTime(1)).toBe("
|
|
359
|
-
expect(formatTime(9)).toBe("
|
|
360
|
-
expect(formatTime(61)).toBe("
|
|
364
|
+
it("zero-pads minutes and seconds to two digits", () => {
|
|
365
|
+
expect(formatTime(1)).toBe("00:01");
|
|
366
|
+
expect(formatTime(9)).toBe("00:09");
|
|
367
|
+
expect(formatTime(61)).toBe("01:01");
|
|
361
368
|
});
|
|
362
369
|
});
|
|
363
370
|
|
|
364
371
|
describe("formatTimelineTickLabel", () => {
|
|
365
372
|
it("uses minute-second labels for normal timeline intervals", () => {
|
|
366
|
-
expect(formatTimelineTickLabel(90, 180, 5)).toBe("
|
|
373
|
+
expect(formatTimelineTickLabel(90, 180, 5)).toBe("01:30");
|
|
367
374
|
});
|
|
368
375
|
|
|
369
376
|
it("uses hour labels for long timelines", () => {
|
|
@@ -371,7 +378,7 @@ describe("formatTimelineTickLabel", () => {
|
|
|
371
378
|
});
|
|
372
379
|
|
|
373
380
|
it("shows subsecond labels when the major ruler interval is below one second", () => {
|
|
374
|
-
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("
|
|
381
|
+
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("00:01.5");
|
|
375
382
|
});
|
|
376
383
|
});
|
|
377
384
|
|
|
@@ -390,6 +397,80 @@ describe("shouldAutoScrollTimeline", () => {
|
|
|
390
397
|
});
|
|
391
398
|
});
|
|
392
399
|
|
|
400
|
+
describe("getTimelineFitPps (min 60s extent)", () => {
|
|
401
|
+
const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
|
|
402
|
+
|
|
403
|
+
it("computes fit pps against the 60s floor for short compositions", () => {
|
|
404
|
+
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
|
|
405
|
+
const pps = getTimelineFitPps(viewport, 10);
|
|
406
|
+
expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
|
|
407
|
+
expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("keeps filling the viewport with the composition when it is 60s or longer", () => {
|
|
411
|
+
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo((viewport - GUTTER - 2) / 60);
|
|
412
|
+
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo((viewport - GUTTER - 2) / 120);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("falls back to 100 pps before the viewport is measured", () => {
|
|
416
|
+
expect(getTimelineFitPps(0, 10)).toBe(100);
|
|
417
|
+
expect(getTimelineFitPps(GUTTER, 10)).toBe(100);
|
|
418
|
+
expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("uses the floor for zero/invalid durations", () => {
|
|
422
|
+
expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
|
|
423
|
+
(viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
|
|
424
|
+
);
|
|
425
|
+
expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
|
|
426
|
+
(viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("getTimelineDisplayContentWidth", () => {
|
|
432
|
+
it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => {
|
|
433
|
+
// 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
|
|
434
|
+
expect(
|
|
435
|
+
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }),
|
|
436
|
+
).toBe(MIN_TIMELINE_EXTENT_S * 20);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("still fills the viewport when that is larger than the 60s floor", () => {
|
|
440
|
+
expect(
|
|
441
|
+
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
|
|
442
|
+
).toBe(2000 - GUTTER - 2);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
|
|
446
|
+
expect(
|
|
447
|
+
getTimelineDisplayContentWidth({
|
|
448
|
+
trackContentWidth: 500,
|
|
449
|
+
viewportWidth: 400,
|
|
450
|
+
pps: 5,
|
|
451
|
+
dragGhostEndPx: 5000,
|
|
452
|
+
}),
|
|
453
|
+
).toBe(5000);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("tracks a resize (trim) ghost past every other bound (trim-to-extend)", () => {
|
|
457
|
+
expect(
|
|
458
|
+
getTimelineDisplayContentWidth({
|
|
459
|
+
trackContentWidth: 500,
|
|
460
|
+
viewportWidth: 400,
|
|
461
|
+
pps: 5,
|
|
462
|
+
resizeGhostEndPx: 4200,
|
|
463
|
+
}),
|
|
464
|
+
).toBe(4200);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("keeps long content authoritative", () => {
|
|
468
|
+
expect(
|
|
469
|
+
getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }),
|
|
470
|
+
).toBe(9000);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
393
474
|
describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
394
475
|
it("resets horizontal scroll when switching from manual zoom back to fit", () => {
|
|
395
476
|
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|
|
@@ -524,7 +605,9 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
524
605
|
trackOrder: [0, 3, 7],
|
|
525
606
|
},
|
|
526
607
|
432,
|
|
527
|
-
|
|
608
|
+
// clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
|
|
609
|
+
// TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
|
|
610
|
+
368,
|
|
528
611
|
),
|
|
529
612
|
).toEqual({ start: 3, track: 3 });
|
|
530
613
|
});
|