@hyperframes/studio 0.7.61 → 0.7.63

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 (84) hide show
  1. package/dist/assets/{hyperframes-player-Xvx2hkrc.js → hyperframes-player-BO2UXx-E.js} +1 -1
  2. package/dist/assets/{index-D-uFclFj.js → index-C0A0DlLk.js} +1 -1
  3. package/dist/assets/index-C_7k3kwr.js +426 -0
  4. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  5. package/dist/assets/{index-BCpoiv9S.js → index-_72wCKSP.js} +1 -1
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3504 -2955
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -10
  12. package/src/components/EditorShell.tsx +12 -0
  13. package/src/components/StudioLeftSidebar.tsx +3 -0
  14. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  15. package/src/components/editor/PropertyPanelFlat.tsx +6 -0
  16. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  17. package/src/components/editor/domEditing.test.ts +34 -0
  18. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  19. package/src/components/editor/propertyPanelColor.tsx +150 -28
  20. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
  23. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
  24. package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
  25. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  26. package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
  27. package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
  28. package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
  29. package/src/components/editor/propertyPanelSections.tsx +2 -2
  30. package/src/components/editor/propertyPanelTypes.ts +2 -0
  31. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  33. package/src/components/nle/TimelinePane.tsx +6 -0
  34. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  35. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  36. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  37. package/src/contexts/StudioContext.tsx +4 -0
  38. package/src/hooks/timelineEditingHelpers.ts +10 -4
  39. package/src/hooks/usePreviewPersistence.ts +7 -1
  40. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  41. package/src/hooks/useRazorSplit.test.tsx +20 -9
  42. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  43. package/src/hooks/useRazorSplit.ts +78 -350
  44. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  45. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  46. package/src/hooks/useTimelineEditing.ts +19 -17
  47. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  48. package/src/player/components/Timeline.tsx +12 -1
  49. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  50. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  51. package/src/player/components/timelineCallbacks.ts +4 -0
  52. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  53. package/src/player/components/timelineClipDragCommit.ts +77 -105
  54. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  55. package/src/player/components/timelineClipDragPreview.ts +2 -1
  56. package/src/player/components/timelineCollision.test.ts +18 -0
  57. package/src/player/components/timelineCollision.ts +12 -9
  58. package/src/player/components/timelineDragDrop.ts +28 -7
  59. package/src/player/components/timelineEditCapabilities.ts +4 -1
  60. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  61. package/src/player/components/timelineGroupEditing.ts +4 -3
  62. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  63. package/src/player/components/timelineLayout.ts +3 -1
  64. package/src/player/components/timelineZMirror.ts +1 -1
  65. package/src/player/components/useTimelineClipDrag.ts +5 -0
  66. package/src/player/components/useTimelineEditPinning.ts +12 -0
  67. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  68. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  69. package/src/player/lib/playbackTypes.ts +2 -0
  70. package/src/player/lib/timelineDOM.test.ts +58 -0
  71. package/src/player/lib/timelineDOM.ts +18 -1
  72. package/src/player/lib/timelineElementHelpers.ts +21 -11
  73. package/src/player/store/playerStore.ts +2 -1
  74. package/src/utils/razorSplitTransaction.test.ts +175 -0
  75. package/src/utils/razorSplitTransaction.ts +200 -0
  76. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  77. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  78. package/src/utils/timelineCompositionDrop.ts +16 -0
  79. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  80. package/src/utils/timelineCompositionInsert.ts +90 -0
  81. package/src/utils/timelineElementSplit.test.ts +14 -2
  82. package/src/utils/timelineElementSplit.ts +5 -1
  83. package/dist/assets/index-2mxh_HSy.js +0 -426
  84. package/dist/assets/index-BhWig0mx.css +0 -1
@@ -2,6 +2,8 @@
2
2
 
3
3
  import React, { act, useState } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
+ import postcss from "postcss";
6
+ import tailwindcss from "tailwindcss";
5
7
  import { afterEach, describe, expect, it, vi } from "vitest";
6
8
  import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection";
7
9
  import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
@@ -256,8 +258,9 @@ describe("FlatTextFieldEditor controls", () => {
256
258
  act(() => root.unmount());
257
259
  });
258
260
 
259
- it("live-commits the Size field on input, without requiring blur/Enter", async () => {
261
+ it("previews Size input immediately and persists once on blur", () => {
260
262
  const onSetTextFieldStyle = vi.fn();
263
+ const onPreviewTextFieldStyle = vi.fn();
261
264
  const { host, root } = renderInto(
262
265
  <FlatTextSection
263
266
  element={makeSingleFieldElement()}
@@ -265,6 +268,7 @@ describe("FlatTextFieldEditor controls", () => {
265
268
  fontAssets={[]}
266
269
  onSetText={vi.fn()}
267
270
  onSetTextFieldStyle={onSetTextFieldStyle}
271
+ onPreviewTextFieldStyle={onPreviewTextFieldStyle}
268
272
  onAddTextField={vi.fn()}
269
273
  onRemoveTextField={vi.fn()}
270
274
  />,
@@ -275,6 +279,7 @@ describe("FlatTextFieldEditor controls", () => {
275
279
  const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
276
280
  if (!input) throw new Error("expected the Size row's input");
277
281
  act(() => {
282
+ input.focus();
278
283
  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
279
284
  window.HTMLInputElement.prototype,
280
285
  "value",
@@ -282,9 +287,10 @@ describe("FlatTextFieldEditor controls", () => {
282
287
  nativeInputValueSetter?.call(input, "24px");
283
288
  input.dispatchEvent(new Event("input", { bubbles: true }));
284
289
  });
285
- // liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
286
- await act(async () => {
287
- await new Promise((resolve) => setTimeout(resolve, 160));
290
+ expect(onPreviewTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
291
+ expect(onSetTextFieldStyle).not.toHaveBeenCalled();
292
+ act(() => {
293
+ input.blur();
288
294
  });
289
295
  expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
290
296
  act(() => root.unmount());
@@ -462,4 +468,41 @@ describe("FlatTextSection — multi-field", () => {
462
468
 
463
469
  act(() => root.unmount());
464
470
  });
471
+
472
+ it("keeps Content expandable and grows it with multiline text", async () => {
473
+ const element = makeMultiFieldElement();
474
+ element.textFields[0].value = "First line\nSecond line\nThird line";
475
+
476
+ const host = document.createElement("div");
477
+ document.body.append(host);
478
+ const root = createRoot(host);
479
+ act(() => {
480
+ root.render(
481
+ <FlatTextSection
482
+ element={element}
483
+ styles={{}}
484
+ fontAssets={[]}
485
+ onSetText={vi.fn()}
486
+ onSetTextFieldStyle={vi.fn()}
487
+ onAddTextField={vi.fn()}
488
+ onRemoveTextField={vi.fn()}
489
+ />,
490
+ );
491
+ });
492
+
493
+ const contentTextarea = host.querySelector<HTMLTextAreaElement>("textarea");
494
+ expect(contentTextarea?.value).toBe("First line\nSecond line\nThird line");
495
+ expect(contentTextarea?.classList).toContain("resize-y");
496
+ expect(contentTextarea?.classList).toContain("overflow-y-auto");
497
+
498
+ const compiled = await postcss([
499
+ tailwindcss({
500
+ content: [{ raw: host.innerHTML, extension: "html" }],
501
+ corePlugins: { preflight: false },
502
+ }),
503
+ ]).process("@tailwind utilities;", { from: undefined });
504
+ expect(compiled.css).toContain("field-sizing: content");
505
+
506
+ act(() => root.unmount());
507
+ });
465
508
  });
@@ -47,6 +47,7 @@ function FlatTextFieldEditor({
47
47
  onImportFonts,
48
48
  onSetText,
49
49
  onSetTextFieldStyle,
50
+ onPreviewTextFieldStyle,
50
51
  autoFocus = false,
51
52
  }: {
52
53
  field: DomEditSelection["textFields"][number];
@@ -55,6 +56,7 @@ function FlatTextFieldEditor({
55
56
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
56
57
  onSetText: (value: string, fieldKey?: string) => void;
57
58
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
59
+ onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
58
60
  autoFocus?: boolean;
59
61
  }) {
60
62
  const track = useTrackDesignInput();
@@ -100,6 +102,7 @@ function FlatTextFieldEditor({
100
102
  value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
101
103
  tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
102
104
  liveCommit
105
+ onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "font-size", next)}
103
106
  onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
104
107
  />
105
108
  <div className="flex min-h-[30px] items-center justify-between">
@@ -220,6 +223,7 @@ function FlatTextFieldEditor({
220
223
  flat
221
224
  label="Color"
222
225
  value={value ?? getTextFieldColor(field, styles)}
226
+ onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "color", next)}
223
227
  onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
224
228
  />
225
229
  )}
@@ -235,6 +239,7 @@ export function FlatTextSection({
235
239
  onImportFonts,
236
240
  onSetText,
237
241
  onSetTextFieldStyle,
242
+ onPreviewTextFieldStyle,
238
243
  onAddTextField,
239
244
  onRemoveTextField,
240
245
  }: {
@@ -244,6 +249,7 @@ export function FlatTextSection({
244
249
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
245
250
  onSetText: (value: string, fieldKey?: string) => void;
246
251
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
252
+ onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
247
253
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
248
254
  onRemoveTextField: (fieldKey: string) => void;
249
255
  }) {
@@ -288,6 +294,7 @@ export function FlatTextSection({
288
294
  onImportFonts={onImportFonts}
289
295
  onSetText={onSetText}
290
296
  onSetTextFieldStyle={onSetTextFieldStyle}
297
+ onPreviewTextFieldStyle={onPreviewTextFieldStyle}
291
298
  autoFocus
292
299
  />
293
300
  </div>
@@ -303,6 +310,7 @@ export function FlatTextSection({
303
310
  onImportFonts={onImportFonts}
304
311
  onSetText={onSetText}
305
312
  onSetTextFieldStyle={onSetTextFieldStyle}
313
+ onPreviewTextFieldStyle={onPreviewTextFieldStyle}
306
314
  />
307
315
  <button
308
316
  type="button"
@@ -356,7 +356,10 @@ describe.each(["classic", "flat"] as const)("shared %s input telemetry", (ui) =>
356
356
  (input) => input.value === "#FF0000",
357
357
  );
358
358
  if (!hex) throw new Error("expected color hex input");
359
- act(() => changeInput(hex, "#00FF00"));
359
+ act(() => {
360
+ changeInput(hex, "#00FF00");
361
+ blurInput(hex);
362
+ });
360
363
 
361
364
  expect(trackStudioEvent).toHaveBeenCalledTimes(1);
362
365
  expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
@@ -3,107 +3,10 @@ import {
3
3
  DesignPanelInputProvider,
4
4
  useTrackDesignInput,
5
5
  } from "../../contexts/DesignPanelInputContext";
6
- import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
6
+ import { FIELD, LABEL } from "./propertyPanelHelpers";
7
+ import { CommitField } from "./propertyPanelCommitField";
7
8
 
8
- export function CommitField({
9
- value,
10
- disabled,
11
- liveCommit,
12
- align = "left",
13
- onCommit,
14
- }: {
15
- value: string;
16
- disabled?: boolean;
17
- liveCommit?: boolean;
18
- /** The legacy panel lays out label-then-value inline (left reads naturally);
19
- * the flat inspector lays out label…value across a `justify-between` row,
20
- * where a left-aligned value looks stranded at the edge of its own
21
- * right-hand box instead of lining up with every other row's value. */
22
- align?: "left" | "right";
23
- onCommit: (nextValue: string) => void;
24
- }) {
25
- const [draft, setDraft] = useState(value);
26
- const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
27
- const valueRef = useRef(value);
28
- const draftRef = useRef(draft);
29
- const inputRef = useRef<HTMLInputElement>(null);
30
-
31
- valueRef.current = value;
32
- draftRef.current = draft;
33
-
34
- useEffect(() => {
35
- setDraft(value);
36
- }, [value]);
37
-
38
- useEffect(() => {
39
- const el = inputRef.current;
40
- if (!el) return;
41
- const handler = (e: WheelEvent) => {
42
- if (disabled || document.activeElement !== el) return;
43
- const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
44
- if (delta === 0) return;
45
- const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
46
- if (!nextDraft) return;
47
- e.preventDefault();
48
- e.stopPropagation();
49
- setDraft(nextDraft);
50
- scheduleCommitRef.current(nextDraft);
51
- };
52
- el.addEventListener("wheel", handler, { passive: false });
53
- return () => el.removeEventListener("wheel", handler);
54
- }, [disabled]);
55
-
56
- useEffect(
57
- () => () => {
58
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
59
- },
60
- [],
61
- );
62
-
63
- const commitDraft = (nextDraft: string) => {
64
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
65
- if (nextDraft !== valueRef.current) onCommit(nextDraft);
66
- };
67
-
68
- const scheduleCommit = (nextDraft: string) => {
69
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
70
- commitTimerRef.current = setTimeout(() => {
71
- if (nextDraft !== valueRef.current) onCommit(nextDraft);
72
- }, 120);
73
- };
74
- const scheduleCommitRef = useRef(scheduleCommit);
75
- scheduleCommitRef.current = scheduleCommit;
76
-
77
- return (
78
- <input
79
- ref={inputRef}
80
- type="text"
81
- value={draft}
82
- disabled={disabled}
83
- onChange={(e) => {
84
- setDraft(e.target.value);
85
- if (liveCommit) scheduleCommit(e.target.value);
86
- }}
87
- onBlur={() => commitDraft(draft)}
88
- onKeyDown={(e) => {
89
- if (e.key === "Enter") {
90
- (e.target as HTMLInputElement).blur();
91
- return;
92
- }
93
- if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
94
- const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
95
- if (!nextDraft) return;
96
- e.preventDefault();
97
- setDraft(nextDraft);
98
- scheduleCommit(nextDraft);
99
- }}
100
- title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
101
- className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
102
- align === "right" ? "text-right" : "text-left"
103
- }`}
104
- />
105
- );
106
- }
9
+ export { CommitField } from "./propertyPanelCommitField";
107
10
 
108
11
  /* ------------------------------------------------------------------ */
109
12
  /* MetricField */
@@ -147,7 +147,7 @@ export function TextAreaField({
147
147
  onFocus={handleFocus}
148
148
  onChange={handleChange}
149
149
  onBlur={handleBlur}
150
- className="w-full resize-none bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4"
150
+ className="[field-sizing:content] max-h-[40vh] min-h-12 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4"
151
151
  />
152
152
  </div>
153
153
  );
@@ -165,7 +165,7 @@ export function TextAreaField({
165
165
  onFocus={handleFocus}
166
166
  onChange={handleChange}
167
167
  onBlur={handleBlur}
168
- className="w-full resize-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
168
+ className="[field-sizing:content] max-h-[40vh] min-h-20 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
169
169
  />
170
170
  </div>
171
171
  </label>
@@ -32,6 +32,7 @@ export interface PropertyPanelProps {
32
32
  onClearSelection: () => void;
33
33
  onUngroup?: () => void;
34
34
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
35
+ onPreviewStyle?: (prop: string, value: string) => void;
35
36
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
36
37
  /** Commits several data-* attributes on the SAME element in ONE atomic
37
38
  * persist call — e.g. a pinned timing range's start+duration together, so
@@ -62,6 +63,7 @@ export interface PropertyPanelProps {
62
63
  onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
63
64
  onSetText: (value: string, fieldKey?: string) => void;
64
65
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
66
+ onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
65
67
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
66
68
  onRemoveTextField: (fieldKey: string) => void;
67
69
  onAskAgent: () => void;
@@ -0,0 +1,37 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ describe("useInspectorGestureTransaction", () => {
11
+ it("keeps a new gesture active when the prior async commit is acknowledged", () => {
12
+ const host = document.createElement("div");
13
+ const root = createRoot(host);
14
+ const onPreview = vi.fn();
15
+ const onCommit = vi.fn();
16
+ let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
17
+
18
+ function Probe({ sourceValue }: { sourceValue: number }) {
19
+ gesture = useInspectorGestureTransaction({ sourceValue, onPreview, onCommit });
20
+ return null;
21
+ }
22
+
23
+ act(() => root.render(<Probe sourceValue={10} />));
24
+ act(() => {
25
+ gesture?.preview(20);
26
+ gesture?.settle();
27
+ gesture?.preview(30);
28
+ });
29
+ act(() => root.render(<Probe sourceValue={20} />));
30
+
31
+ expect(onPreview).toHaveBeenLastCalledWith(30);
32
+ act(() => gesture?.settle());
33
+ expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
34
+
35
+ act(() => root.unmount());
36
+ });
37
+ });
@@ -0,0 +1,60 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+
3
+ /** One owner for continuous inspector edits: preview freely, persist once. */
4
+ export function useInspectorGestureTransaction<T>({
5
+ sourceValue,
6
+ onPreview,
7
+ onCommit,
8
+ }: {
9
+ sourceValue: T;
10
+ onPreview: (value: T) => void;
11
+ onCommit: (value: T) => void;
12
+ }) {
13
+ const sourceRef = useRef(sourceValue);
14
+ const activeRef = useRef<{ before: T; latest: T } | null>(null);
15
+ const previewRef = useRef(onPreview);
16
+ const commitRef = useRef(onCommit);
17
+ if (!activeRef.current) sourceRef.current = sourceValue;
18
+ previewRef.current = onPreview;
19
+ commitRef.current = onCommit;
20
+
21
+ const begin = useCallback(() => {
22
+ if (!activeRef.current) {
23
+ activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
24
+ }
25
+ }, []);
26
+
27
+ const preview = useCallback((value: T) => {
28
+ if (!activeRef.current) {
29
+ activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
30
+ }
31
+ activeRef.current.latest = value;
32
+ previewRef.current(value);
33
+ }, []);
34
+
35
+ const settle = useCallback(() => {
36
+ const active = activeRef.current;
37
+ activeRef.current = null;
38
+ if (active && !Object.is(active.before, active.latest)) {
39
+ sourceRef.current = active.latest;
40
+ // Restore the captured baseline before the persistent commit captures
41
+ // rollback state. The commit reapplies `latest` synchronously, so this
42
+ // is not visible but a failed save can now correctly restore `before`.
43
+ previewRef.current(active.before);
44
+ commitRef.current(active.latest);
45
+ }
46
+ }, []);
47
+
48
+ const cancel = useCallback(() => {
49
+ const active = activeRef.current;
50
+ activeRef.current = null;
51
+ if (active && !Object.is(active.before, active.latest)) {
52
+ sourceRef.current = active.before;
53
+ previewRef.current(active.before);
54
+ }
55
+ }, []);
56
+
57
+ useEffect(() => cancel, [cancel]);
58
+
59
+ return { begin, preview, settle, cancel, activeRef };
60
+ }
@@ -96,6 +96,10 @@ export interface TimelinePaneProps {
96
96
  blockName: string,
97
97
  placement: Pick<TimelineElement, "start" | "track">,
98
98
  ) => Promise<void> | void;
99
+ onCompositionDrop?: (
100
+ sourcePath: string,
101
+ placement: Pick<TimelineElement, "start" | "track">,
102
+ ) => Promise<void> | void;
99
103
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
100
104
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
101
105
  }
@@ -109,6 +113,7 @@ export function TimelinePane({
109
113
  onDeleteElement,
110
114
  onAssetDrop,
111
115
  onBlockDrop,
116
+ onCompositionDrop,
112
117
  onBlockedEditAttempt,
113
118
  onSelectTimelineElement,
114
119
  }: TimelinePaneProps) {
@@ -273,6 +278,7 @@ export function TimelinePane({
273
278
  onDeleteElement={handleDeleteElement}
274
279
  onAssetDrop={onAssetDrop}
275
280
  onBlockDrop={onBlockDrop}
281
+ onCompositionDrop={onCompositionDrop}
276
282
  onMoveElement={handleMoveElement}
277
283
  onMoveElements={handleMoveElements}
278
284
  onResizeElement={handleResizeElement}
@@ -0,0 +1,87 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
7
+ import { CompositionsTab } from "./CompositionsTab";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+ (
11
+ window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
12
+ ).happyDOM.settings.disableIframePageLoading = true;
13
+
14
+ let root: Root | null = null;
15
+
16
+ afterEach(() => {
17
+ if (root) act(() => root?.unmount());
18
+ root = null;
19
+ document.body.innerHTML = "";
20
+ });
21
+
22
+ function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
23
+ const host = document.createElement("div");
24
+ document.body.append(host);
25
+ root = createRoot(host);
26
+ act(() => {
27
+ root?.render(
28
+ <CompositionsTab
29
+ projectId="demo"
30
+ compositions={["compositions/headline.html"]}
31
+ activeComposition={null}
32
+ onSelect={onSelect}
33
+ onAddToTimeline={onAddToTimeline}
34
+ />,
35
+ );
36
+ });
37
+ const card = host.querySelector<HTMLElement>('[draggable="true"]');
38
+ if (!card) throw new Error("composition card did not render");
39
+ return { host, card, onSelect, onAddToTimeline };
40
+ }
41
+
42
+ describe("composition card drag", () => {
43
+ it("keeps ordinary click navigation", () => {
44
+ const { card, onSelect } = mount();
45
+ act(() => card.click());
46
+ expect(onSelect).toHaveBeenCalledWith("compositions/headline.html");
47
+ });
48
+
49
+ it("emits only source identity and suppresses the click following a drag", () => {
50
+ const { card, onSelect } = mount();
51
+ const data = new Map<string, string>();
52
+ const event = new Event("dragstart", { bubbles: true });
53
+ Object.defineProperty(event, "dataTransfer", {
54
+ value: {
55
+ effectAllowed: "none",
56
+ setData: (type: string, value: string) => data.set(type, value),
57
+ },
58
+ });
59
+ act(() => {
60
+ card.dispatchEvent(event);
61
+ card.click();
62
+ });
63
+
64
+ expect(JSON.parse(data.get(TIMELINE_COMPOSITION_MIME) ?? "null")).toEqual({
65
+ sourcePath: "compositions/headline.html",
66
+ });
67
+ expect(card.className).toContain("select-none");
68
+ expect(onSelect).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it("offers pointer and keyboard add-at-playhead actions without opening the card", () => {
72
+ const { host, onSelect, onAddToTimeline } = mount();
73
+ const add = host.querySelector<HTMLButtonElement>(
74
+ '[aria-label="Add headline to timeline at playhead"]',
75
+ );
76
+ if (!add) throw new Error("add action did not render");
77
+ act(() => {
78
+ add.click();
79
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
80
+ add.click();
81
+ });
82
+
83
+ expect(onAddToTimeline).toHaveBeenCalledTimes(2);
84
+ expect(onAddToTimeline).toHaveBeenLastCalledWith("compositions/headline.html");
85
+ expect(onSelect).not.toHaveBeenCalled();
86
+ });
87
+ });
@@ -1,5 +1,6 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
3
+ import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
3
4
 
4
5
  interface CompositionsTabProps {
5
6
  projectId: string;
@@ -7,6 +8,7 @@ interface CompositionsTabProps {
7
8
  activeComposition: string | null;
8
9
  onSelect: (comp: string) => void;
9
10
  onRenderComposition?: (comp: string) => void;
11
+ onAddToTimeline?: (comp: string) => void;
10
12
  isRendering?: boolean;
11
13
  lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
12
14
  }
@@ -115,6 +117,7 @@ function CompCard({
115
117
  onRender,
116
118
  isRendering,
117
119
  lintInfo,
120
+ onAddToTimeline,
118
121
  }: {
119
122
  projectId: string;
120
123
  comp: string;
@@ -123,12 +126,14 @@ function CompCard({
123
126
  onRender?: () => void;
124
127
  isRendering?: boolean;
125
128
  lintInfo?: { count: number; messages: string[] };
129
+ onAddToTimeline?: () => void;
126
130
  }) {
127
131
  const [hovered, setHovered] = useState(false);
128
132
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
129
133
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
130
134
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
131
135
  const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
136
+ const draggedRef = useRef(false);
132
137
 
133
138
  const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
134
139
  if (syncTimer.current) {
@@ -179,10 +184,32 @@ function CompCard({
179
184
 
180
185
  return (
181
186
  <div
182
- onClick={onSelect}
187
+ role="button"
188
+ tabIndex={0}
189
+ draggable
190
+ onDragStart={(event) => {
191
+ draggedRef.current = true;
192
+ event.dataTransfer.effectAllowed = "copy";
193
+ event.dataTransfer.setData(TIMELINE_COMPOSITION_MIME, JSON.stringify({ sourcePath: comp }));
194
+ }}
195
+ onDragEnd={() => {
196
+ window.setTimeout(() => {
197
+ draggedRef.current = false;
198
+ }, 0);
199
+ }}
200
+ onClick={() => {
201
+ if (!draggedRef.current) onSelect();
202
+ }}
203
+ onKeyDown={(event) => {
204
+ if (event.target !== event.currentTarget) return;
205
+ if (event.key === "Enter" || event.key === " ") {
206
+ event.preventDefault();
207
+ onSelect();
208
+ }
209
+ }}
183
210
  onPointerEnter={handleEnter}
184
211
  onPointerLeave={handleLeave}
185
- className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
212
+ className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing ${
186
213
  isActive
187
214
  ? "bg-studio-accent/10 border-l-2 border-studio-accent"
188
215
  : "border-l-2 border-transparent hover:bg-neutral-800/50"
@@ -232,6 +259,20 @@ function CompCard({
232
259
  </div>
233
260
  <span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
234
261
  </div>
262
+ {onAddToTimeline && (
263
+ <button
264
+ type="button"
265
+ title={`Add ${name} to timeline at playhead`}
266
+ aria-label={`Add ${name} to timeline at playhead`}
267
+ onClick={(event) => {
268
+ event.stopPropagation();
269
+ onAddToTimeline();
270
+ }}
271
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-neutral-600 opacity-0 transition-[color,background-color,opacity] hover:bg-neutral-800 hover:text-studio-accent group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus:opacity-100"
272
+ >
273
+ <span aria-hidden="true">+</span>
274
+ </button>
275
+ )}
235
276
  {onRender && (
236
277
  <button
237
278
  type="button"
@@ -274,6 +315,7 @@ export const CompositionsTab = memo(function CompositionsTab({
274
315
  activeComposition,
275
316
  onSelect,
276
317
  onRenderComposition,
318
+ onAddToTimeline,
277
319
  isRendering,
278
320
  lintFindingsByFile,
279
321
  }: CompositionsTabProps) {
@@ -295,6 +337,7 @@ export const CompositionsTab = memo(function CompositionsTab({
295
337
  isActive={activeComposition === comp}
296
338
  onSelect={() => onSelect(comp)}
297
339
  onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
340
+ onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
298
341
  isRendering={isRendering}
299
342
  lintInfo={lintFindingsByFile?.get(comp)}
300
343
  />
@@ -61,6 +61,7 @@ interface LeftSidebarProps {
61
61
  onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
62
62
  takeoverContent?: ReactNode;
63
63
  onAddAssetToTimeline?: (path: string) => void;
64
+ onAddCompositionToTimeline?: (path: string) => void;
64
65
  }
65
66
 
66
67
  export const LeftSidebar = memo(
@@ -94,6 +95,7 @@ export const LeftSidebar = memo(
94
95
  onPreviewBlock,
95
96
  takeoverContent,
96
97
  onAddAssetToTimeline,
98
+ onAddCompositionToTimeline,
97
99
  },
98
100
  ref,
99
101
  ) {
@@ -220,6 +222,7 @@ export const LeftSidebar = memo(
220
222
  compositions={compositions}
221
223
  activeComposition={activeComposition}
222
224
  onSelect={onSelectComposition}
225
+ onAddToTimeline={onAddCompositionToTimeline}
223
226
  onRenderComposition={onRenderComposition}
224
227
  isRendering={isRendering}
225
228
  lintFindingsByFile={lintFindingsByFile}
@@ -69,6 +69,10 @@ export function useStudioPlaybackContext(): StudioPlaybackValue {
69
69
  return ctx;
70
70
  }
71
71
 
72
+ export function useStudioPlaybackContextOptional(): StudioPlaybackValue | null {
73
+ return useContext(StudioPlaybackContext);
74
+ }
75
+
72
76
  /** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
73
77
  // fallow-ignore-next-line unused-export
74
78
  export function useStudioContext(): StudioContextValue {