@hyperframes/studio 0.7.38 → 0.7.40
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-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act, isValidElement, type ReactNode } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
6
7
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
7
8
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
8
9
|
import { normalizeCompositionSrc } from "./useRenderClipContent";
|
|
@@ -63,7 +64,10 @@ describe("normalizeCompositionSrc", () => {
|
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
describe("useRenderClipContent", () => {
|
|
66
|
-
function renderClipContent(
|
|
67
|
+
function renderClipContent(
|
|
68
|
+
el: TimelineElement,
|
|
69
|
+
activePreviewUrl: string | null = "/api/projects/my-project/preview",
|
|
70
|
+
): ReactNode {
|
|
67
71
|
const host = document.createElement("div");
|
|
68
72
|
document.body.append(host);
|
|
69
73
|
const root = createRoot(host);
|
|
@@ -73,7 +77,7 @@ describe("useRenderClipContent", () => {
|
|
|
73
77
|
const render = useRenderClipContent({
|
|
74
78
|
projectIdRef: { current: "my-project" },
|
|
75
79
|
compIdToSrc: new Map(),
|
|
76
|
-
activePreviewUrl
|
|
80
|
+
activePreviewUrl,
|
|
77
81
|
effectiveTimelineDuration: 12,
|
|
78
82
|
});
|
|
79
83
|
content = render(el, { clip: "#222", label: "#fff" });
|
|
@@ -100,4 +104,66 @@ describe("useRenderClipContent", () => {
|
|
|
100
104
|
expect(isValidElement(content)).toBe(true);
|
|
101
105
|
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
|
|
102
106
|
});
|
|
107
|
+
|
|
108
|
+
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
|
109
|
+
const cases: Array<{ content: ReactNode; type: unknown }> = [
|
|
110
|
+
{
|
|
111
|
+
content: renderClipContent({
|
|
112
|
+
id: "voiceover",
|
|
113
|
+
tag: "audio",
|
|
114
|
+
start: 1,
|
|
115
|
+
duration: 4,
|
|
116
|
+
track: 1,
|
|
117
|
+
src: "assets/voiceover.mp3",
|
|
118
|
+
}),
|
|
119
|
+
type: AudioWaveform,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
content: renderClipContent({
|
|
123
|
+
id: "nested",
|
|
124
|
+
tag: "div",
|
|
125
|
+
start: 0,
|
|
126
|
+
duration: 4,
|
|
127
|
+
track: 0,
|
|
128
|
+
compositionSrc: "compositions/nested.html",
|
|
129
|
+
}),
|
|
130
|
+
type: CompositionThumbnail,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
content: renderClipContent(
|
|
134
|
+
{
|
|
135
|
+
id: "clip-video",
|
|
136
|
+
tag: "video",
|
|
137
|
+
start: 0,
|
|
138
|
+
duration: 4,
|
|
139
|
+
track: 0,
|
|
140
|
+
src: "assets/clip.mp4",
|
|
141
|
+
},
|
|
142
|
+
null,
|
|
143
|
+
),
|
|
144
|
+
type: VideoThumbnail,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
content: renderClipContent(
|
|
148
|
+
{
|
|
149
|
+
id: "headline",
|
|
150
|
+
tag: "div",
|
|
151
|
+
start: 1,
|
|
152
|
+
duration: 4,
|
|
153
|
+
track: 0,
|
|
154
|
+
},
|
|
155
|
+
null,
|
|
156
|
+
),
|
|
157
|
+
type: CompositionThumbnail,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
for (const item of cases) {
|
|
162
|
+
expect(isValidElement<{ label: string }>(item.content)).toBe(true);
|
|
163
|
+
if (isValidElement<{ label: string }>(item.content)) {
|
|
164
|
+
expect(item.content.type).toBe(item.type);
|
|
165
|
+
expect(item.content.props.label).toBe("");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
103
169
|
});
|
|
@@ -3,7 +3,6 @@ import { createElement } from "react";
|
|
|
3
3
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
4
4
|
import type { TimelineElement } from "../player";
|
|
5
5
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
6
|
-
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
7
6
|
|
|
8
7
|
export function normalizeCompositionSrc(
|
|
9
8
|
compSrc: string,
|
|
@@ -58,7 +57,7 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string):
|
|
|
58
57
|
return createElement(AudioWaveform, {
|
|
59
58
|
audioUrl,
|
|
60
59
|
waveformUrl,
|
|
61
|
-
label:
|
|
60
|
+
label: "",
|
|
62
61
|
labelColor,
|
|
63
62
|
trimStartFraction: start,
|
|
64
63
|
trimEndFraction: end,
|
|
@@ -102,7 +101,7 @@ export function useRenderClipContent({
|
|
|
102
101
|
if (compSrc) {
|
|
103
102
|
return createElement(CompositionThumbnail, {
|
|
104
103
|
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
|
|
105
|
-
label:
|
|
104
|
+
label: "",
|
|
106
105
|
labelColor: style.label,
|
|
107
106
|
|
|
108
107
|
seekTime: 0,
|
|
@@ -122,7 +121,7 @@ export function useRenderClipContent({
|
|
|
122
121
|
if (activePreviewUrl && el.duration > 0) {
|
|
123
122
|
return createElement(CompositionThumbnail, {
|
|
124
123
|
previewUrl: activePreviewUrl,
|
|
125
|
-
label:
|
|
124
|
+
label: "",
|
|
126
125
|
labelColor: style.label,
|
|
127
126
|
|
|
128
127
|
selector: el.selector,
|
|
@@ -144,7 +143,7 @@ export function useRenderClipContent({
|
|
|
144
143
|
: `/api/projects/${pid}/preview/${el.src}`;
|
|
145
144
|
return createElement(VideoThumbnail, {
|
|
146
145
|
videoSrc: mediaSrc,
|
|
147
|
-
label:
|
|
146
|
+
label: "",
|
|
148
147
|
labelColor: style.label,
|
|
149
148
|
duration: el.duration,
|
|
150
149
|
});
|
|
@@ -153,7 +152,7 @@ export function useRenderClipContent({
|
|
|
153
152
|
if (htmlPreviewEligible) {
|
|
154
153
|
return createElement(CompositionThumbnail, {
|
|
155
154
|
previewUrl: `/api/projects/${pid}/preview`,
|
|
156
|
-
label:
|
|
155
|
+
label: "",
|
|
157
156
|
labelColor: style.label,
|
|
158
157
|
|
|
159
158
|
selector: el.selector,
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
collectHtmlIds,
|
|
21
21
|
resolveDroppedAssetDuration,
|
|
22
22
|
} from "../utils/studioHelpers";
|
|
23
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
24
23
|
import {
|
|
25
24
|
buildPatchTarget,
|
|
26
25
|
patchIframeDomTiming,
|
|
@@ -33,36 +32,12 @@ import {
|
|
|
33
32
|
scaleGsapPositions,
|
|
34
33
|
} from "./timelineEditingHelpers";
|
|
35
34
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
35
|
+
import {
|
|
36
|
+
useTimelineElementVisibilityEditing,
|
|
37
|
+
useTimelineTrackVisibilityEditing,
|
|
38
|
+
} from "./timelineTrackVisibility";
|
|
36
39
|
import { sdkTimingPersist } from "../utils/sdkCutover";
|
|
37
|
-
import type {
|
|
38
|
-
|
|
39
|
-
// ── Types ──
|
|
40
|
-
|
|
41
|
-
interface RecordEditInput {
|
|
42
|
-
label: string;
|
|
43
|
-
kind: EditHistoryKind;
|
|
44
|
-
coalesceKey?: string;
|
|
45
|
-
files: Record<string, { before: string; after: string }>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface UseTimelineEditingOptions {
|
|
49
|
-
projectId: string | null;
|
|
50
|
-
activeCompPath: string | null;
|
|
51
|
-
timelineElements: TimelineElement[];
|
|
52
|
-
showToast: (message: string, tone?: "error" | "info") => void;
|
|
53
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
54
|
-
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
55
|
-
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
56
|
-
reloadPreview: () => void;
|
|
57
|
-
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
58
|
-
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
59
|
-
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
60
|
-
isRecordingRef?: React.RefObject<boolean>;
|
|
61
|
-
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
|
62
|
-
sdkSession?: Composition | null;
|
|
63
|
-
/** Resync the SDK session after a server-authoritative timeline write. */
|
|
64
|
-
forceReloadSdkSession?: () => void;
|
|
65
|
-
}
|
|
40
|
+
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
66
41
|
|
|
67
42
|
// ── Hook ──
|
|
68
43
|
|
|
@@ -323,6 +298,34 @@ export function useTimelineEditing({
|
|
|
323
298
|
],
|
|
324
299
|
);
|
|
325
300
|
|
|
301
|
+
const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({
|
|
302
|
+
projectIdRef,
|
|
303
|
+
activeCompPath,
|
|
304
|
+
timelineElements,
|
|
305
|
+
showToast,
|
|
306
|
+
writeProjectFile,
|
|
307
|
+
recordEdit,
|
|
308
|
+
domEditSaveTimestampRef,
|
|
309
|
+
previewIframeRef,
|
|
310
|
+
pendingTimelineEditPathRef,
|
|
311
|
+
isRecordingRef,
|
|
312
|
+
forceReloadSdkSession,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const handleToggleElementHidden = useTimelineElementVisibilityEditing({
|
|
316
|
+
projectIdRef,
|
|
317
|
+
activeCompPath,
|
|
318
|
+
timelineElements,
|
|
319
|
+
showToast,
|
|
320
|
+
writeProjectFile,
|
|
321
|
+
recordEdit,
|
|
322
|
+
domEditSaveTimestampRef,
|
|
323
|
+
previewIframeRef,
|
|
324
|
+
pendingTimelineEditPathRef,
|
|
325
|
+
isRecordingRef,
|
|
326
|
+
forceReloadSdkSession,
|
|
327
|
+
});
|
|
328
|
+
|
|
326
329
|
// fallow-ignore-next-line complexity
|
|
327
330
|
const handleTimelineElementDelete = useCallback(
|
|
328
331
|
// fallow-ignore-next-line complexity
|
|
@@ -562,6 +565,8 @@ export function useTimelineEditing({
|
|
|
562
565
|
return {
|
|
563
566
|
handleTimelineElementMove,
|
|
564
567
|
handleTimelineElementResize,
|
|
568
|
+
handleToggleTrackHidden,
|
|
569
|
+
handleToggleElementHidden,
|
|
565
570
|
handleTimelineElementDelete,
|
|
566
571
|
handleTimelineElementSplit: handleRazorSplit,
|
|
567
572
|
handleRazorSplit,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MutableRefObject, RefObject } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import type { TimelineElement } from "../player";
|
|
4
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
|
|
6
|
+
interface RecordEditInput {
|
|
7
|
+
label: string;
|
|
8
|
+
kind: EditHistoryKind;
|
|
9
|
+
coalesceKey?: string;
|
|
10
|
+
files: Record<string, { before: string; after: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UseTimelineEditingOptions {
|
|
14
|
+
projectId: string | null;
|
|
15
|
+
activeCompPath: string | null;
|
|
16
|
+
timelineElements: TimelineElement[];
|
|
17
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
18
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
19
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
20
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
21
|
+
reloadPreview: () => void;
|
|
22
|
+
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
|
23
|
+
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
24
|
+
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
25
|
+
isRecordingRef?: RefObject<boolean>;
|
|
26
|
+
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
|
27
|
+
sdkSession?: Composition | null;
|
|
28
|
+
/** Resync the SDK session after a server-authoritative timeline write. */
|
|
29
|
+
forceReloadSdkSession?: () => void;
|
|
30
|
+
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
Eye as PhEye,
|
|
5
5
|
FilmStrip,
|
|
6
6
|
Stack,
|
|
7
|
-
ChatCenteredText,
|
|
8
7
|
ArrowsOutCardinal,
|
|
9
8
|
MusicNote,
|
|
10
9
|
Palette as PhPalette,
|
|
@@ -15,7 +14,6 @@ import {
|
|
|
15
14
|
TextT,
|
|
16
15
|
X as PhX,
|
|
17
16
|
Lightning,
|
|
18
|
-
CaretDown,
|
|
19
17
|
CaretRight,
|
|
20
18
|
ClipboardText,
|
|
21
19
|
ArrowCounterClockwise,
|
|
@@ -41,7 +39,6 @@ export const Clock = makeIcon(PhClock);
|
|
|
41
39
|
export const Eye = makeIcon(PhEye);
|
|
42
40
|
export const Film = makeIcon(FilmStrip);
|
|
43
41
|
export const Layers = makeIcon(Stack);
|
|
44
|
-
export const MessageSquare = makeIcon(ChatCenteredText);
|
|
45
42
|
export const Move = makeIcon(ArrowsOutCardinal);
|
|
46
43
|
export const Music = makeIcon(MusicNote);
|
|
47
44
|
export const Palette = makeIcon(PhPalette);
|
|
@@ -53,7 +50,18 @@ export const Type = makeIcon(TextT);
|
|
|
53
50
|
export const X = makeIcon(PhX);
|
|
54
51
|
export const Zap = makeIcon(Lightning);
|
|
55
52
|
// Extra icons used in this project (not in lucide's default mapping above)
|
|
56
|
-
export const ChevronDown =
|
|
53
|
+
export const ChevronDown = ({ title, style, ...props }: IconProps) => {
|
|
54
|
+
const transform = style?.transform ? `${style.transform} rotate(90deg)` : "rotate(90deg)";
|
|
55
|
+
return (
|
|
56
|
+
<CaretRight
|
|
57
|
+
alt={title}
|
|
58
|
+
aria-label={title}
|
|
59
|
+
aria-hidden={title ? undefined : true}
|
|
60
|
+
style={{ ...style, transform }}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
57
65
|
export const ChevronRight = makeIcon(CaretRight);
|
|
58
66
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
59
67
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
@@ -32,6 +32,7 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
|
|
32
32
|
const start = i * samplesPerBar;
|
|
33
33
|
const end = Math.min(start + samplesPerBar, channelData.length);
|
|
34
34
|
for (let j = start; j < end; j++) {
|
|
35
|
+
// fallow-ignore-next-line code-duplication
|
|
35
36
|
const abs = Math.abs(channelData[j] ?? 0);
|
|
36
37
|
if (abs > max) max = abs;
|
|
37
38
|
}
|
|
@@ -195,14 +196,16 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
195
196
|
}}
|
|
196
197
|
/>
|
|
197
198
|
)}
|
|
198
|
-
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
199
|
+
{label && (
|
|
200
|
+
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
|
|
201
|
+
<span
|
|
202
|
+
className="text-[9px] font-semibold truncate block leading-tight"
|
|
203
|
+
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
|
204
|
+
>
|
|
205
|
+
{label}
|
|
206
|
+
</span>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
206
209
|
</div>
|
|
207
210
|
);
|
|
208
211
|
});
|
|
@@ -66,6 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
66
66
|
if (!el) return;
|
|
67
67
|
|
|
68
68
|
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
|
69
|
+
// fallow-ignore-next-line code-duplication
|
|
69
70
|
setContainerWidth(measured);
|
|
70
71
|
|
|
71
72
|
const target = el.parentElement || el;
|
|
@@ -130,17 +131,19 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
130
131
|
</div>
|
|
131
132
|
)}
|
|
132
133
|
|
|
133
|
-
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
134
|
+
{label && (
|
|
135
|
+
<div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
|
|
136
|
+
<span
|
|
137
|
+
className="block max-w-full truncate text-[10px] font-semibold leading-none"
|
|
138
|
+
style={{
|
|
139
|
+
color: labelColor,
|
|
140
|
+
textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{label}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
144
147
|
</div>
|
|
145
148
|
);
|
|
146
149
|
});
|
|
@@ -12,29 +12,39 @@ interface PlayheadIndicatorProps {
|
|
|
12
12
|
|
|
13
13
|
export function PlayheadIndicator({
|
|
14
14
|
color = "var(--hf-accent, #3CE6AC)",
|
|
15
|
-
glowColor = "rgba(60,230,172,0.
|
|
15
|
+
glowColor = "rgba(60,230,172,0.14)",
|
|
16
16
|
}: PlayheadIndicatorProps) {
|
|
17
17
|
return (
|
|
18
18
|
<>
|
|
19
19
|
<div
|
|
20
|
+
aria-hidden="true"
|
|
20
21
|
className="absolute top-0 bottom-0"
|
|
21
22
|
style={{
|
|
22
23
|
left: "50%",
|
|
23
|
-
width:
|
|
24
|
-
|
|
24
|
+
width: 13,
|
|
25
|
+
transform: "translateX(-50%)",
|
|
26
|
+
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
<div
|
|
30
|
+
className="absolute top-0 bottom-0"
|
|
31
|
+
style={{
|
|
32
|
+
left: "50%",
|
|
33
|
+
width: 1,
|
|
34
|
+
marginLeft: -0.5,
|
|
25
35
|
background: color,
|
|
26
|
-
boxShadow: `0 0
|
|
36
|
+
boxShadow: `0 0 6px ${glowColor}`,
|
|
27
37
|
}}
|
|
28
38
|
/>
|
|
29
|
-
<div className="absolute" style={{ left: "50%", top:
|
|
39
|
+
<div className="absolute" style={{ left: "50%", top: 1, transform: "translateX(-50%)" }}>
|
|
30
40
|
<div
|
|
31
41
|
style={{
|
|
32
|
-
width:
|
|
33
|
-
height:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
width: 9,
|
|
43
|
+
height: 9,
|
|
44
|
+
borderRadius: 2,
|
|
45
|
+
background: color,
|
|
46
|
+
boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
|
|
47
|
+
transform: "rotate(45deg)",
|
|
38
48
|
}}
|
|
39
49
|
/>
|
|
40
50
|
</div>
|
|
@@ -60,6 +60,15 @@ const SHORTCUT_SECTIONS = [
|
|
|
60
60
|
{ key: "⇧ Drag", label: "Uniform resize" },
|
|
61
61
|
],
|
|
62
62
|
},
|
|
63
|
+
{
|
|
64
|
+
title: "Crop",
|
|
65
|
+
hints: [
|
|
66
|
+
{ key: "DblClick", label: "Crop selected element" },
|
|
67
|
+
{ key: "Drag edge", label: "Adjust crop side" },
|
|
68
|
+
{ key: "Drag frame", label: "Move crop window" },
|
|
69
|
+
{ key: "Esc", label: "Exit crop (or click outside)" },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
63
72
|
{
|
|
64
73
|
title: "Panels",
|
|
65
74
|
hints: [
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { RULER_H, TRACK_H } from "./timelineLayout";
|
|
22
22
|
import { formatTime } from "../lib/time";
|
|
23
23
|
import { usePlayerStore } from "../store/playerStore";
|
|
24
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
24
25
|
|
|
25
26
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
26
27
|
|
|
@@ -30,6 +31,7 @@ afterEach(() => {
|
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
describe("Timeline provider boundary", () => {
|
|
34
|
+
// fallow-ignore-next-line code-duplication
|
|
33
35
|
it("renders the public Timeline export without TimelineEditProvider", () => {
|
|
34
36
|
const host = document.createElement("div");
|
|
35
37
|
document.body.append(host);
|
|
@@ -55,6 +57,99 @@ describe("Timeline provider boundary", () => {
|
|
|
55
57
|
act(() => root.unmount());
|
|
56
58
|
});
|
|
57
59
|
|
|
60
|
+
// fallow-ignore-next-line code-duplication
|
|
61
|
+
it("renders the gutter without legacy icons or hue dots", () => {
|
|
62
|
+
const host = document.createElement("div");
|
|
63
|
+
document.body.append(host);
|
|
64
|
+
Object.defineProperty(host, "clientWidth", {
|
|
65
|
+
configurable: true,
|
|
66
|
+
value: 640,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
usePlayerStore.setState({
|
|
70
|
+
duration: 4,
|
|
71
|
+
timelineReady: true,
|
|
72
|
+
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const root = createRoot(host);
|
|
76
|
+
act(() => {
|
|
77
|
+
root.render(React.createElement(Timeline));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const hueDot = Array.from(host.querySelectorAll("div")).find(
|
|
81
|
+
(node) =>
|
|
82
|
+
node.style.width === "6px" &&
|
|
83
|
+
node.style.height === "6px" &&
|
|
84
|
+
node.style.borderRadius === "9999px",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(host.querySelector('img[src^="/icons/timeline/"]')).toBeNull();
|
|
88
|
+
expect(hueDot).toBeUndefined();
|
|
89
|
+
act(() => root.unmount());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// fallow-ignore-next-line code-duplication
|
|
93
|
+
it("requests persisted track visibility from the gutter without seeking", () => {
|
|
94
|
+
const host = document.createElement("div");
|
|
95
|
+
document.body.append(host);
|
|
96
|
+
Object.defineProperty(host, "clientWidth", {
|
|
97
|
+
configurable: true,
|
|
98
|
+
value: 640,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
usePlayerStore.setState({
|
|
102
|
+
duration: 4,
|
|
103
|
+
timelineReady: true,
|
|
104
|
+
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0, hidden: true }],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const onSeek = vi.fn();
|
|
108
|
+
const onToggleTrackHidden = vi.fn();
|
|
109
|
+
const root = createRoot(host);
|
|
110
|
+
act(() => {
|
|
111
|
+
root.render(
|
|
112
|
+
React.createElement(
|
|
113
|
+
TimelineEditProvider,
|
|
114
|
+
{ value: { onToggleTrackHidden } },
|
|
115
|
+
React.createElement(Timeline, { onSeek }),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
|
|
121
|
+
expect(button).not.toBeNull();
|
|
122
|
+
if (!button) throw new Error("Expected a track visibility toggle");
|
|
123
|
+
|
|
124
|
+
act(() => {
|
|
125
|
+
button.dispatchEvent(
|
|
126
|
+
new MouseEvent("pointerdown", {
|
|
127
|
+
bubbles: true,
|
|
128
|
+
cancelable: true,
|
|
129
|
+
button: 0,
|
|
130
|
+
clientX: 120,
|
|
131
|
+
clientY: 40,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
expect(onSeek).not.toHaveBeenCalled();
|
|
136
|
+
|
|
137
|
+
act(() => {
|
|
138
|
+
button.click();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const row = button.parentElement?.parentElement;
|
|
142
|
+
const trackContent = row?.children.item(1);
|
|
143
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
|
144
|
+
expect(trackContent).toBeInstanceOf(HTMLElement);
|
|
145
|
+
if (!(trackContent instanceof HTMLElement)) {
|
|
146
|
+
throw new Error("Expected track content element");
|
|
147
|
+
}
|
|
148
|
+
expect(trackContent.style.opacity).toBe("0.35");
|
|
149
|
+
|
|
150
|
+
act(() => root.unmount());
|
|
151
|
+
});
|
|
152
|
+
|
|
58
153
|
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
|
59
154
|
const host = document.createElement("div");
|
|
60
155
|
document.body.append(host);
|
|
@@ -177,10 +272,10 @@ describe("generateTicks", () => {
|
|
|
177
272
|
it("uses denser major labels as timeline zoom increases", () => {
|
|
178
273
|
const fitTicks = generateTicks(180, 10);
|
|
179
274
|
const zoomedTicks = generateTicks(180, 48);
|
|
180
|
-
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(
|
|
181
|
-
expect(
|
|
275
|
+
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(10);
|
|
276
|
+
expect(fitTicks.minor).toContain(5);
|
|
277
|
+
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(2);
|
|
182
278
|
expect(zoomedTicks.minor).toContain(1);
|
|
183
|
-
expect(zoomedTicks.minor).toContain(4);
|
|
184
279
|
});
|
|
185
280
|
|
|
186
281
|
it("keeps labels readable instead of placing one at every tiny tick", () => {
|
|
@@ -194,6 +289,7 @@ describe("formatTime", () => {
|
|
|
194
289
|
expect(formatTime(0)).toBe("0:00");
|
|
195
290
|
});
|
|
196
291
|
|
|
292
|
+
// fallow-ignore-next-line code-duplication
|
|
197
293
|
it("formats seconds below a minute", () => {
|
|
198
294
|
expect(formatTime(5)).toBe("0:05");
|
|
199
295
|
expect(formatTime(30)).toBe("0:30");
|
|
@@ -261,8 +357,12 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
|
261
357
|
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|
|
262
358
|
});
|
|
263
359
|
|
|
264
|
-
it("
|
|
265
|
-
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(
|
|
360
|
+
it("resets horizontal scroll whenever the next zoom mode is fit", () => {
|
|
361
|
+
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(0);
|
|
362
|
+
expect(getTimelineScrollLeftForZoomTransition(null, "fit", 480)).toBe(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("preserves the current scroll offset for manual zoom transitions", () => {
|
|
266
366
|
expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
|
|
267
367
|
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
|
268
368
|
});
|