@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.
Files changed (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /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(el: TimelineElement): ReactNode {
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: "/api/projects/my-project/preview",
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: getTimelineElementLabel(el),
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: getTimelineElementLabel(el),
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: getTimelineElementLabel(el),
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: getTimelineElementLabel(el),
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: getTimelineElementLabel(el),
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 { Composition } from "@hyperframes/sdk";
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 = makeIcon(CaretDown);
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
- <div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
199
- <span
200
- className="text-[9px] font-semibold truncate block leading-tight"
201
- style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
202
- >
203
- {label}
204
- </span>
205
- </div>
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
- <div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
134
- <span
135
- className="block max-w-full truncate text-[10px] font-semibold leading-none"
136
- style={{
137
- color: labelColor,
138
- textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
139
- }}
140
- >
141
- {label}
142
- </span>
143
- </div>
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.5)",
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: 2,
24
- marginLeft: -1,
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 8px ${glowColor}`,
36
+ boxShadow: `0 0 6px ${glowColor}`,
27
37
  }}
28
38
  />
29
- <div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
39
+ <div className="absolute" style={{ left: "50%", top: 1, transform: "translateX(-50%)" }}>
30
40
  <div
31
41
  style={{
32
- width: 0,
33
- height: 0,
34
- borderLeft: "6px solid transparent",
35
- borderRight: "6px solid transparent",
36
- borderTop: `8px solid ${color}`,
37
- filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
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(15);
181
- expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
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("preserves the current scroll offset for other zoom transitions", () => {
265
- expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
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
  });