@hyperframes/studio 0.7.55 → 0.7.57
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--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.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 +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- 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 +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- 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 +126 -125
- 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 +53 -2
- 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 +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- 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/propertyPanelColorGradingSection.tsx +15 -21
- 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/useCanvasContextMenuState.ts +92 -0
- 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/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -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 +260 -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.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -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 +56 -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 +23 -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 +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -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/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -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 +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- 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 +148 -18
- 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/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- 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/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- 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/TimelineRuler.tsx +58 -48
- 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 +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -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 +42 -2
- 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 +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- 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 +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- 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/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- 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,217 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../../player";
|
|
3
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
4
|
+
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
|
+
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
|
6
|
+
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
7
|
+
import {
|
|
8
|
+
useDomEditActionsContext,
|
|
9
|
+
useDomEditSelectionContext,
|
|
10
|
+
} from "../../contexts/DomEditContext";
|
|
11
|
+
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
|
|
12
|
+
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
|
|
13
|
+
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
|
|
14
|
+
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
15
|
+
|
|
16
|
+
export interface TimelineEditCallbackDeps {
|
|
17
|
+
handleTimelineElementMove: (
|
|
18
|
+
element: TimelineElement,
|
|
19
|
+
updates: Pick<TimelineElement, "start" | "track">,
|
|
20
|
+
) => Promise<void> | void;
|
|
21
|
+
handleTimelineElementsMove: (
|
|
22
|
+
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
|
|
23
|
+
coalesceKey?: string,
|
|
24
|
+
operation?: TimelineMoveOperation,
|
|
25
|
+
) => Promise<void> | void;
|
|
26
|
+
handleTimelineElementResize: (
|
|
27
|
+
element: TimelineElement,
|
|
28
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
29
|
+
) => Promise<void> | void;
|
|
30
|
+
handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
|
31
|
+
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
|
32
|
+
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
33
|
+
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
34
|
+
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
35
|
+
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds the timeline edit callback bag (move/resize/split/razor plus the
|
|
40
|
+
* keyframe-diamond callbacks) provided to `<Timeline>` via TimelineEditProvider.
|
|
41
|
+
* The keyframe callbacks resolve the dragged diamond back to its GSAP anim id +
|
|
42
|
+
* tween-relative percentage, reading DOM-edit selection state from context.
|
|
43
|
+
*/
|
|
44
|
+
// fallow-ignore-next-line complexity
|
|
45
|
+
export function useTimelineEditCallbacks({
|
|
46
|
+
handleTimelineElementMove,
|
|
47
|
+
handleTimelineElementsMove,
|
|
48
|
+
handleTimelineElementResize,
|
|
49
|
+
handleTimelineGroupResize,
|
|
50
|
+
handleToggleTrackHidden,
|
|
51
|
+
handleBlockedTimelineEdit,
|
|
52
|
+
handleTimelineElementSplit,
|
|
53
|
+
handleRazorSplit,
|
|
54
|
+
handleRazorSplitAll,
|
|
55
|
+
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
|
56
|
+
const { projectId, activeCompPath } = useStudioShellContext();
|
|
57
|
+
const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
|
|
58
|
+
const {
|
|
59
|
+
handleGsapRemoveKeyframe,
|
|
60
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
61
|
+
handleGsapMoveKeyframe,
|
|
62
|
+
handleGsapResizeKeyframedTween,
|
|
63
|
+
handleGsapUpdateMeta,
|
|
64
|
+
handleGsapAddKeyframe,
|
|
65
|
+
handleGsapConvertToKeyframes,
|
|
66
|
+
handleGsapRemoveAllKeyframes,
|
|
67
|
+
buildDomSelectionForTimelineElement,
|
|
68
|
+
} = useDomEditActionsContext();
|
|
69
|
+
|
|
70
|
+
// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
|
|
71
|
+
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
|
|
72
|
+
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
|
73
|
+
// anim in the keyframe's property group, falling back to the first keyframed one.
|
|
74
|
+
const resolveKeyframeTarget = useCallback(
|
|
75
|
+
// fallow-ignore-next-line complexity
|
|
76
|
+
(pct: number): { animId: string; tweenPct: number } | null => {
|
|
77
|
+
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
|
78
|
+
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
|
79
|
+
const group = kf?.propertyGroup;
|
|
80
|
+
const anim =
|
|
81
|
+
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
|
|
82
|
+
selectedGsapAnimations.find((a) => a.keyframes);
|
|
83
|
+
return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null;
|
|
84
|
+
},
|
|
85
|
+
[domEditSelection?.id, selectedGsapAnimations],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return useMemo(
|
|
89
|
+
() => ({
|
|
90
|
+
onMoveElement: handleTimelineElementMove,
|
|
91
|
+
onMoveElements: handleTimelineElementsMove,
|
|
92
|
+
onResizeElement: handleTimelineElementResize,
|
|
93
|
+
onResizeElements: handleTimelineGroupResize,
|
|
94
|
+
onToggleTrackHidden: handleToggleTrackHidden,
|
|
95
|
+
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
|
96
|
+
onSplitElement: handleTimelineElementSplit,
|
|
97
|
+
onRazorSplit: handleRazorSplit,
|
|
98
|
+
onRazorSplitAll: handleRazorSplitAll,
|
|
99
|
+
onDeleteAllKeyframes: () => {
|
|
100
|
+
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
101
|
+
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
102
|
+
// that the next drag adds to, flinging the element off-screen.
|
|
103
|
+
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
|
104
|
+
if (!anim) return;
|
|
105
|
+
handleGsapRemoveAllKeyframes(anim.id);
|
|
106
|
+
},
|
|
107
|
+
onDeleteKeyframe: (_elId: string, pct: number) => {
|
|
108
|
+
const target = resolveKeyframeTarget(pct);
|
|
109
|
+
if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
|
|
110
|
+
},
|
|
111
|
+
// Retime the keyframe to the playhead, preserving its value + ease.
|
|
112
|
+
onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
|
|
113
|
+
const target = resolveKeyframeTarget(pct);
|
|
114
|
+
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
|
|
115
|
+
},
|
|
116
|
+
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
|
|
117
|
+
// the dragged keyframe's anim + tween-%. We convert the clip-% drop to an
|
|
118
|
+
// absolute time (via the clip's timing basis) and let resolveKeyframeRetime
|
|
119
|
+
// decide: a drop inside the tween window is a plain move (re-key tween-%); a
|
|
120
|
+
// drop past the boundary (last keyframe past the end, first before the start)
|
|
121
|
+
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
|
122
|
+
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
|
123
|
+
// fallow-ignore-next-line complexity
|
|
124
|
+
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
|
125
|
+
const target = resolveKeyframeTarget(fromClipPct);
|
|
126
|
+
const sel = domEditSelection;
|
|
127
|
+
if (!target || !sel) return;
|
|
128
|
+
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
|
129
|
+
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
|
130
|
+
if (!anim || tweenStart === null) return;
|
|
131
|
+
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
|
132
|
+
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
|
133
|
+
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
134
|
+
const { elStart, elDuration } = resolveClipTimingBasis(
|
|
135
|
+
sel.id ?? "",
|
|
136
|
+
sourceFile,
|
|
137
|
+
elements,
|
|
138
|
+
domClipChildren,
|
|
139
|
+
);
|
|
140
|
+
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
|
|
141
|
+
const decision = resolveKeyframeRetime({
|
|
142
|
+
keyframes: anim.keyframes?.keyframes ?? [],
|
|
143
|
+
draggedTweenPct: target.tweenPct,
|
|
144
|
+
tweenStart,
|
|
145
|
+
tweenDuration,
|
|
146
|
+
dropAbsTime,
|
|
147
|
+
});
|
|
148
|
+
if (decision.kind === "move" && decision.toTweenPct != null) {
|
|
149
|
+
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
|
|
150
|
+
} else if (
|
|
151
|
+
decision.kind === "resize" &&
|
|
152
|
+
decision.pctRemap &&
|
|
153
|
+
decision.position != null &&
|
|
154
|
+
decision.duration != null
|
|
155
|
+
) {
|
|
156
|
+
handleGsapResizeKeyframedTween(
|
|
157
|
+
target.animId,
|
|
158
|
+
decision.position,
|
|
159
|
+
decision.duration,
|
|
160
|
+
decision.pctRemap,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
|
165
|
+
for (const anim of selectedGsapAnimations) {
|
|
166
|
+
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
// fallow-ignore-next-line complexity
|
|
170
|
+
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
|
|
171
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
172
|
+
const pct =
|
|
173
|
+
el.duration > 0
|
|
174
|
+
? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
|
|
175
|
+
: 0;
|
|
176
|
+
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
|
177
|
+
if (anim?.keyframes) {
|
|
178
|
+
const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
|
179
|
+
if (existing) {
|
|
180
|
+
handleGsapRemoveKeyframe(anim.id, existing.percentage);
|
|
181
|
+
} else {
|
|
182
|
+
handleGsapAddKeyframe(anim.id, pct, "x", 0);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
|
|
186
|
+
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
[
|
|
192
|
+
handleTimelineElementMove,
|
|
193
|
+
handleTimelineElementsMove,
|
|
194
|
+
handleTimelineElementResize,
|
|
195
|
+
handleTimelineGroupResize,
|
|
196
|
+
handleToggleTrackHidden,
|
|
197
|
+
handleBlockedTimelineEdit,
|
|
198
|
+
handleTimelineElementSplit,
|
|
199
|
+
handleRazorSplit,
|
|
200
|
+
handleRazorSplitAll,
|
|
201
|
+
handleGsapRemoveAllKeyframes,
|
|
202
|
+
resolveKeyframeTarget,
|
|
203
|
+
selectedGsapAnimations,
|
|
204
|
+
handleGsapRemoveKeyframe,
|
|
205
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
206
|
+
handleGsapMoveKeyframe,
|
|
207
|
+
handleGsapResizeKeyframedTween,
|
|
208
|
+
handleGsapUpdateMeta,
|
|
209
|
+
handleGsapAddKeyframe,
|
|
210
|
+
handleGsapConvertToKeyframes,
|
|
211
|
+
buildDomSelectionForTimelineElement,
|
|
212
|
+
projectId,
|
|
213
|
+
activeCompPath,
|
|
214
|
+
domEditSelection,
|
|
215
|
+
],
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
|
7
|
+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
8
|
+
import { AssetCard } from "./AssetCard";
|
|
9
|
+
import { AudioRow } from "./AudioRow";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
let root: Root | null = null;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (root) {
|
|
17
|
+
act(() => root?.unmount());
|
|
18
|
+
root = null;
|
|
19
|
+
}
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
usePlayerStore.getState().reset();
|
|
22
|
+
useAssetPreviewStore.getState().clearPreviewAsset();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function mount(node: React.ReactElement): HTMLElement {
|
|
27
|
+
const host = document.createElement("div");
|
|
28
|
+
document.body.append(host);
|
|
29
|
+
root = createRoot(host);
|
|
30
|
+
act(() => {
|
|
31
|
+
root?.render(node);
|
|
32
|
+
});
|
|
33
|
+
return host;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
|
|
37
|
+
return { tag: "div", start: 0, duration: 5, track: 0, ...input };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Simulate a drag-free click: pointerdown + pointerup at the same point. */
|
|
41
|
+
function clickCard(host: HTMLElement): void {
|
|
42
|
+
const card = host.querySelector('[draggable="true"]');
|
|
43
|
+
if (!card) throw new Error("Expected a draggable card root");
|
|
44
|
+
const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
|
|
45
|
+
act(() => {
|
|
46
|
+
card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
|
|
47
|
+
card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("AssetCard click behavior", () => {
|
|
52
|
+
const cardProps = {
|
|
53
|
+
projectId: "p1",
|
|
54
|
+
onCopy: vi.fn(),
|
|
55
|
+
isCopied: false,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
|
|
59
|
+
usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
|
|
60
|
+
// Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
|
|
61
|
+
// or it stays stuck over the canvas while the timeline reveals the clip.
|
|
62
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
|
|
63
|
+
|
|
64
|
+
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
|
|
65
|
+
clickCard(host);
|
|
66
|
+
|
|
67
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
|
68
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("img1");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("opens the preview overlay for a not-yet-added asset", () => {
|
|
72
|
+
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
|
|
73
|
+
clickCard(host);
|
|
74
|
+
|
|
75
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
|
|
76
|
+
expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("AudioRow click behavior", () => {
|
|
81
|
+
const rowProps = {
|
|
82
|
+
projectId: "p1",
|
|
83
|
+
onCopy: vi.fn(),
|
|
84
|
+
isCopied: false,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
|
|
88
|
+
usePlayerStore
|
|
89
|
+
.getState()
|
|
90
|
+
.setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
|
|
91
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
|
|
92
|
+
|
|
93
|
+
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
|
|
94
|
+
clickCard(host);
|
|
95
|
+
|
|
96
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
|
97
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("opens the preview overlay for a not-yet-added audio asset", () => {
|
|
101
|
+
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
|
|
102
|
+
clickCard(host);
|
|
103
|
+
|
|
104
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssetCard and FontRow — visual asset tile / row components for the Assets panel.
|
|
3
|
+
* Extracted from AssetsTab.tsx to keep that file under the 600-line CI gate.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
6
|
+
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
7
|
+
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
|
|
8
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
9
|
+
import { ContextMenu } from "./AssetContextMenu";
|
|
10
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
11
|
+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
12
|
+
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
|
13
|
+
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
|
|
14
|
+
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
|
15
|
+
|
|
16
|
+
/** Drag payload writer shared by the asset tile and the font row: copy effect
|
|
17
|
+
* plus the timeline-asset MIME and a plain-text path fallback. */
|
|
18
|
+
function writeAssetDragData(e: React.DragEvent, asset: string): void {
|
|
19
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
20
|
+
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
21
|
+
e.dataTransfer.setData("text/plain", asset);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Open the row/tile context menu at the pointer, shared by asset tile + font row. */
|
|
25
|
+
function openAssetContextMenu(
|
|
26
|
+
e: React.MouseEvent,
|
|
27
|
+
setContextMenu: (menu: { x: number; y: number }) => void,
|
|
28
|
+
): void {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lazily probe a video/audio URL for its duration via a hidden HTMLVideoElement
|
|
35
|
+
* (`preload="metadata"`). The manifest only covers ~/.media assets, so project
|
|
36
|
+
* assets in assets/ have no manifest entry — this fills the gap.
|
|
37
|
+
* Returns `undefined` until the probe completes; `null` if it failed.
|
|
38
|
+
*/
|
|
39
|
+
function useProbedDuration(src: string, skip: boolean): number | null | undefined {
|
|
40
|
+
const [duration, setDuration] = useState<number | null | undefined>(undefined);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (skip) return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
45
|
+
// The in-flight probe element, so unmount cleanup can abort its network
|
|
46
|
+
// fetch (clearing `src`) instead of leaving it to finish in the background.
|
|
47
|
+
let liveVid: HTMLVideoElement | null = null;
|
|
48
|
+
|
|
49
|
+
function teardown(vid: HTMLVideoElement) {
|
|
50
|
+
vid.onloadedmetadata = null;
|
|
51
|
+
vid.onerror = null;
|
|
52
|
+
vid.src = "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function probe(attempt: number) {
|
|
56
|
+
if (cancelled) return;
|
|
57
|
+
const vid = document.createElement("video");
|
|
58
|
+
liveVid = vid;
|
|
59
|
+
vid.preload = "metadata";
|
|
60
|
+
vid.muted = true;
|
|
61
|
+
vid.onloadedmetadata = () => {
|
|
62
|
+
const d = Number.isFinite(vid.duration) && vid.duration > 0 ? vid.duration : null;
|
|
63
|
+
teardown(vid);
|
|
64
|
+
if (!cancelled) setDuration(d);
|
|
65
|
+
};
|
|
66
|
+
vid.onerror = () => {
|
|
67
|
+
teardown(vid);
|
|
68
|
+
if (!cancelled) {
|
|
69
|
+
if (attempt < 1) retryTimer = setTimeout(() => probe(attempt + 1), 50);
|
|
70
|
+
else setDuration(null);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
vid.src = src;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
probe(0);
|
|
77
|
+
return () => {
|
|
78
|
+
cancelled = true;
|
|
79
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
80
|
+
if (liveVid) teardown(liveVid);
|
|
81
|
+
};
|
|
82
|
+
}, [src, skip]);
|
|
83
|
+
return duration;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AssetCardProps {
|
|
87
|
+
projectId: string;
|
|
88
|
+
asset: string;
|
|
89
|
+
used: boolean;
|
|
90
|
+
duration?: number;
|
|
91
|
+
onCopy: (path: string) => void;
|
|
92
|
+
isCopied: boolean;
|
|
93
|
+
onDelete?: (path: string) => void;
|
|
94
|
+
onRename?: (oldPath: string, newPath: string) => void;
|
|
95
|
+
onAddAssetToTimeline?: (path: string) => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Thumbnail card for images and video assets. Renders in a 2-col grid.
|
|
100
|
+
*
|
|
101
|
+
* Click behaviour (CapCut-style):
|
|
102
|
+
* - Already added → selects the clip on the timeline (setSelectedElementId).
|
|
103
|
+
* - Not yet added → opens the asset preview overlay over the canvas.
|
|
104
|
+
* Drag behaviour is preserved: a pointer movement exceeding DRAG_THRESHOLD_PX
|
|
105
|
+
* before pointerup is treated as drag-start, not a click.
|
|
106
|
+
*/
|
|
107
|
+
// fallow-ignore-next-line complexity
|
|
108
|
+
export function AssetCard({
|
|
109
|
+
projectId,
|
|
110
|
+
asset,
|
|
111
|
+
used,
|
|
112
|
+
duration,
|
|
113
|
+
onCopy,
|
|
114
|
+
isCopied,
|
|
115
|
+
onDelete,
|
|
116
|
+
onRename,
|
|
117
|
+
onAddAssetToTimeline,
|
|
118
|
+
}: AssetCardProps) {
|
|
119
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
120
|
+
const [hovered, setHovered] = useState(false);
|
|
121
|
+
const fullName = asset.split("/").pop() ?? asset;
|
|
122
|
+
const name = basename(asset);
|
|
123
|
+
const extension = ext(asset);
|
|
124
|
+
const serveUrl = resolveMediaPreviewUrl(asset, projectId);
|
|
125
|
+
const isVideo = VIDEO_EXT.test(asset);
|
|
126
|
+
const isImage = IMAGE_EXT.test(asset);
|
|
127
|
+
const probedDuration = useProbedDuration(serveUrl, !isVideo || duration != null);
|
|
128
|
+
const resolvedDuration = duration ?? probedDuration ?? undefined;
|
|
129
|
+
const durationLabel = formatDuration(resolvedDuration ?? 0);
|
|
130
|
+
|
|
131
|
+
// Drag-threshold click gate: track pointer-down position so we can ignore
|
|
132
|
+
// pointer-up events that followed a real drag gesture.
|
|
133
|
+
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
134
|
+
|
|
135
|
+
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
136
|
+
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
|
|
137
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
138
|
+
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
|
139
|
+
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
|
140
|
+
|
|
141
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
142
|
+
pointerDownRef.current = { x: e.clientX, y: e.clientY };
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const handlePointerUp = useCallback(
|
|
146
|
+
(e: React.PointerEvent) => {
|
|
147
|
+
const origin = pointerDownRef.current;
|
|
148
|
+
pointerDownRef.current = null;
|
|
149
|
+
if (!origin) return;
|
|
150
|
+
if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return;
|
|
151
|
+
// Treat as click
|
|
152
|
+
if (used) {
|
|
153
|
+
const clip = findClipForAsset(elements, asset);
|
|
154
|
+
if (clip) {
|
|
155
|
+
// Dismiss any open preview overlay (from another asset) — the reveal
|
|
156
|
+
// must not leave a stale preview card floating over the canvas.
|
|
157
|
+
clearPreviewAsset();
|
|
158
|
+
const clipKey = clip.key ?? clip.id;
|
|
159
|
+
setSelectedElementId(clipKey);
|
|
160
|
+
// Scroll the timeline so the selected clip is actually visible.
|
|
161
|
+
requestClipReveal(clipKey);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Not added (or no matching clip found) → preview overlay
|
|
166
|
+
setPreviewAsset(asset, projectId);
|
|
167
|
+
},
|
|
168
|
+
[
|
|
169
|
+
used,
|
|
170
|
+
elements,
|
|
171
|
+
asset,
|
|
172
|
+
projectId,
|
|
173
|
+
setSelectedElementId,
|
|
174
|
+
requestClipReveal,
|
|
175
|
+
setPreviewAsset,
|
|
176
|
+
clearPreviewAsset,
|
|
177
|
+
],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<>
|
|
182
|
+
<div
|
|
183
|
+
draggable
|
|
184
|
+
onPointerDown={handlePointerDown}
|
|
185
|
+
onPointerUp={handlePointerUp}
|
|
186
|
+
onDragStart={(e) => writeAssetDragData(e, asset)}
|
|
187
|
+
onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
|
|
188
|
+
onPointerEnter={() => setHovered(true)}
|
|
189
|
+
onPointerLeave={() => setHovered(false)}
|
|
190
|
+
className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors ${
|
|
191
|
+
isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40"
|
|
192
|
+
}`}
|
|
193
|
+
>
|
|
194
|
+
{/* Thumbnail */}
|
|
195
|
+
<div className="w-full aspect-video rounded overflow-hidden bg-neutral-900 relative">
|
|
196
|
+
{isImage && (
|
|
197
|
+
<img
|
|
198
|
+
src={serveUrl}
|
|
199
|
+
alt={name}
|
|
200
|
+
loading="lazy"
|
|
201
|
+
className="w-full h-full object-cover"
|
|
202
|
+
onError={(e) => {
|
|
203
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
{isVideo && (
|
|
208
|
+
<>
|
|
209
|
+
<VideoFrameThumbnail src={serveUrl} />
|
|
210
|
+
{hovered && (
|
|
211
|
+
<video
|
|
212
|
+
src={serveUrl}
|
|
213
|
+
autoPlay
|
|
214
|
+
muted
|
|
215
|
+
loop
|
|
216
|
+
playsInline
|
|
217
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
{!isImage && !isVideo && (
|
|
223
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
224
|
+
<span className="text-[10px] font-medium text-neutral-600">{extension}</span>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{/* "Added" badge — top-left */}
|
|
229
|
+
{used && (
|
|
230
|
+
<span className="absolute top-1 left-1 text-[9px] font-semibold leading-none px-1.5 py-[3px] rounded bg-neutral-950/80 text-panel-text-1">
|
|
231
|
+
Added
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Duration badge — top-right, media only */}
|
|
236
|
+
{durationLabel && (
|
|
237
|
+
<span className="absolute top-1 right-1 text-[9px] font-medium leading-none px-1.5 py-[3px] rounded bg-neutral-950/80 text-panel-text-2 tabular-nums">
|
|
238
|
+
{durationLabel}
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{/* Filename caption */}
|
|
244
|
+
<span
|
|
245
|
+
className={`text-[10px] leading-tight text-center block w-full ${
|
|
246
|
+
used ? "text-panel-text-2" : "text-panel-text-4"
|
|
247
|
+
}`}
|
|
248
|
+
title={fullName}
|
|
249
|
+
>
|
|
250
|
+
{truncateMiddle(fullName, 22)}
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{contextMenu && (
|
|
255
|
+
<ContextMenu
|
|
256
|
+
x={contextMenu.x}
|
|
257
|
+
y={contextMenu.y}
|
|
258
|
+
asset={asset}
|
|
259
|
+
onClose={() => setContextMenu(null)}
|
|
260
|
+
onCopy={onCopy}
|
|
261
|
+
onDelete={onDelete}
|
|
262
|
+
onRename={onRename}
|
|
263
|
+
onAddAtPlayhead={onAddAssetToTimeline}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
</>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface FontRowProps {
|
|
271
|
+
asset: string;
|
|
272
|
+
used: boolean;
|
|
273
|
+
onCopy: (path: string) => void;
|
|
274
|
+
isCopied: boolean;
|
|
275
|
+
onDelete?: (path: string) => void;
|
|
276
|
+
onRename?: (oldPath: string, newPath: string) => void;
|
|
277
|
+
onAddAssetToTimeline?: (path: string) => void;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Compact row for font assets (no meaningful thumbnail; show ext badge + name).
|
|
282
|
+
*/
|
|
283
|
+
export function FontRow({
|
|
284
|
+
asset,
|
|
285
|
+
used,
|
|
286
|
+
onCopy,
|
|
287
|
+
isCopied,
|
|
288
|
+
onDelete,
|
|
289
|
+
onRename,
|
|
290
|
+
onAddAssetToTimeline,
|
|
291
|
+
}: FontRowProps) {
|
|
292
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
293
|
+
const name = basename(asset);
|
|
294
|
+
const extension = ext(asset);
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<>
|
|
298
|
+
<div
|
|
299
|
+
draggable
|
|
300
|
+
onClick={() => onCopy(asset)}
|
|
301
|
+
onDragStart={(e) => writeAssetDragData(e, asset)}
|
|
302
|
+
onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
|
|
303
|
+
className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors ${
|
|
304
|
+
isCopied
|
|
305
|
+
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
306
|
+
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
307
|
+
}`}
|
|
308
|
+
>
|
|
309
|
+
<div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
|
|
310
|
+
<span className="text-[9px] font-medium text-neutral-700">{extension}</span>
|
|
311
|
+
</div>
|
|
312
|
+
<div className="min-w-0 flex-1">
|
|
313
|
+
<span
|
|
314
|
+
className={`text-xs font-medium truncate block ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
315
|
+
>
|
|
316
|
+
{name}
|
|
317
|
+
</span>
|
|
318
|
+
<div className="flex items-center gap-1.5">
|
|
319
|
+
<span className="text-[10px] text-neutral-600 truncate">{extension}</span>
|
|
320
|
+
{used && (
|
|
321
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
|
322
|
+
in use
|
|
323
|
+
</span>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
{contextMenu && (
|
|
330
|
+
<ContextMenu
|
|
331
|
+
x={contextMenu.x}
|
|
332
|
+
y={contextMenu.y}
|
|
333
|
+
asset={asset}
|
|
334
|
+
onClose={() => setContextMenu(null)}
|
|
335
|
+
onCopy={onCopy}
|
|
336
|
+
onDelete={onDelete}
|
|
337
|
+
onRename={onRename}
|
|
338
|
+
onAddAtPlayhead={onAddAssetToTimeline}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</>
|
|
342
|
+
);
|
|
343
|
+
}
|