@hyperframes/studio 0.8.82 → 0.8.83

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 (76) hide show
  1. package/dist/assets/{hyperframes-player-zGIbZhLJ.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-BiLCTunu.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-DSazIt7R.js → index-BYhK_KMc.js} +193 -193
  4. package/dist/assets/{index-DStmfnpZ.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +669 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +20501 -23167
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  29. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  30. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  31. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  32. package/src/components/editor/DomEditOverlay.tsx +36 -21
  33. package/src/components/editor/LayersPanel.tsx +1 -1
  34. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  35. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  36. package/src/components/editor/clipAudioClock.test.ts +61 -0
  37. package/src/components/editor/clipAudioClock.ts +78 -0
  38. package/src/components/editor/marqueeCommit.ts +100 -37
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  40. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
  41. package/src/components/editor/useFxCarve.ts +11 -15
  42. package/src/components/editor/useFxCarveNodes.ts +27 -15
  43. package/src/components/editor/useFxLevelling.ts +7 -43
  44. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  45. package/src/components/feedback/projectProvenance.ts +2 -3
  46. package/src/components/nle/PreviewOverlays.tsx +7 -188
  47. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  48. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  49. package/src/components/ui/HyperframesLogo.tsx +120 -0
  50. package/src/domEditExports.test.tsx +69 -0
  51. package/src/editorGesturesExports.test.tsx +236 -0
  52. package/src/hooks/useElementLifecycleOps.ts +1 -1
  53. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  54. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  55. package/src/hooks/usePreviewPersistence.ts +1 -1
  56. package/src/index.ts +45 -32
  57. package/src/lightEntries.test.ts +32 -0
  58. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  59. package/src/player/components/TimelineClipFades.tsx +2 -1
  60. package/src/player/components/TimelineTypes.ts +4 -2
  61. package/src/player/components/studioShortcuts.ts +8 -5
  62. package/src/player/components/timelineClipChildren.test.ts +34 -1
  63. package/src/player/components/timelineClipChildren.tsx +7 -4
  64. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  65. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  66. package/src/player/components/timelinePointerRelease.ts +4 -0
  67. package/src/player/components/useTimelineProviderState.tsx +4 -0
  68. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  69. package/src/player/components/useTimelineStackingSync.ts +18 -11
  70. package/src/playerEntry.ts +1 -0
  71. package/src/timelineStackingSyncExport.test.tsx +67 -0
  72. package/src/ui.ts +31 -0
  73. package/src/utils/mediaTypes.test.ts +33 -0
  74. package/src/utils/mediaTypes.ts +12 -5
  75. package/dist/chunk-I7XNAIOF.js.map +0 -1
  76. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
package/src/index.ts CHANGED
@@ -1,35 +1,5 @@
1
1
  // UI primitives
2
- export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
3
- export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
4
- export { HyperframesLogo } from "./components/StudioHeader";
5
- export { IconButton } from "./components/ui/IconButton";
6
- export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
7
- export { HyperframesLoader } from "./components/ui/HyperframesLoader";
8
- export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
9
- export { Tooltip } from "./components/ui/Tooltip";
10
- export { cn } from "./components/ui/cn";
11
- export {
12
- ContextMenu,
13
- Menu,
14
- MenuItem,
15
- MenuRadioGroup,
16
- MenuRadioItem,
17
- MenuSeparator,
18
- MenuShortcut,
19
- popupSurface,
20
- } from "./components/ui/Menu";
21
- export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
22
- export { Popover } from "./components/ui/Popover";
23
- export { Input, fieldBase, fieldText } from "./components/ui/Input";
24
- export type { InputProps } from "./components/ui/Input";
25
- export { NumberField } from "./components/ui/NumberField";
26
- export type { NumberFieldProps } from "./components/ui/NumberField";
27
- export { Select } from "./components/ui/Select";
28
- export type { SelectOption, SelectProps } from "./components/ui/Select";
29
- export { Slider } from "./components/ui/Slider";
30
- export type { SliderProps } from "./components/ui/Slider";
31
- export { Toggle } from "./components/ui/Toggle";
32
- export type { ToggleProps } from "./components/ui/Toggle";
2
+ export * from "./ui";
33
3
 
34
4
  // NLE Layout
35
5
  export { EditorShell } from "./components/EditorShell";
@@ -154,7 +124,10 @@ export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/h
154
124
  export { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
155
125
  export type { UsePersistentEditHistoryOptions } from "./hooks/usePersistentEditHistory";
156
126
  export { useTimelineEditing } from "./hooks/useTimelineEditing";
157
- export type { UseTimelineEditingOptions } from "./hooks/useTimelineEditingTypes";
127
+ export type {
128
+ TimelineZIndexReorderCommit,
129
+ UseTimelineEditingOptions,
130
+ } from "./hooks/useTimelineEditingTypes";
158
131
  // A host's own waitForPendingDomEditSaves must also call this, or undo/redo
159
132
  // can race a write still in flight (see useTrackPendingTimelineEdit.ts).
160
133
  export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
@@ -193,9 +166,49 @@ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
193
166
  export { useClipboard } from "./hooks/useClipboard";
194
167
  export type { UseClipboardOptions } from "./hooks/useClipboard";
195
168
 
169
+ // DOM editing: the canvas session, persistence and overlay, for a host outside EditorShell.
170
+ export { useDomEditSession } from "./hooks/useDomEditSession";
171
+ export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
172
+ export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
173
+ export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
174
+ export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
175
+ export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
176
+ export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
177
+ export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
178
+ export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
179
+
196
180
  export {
197
181
  playSeamTransitionLoop,
198
182
  type SeamTransitionFrameSource,
199
183
  type SeamTransitionLoopHandle,
200
184
  type SeamTransitionLoopOptions,
201
185
  } from "@hyperframes/shader-transitions";
186
+
187
+ // Editor gestures for a host's own preview overlay: snapping and the marquee
188
+ export {
189
+ SNAP_THRESHOLD_PX,
190
+ snapEngagedForTravel,
191
+ extractSnapTargets,
192
+ buildCompositionSnapTarget,
193
+ buildGridSnapEdges,
194
+ resolveSnapAdjustment,
195
+ resolveGuideLineRect,
196
+ resolveEquidistanceGuides,
197
+ } from "./components/editor/snapEngine";
198
+ export type {
199
+ SnapEdge,
200
+ SnapTarget,
201
+ SnapGuide,
202
+ SpacingGuide,
203
+ SnapResult,
204
+ Rect as SnapRect,
205
+ } from "./components/editor/snapEngine";
206
+ export { collectSnapContext } from "./components/editor/snapTargetCollection";
207
+ export type { SnapContext } from "./components/editor/snapTargetCollection";
208
+ export { SnapGuideOverlay } from "./components/editor/SnapGuideOverlay";
209
+ export type { SnapGuideOverlayProps, SnapGuidesState } from "./components/editor/SnapGuideOverlay";
210
+ export { useMarqueeGestures } from "./components/editor/marqueeCommit";
211
+ export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/marqueeCommit";
212
+ export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
213
+ export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
214
+ export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
@@ -0,0 +1,32 @@
1
+ // @vitest-environment happy-dom
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ const packageDir = resolve(import.meta.dirname, "..");
7
+ const { subpaths } = JSON.parse(readFileSync(resolve(packageDir, "package-subpaths.json"), "utf8"));
8
+
9
+ function refuse(name: string): never {
10
+ throw new Error(`a light entry loaded ${name}`);
11
+ }
12
+ vi.mock("@hyperframes/core", () => refuse("@hyperframes/core"));
13
+ vi.mock("@hyperframes/shader-transitions", () => refuse("@hyperframes/shader-transitions"));
14
+ vi.mock("@codemirror/view", () => refuse("@codemirror/view"));
15
+ vi.mock("@codemirror/state", () => refuse("@codemirror/state"));
16
+ vi.mock("mediabunny", () => refuse("mediabunny"));
17
+ vi.mock("gsap", () => refuse("gsap"));
18
+ vi.mock("@hyperframes/parsers", () => refuse("@hyperframes/parsers"));
19
+ vi.mock("@hyperframes/parsers/hf-ids", () => refuse("@hyperframes/parsers/hf-ids"));
20
+ vi.mock("@hyperframes/sdk", () => refuse("@hyperframes/sdk"));
21
+ vi.mock("dockview-react", () => refuse("dockview-react"));
22
+
23
+ describe("entries a host imports for its first screen", () => {
24
+ it.each(["./ui", "./player"])(
25
+ "%s loads without the editor, the parsers, gsap or media decoding",
26
+ async (entry) => {
27
+ await expect(import(resolve(packageDir, subpaths[entry].source))).resolves.toBeDefined();
28
+ },
29
+ // The icon set alone is thousands of modules to transform on a cold run.
30
+ 30_000,
31
+ );
32
+ });
@@ -71,8 +71,8 @@ function render(
71
71
  };
72
72
  }
73
73
 
74
- function pointer(type: string, clientX: number, pointerId = 1) {
75
- const event = new MouseEvent(type, { bubbles: true, clientX, button: 0 });
74
+ function pointer(type: string, clientX: number, pointerId = 1, clientY = 0) {
75
+ const event = new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 });
76
76
  Object.defineProperty(event, "pointerId", { value: pointerId });
77
77
  return event;
78
78
  }
@@ -134,14 +134,14 @@ describe("TimelineClipFades", () => {
134
134
  const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
135
135
  if (!handle) throw new Error("expected a fade-out handle");
136
136
  armCapture(handle);
137
- // Handle at x=800 (2 s before the end). 300 px left → 5 s.
138
- act(() => handle.dispatchEvent(pointer("pointerdown", 800)));
139
- act(() => handle.dispatchEvent(pointer("pointermove", 500)));
137
+ // Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
138
+ act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
139
+ act(() => handle.dispatchEvent(pointer("pointermove", 700)));
140
140
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
141
141
  // Way past the start: clamps to duration − fadeIn = 9 s.
142
- act(() => handle.dispatchEvent(pointer("pointermove", -500)));
142
+ act(() => handle.dispatchEvent(pointer("pointermove", 0)));
143
143
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "9");
144
- act(() => handle.dispatchEvent(pointer("pointerup", -500)));
144
+ act(() => handle.dispatchEvent(pointer("pointerup", 0)));
145
145
  expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-out", "9", "Fade out");
146
146
  act(() => root.unmount());
147
147
  });
@@ -151,9 +151,9 @@ describe("TimelineClipFades", () => {
151
151
  const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
152
152
  if (!handle) throw new Error("expected a fade-in handle");
153
153
  armCapture(handle);
154
- act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
155
- act(() => handle.dispatchEvent(pointer("pointermove", -40)));
156
- act(() => handle.dispatchEvent(pointer("pointerup", -40)));
154
+ act(() => handle.dispatchEvent(pointer("pointerdown", 200)));
155
+ act(() => handle.dispatchEvent(pointer("pointermove", 0)));
156
+ act(() => handle.dispatchEvent(pointer("pointerup", 0)));
157
157
  expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
158
158
  act(() => root.unmount());
159
159
  });
@@ -179,6 +179,27 @@ describe("TimelineClipFades", () => {
179
179
  act(() => root.unmount());
180
180
  });
181
181
 
182
+ it.each([
183
+ ["above the window", 300, -40],
184
+ ["on the window's right edge", window.innerWidth, 10],
185
+ ])("restores the fade and saves nothing when released %s", (_, clientX, clientY) => {
186
+ const { host, root, onSetElementAttributeLive, onRevertElementAttributeLive, ...rest } =
187
+ render(clip);
188
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
189
+ if (!handle) throw new Error("expected a fade-in handle");
190
+ armCapture(handle);
191
+ act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
192
+ act(() => handle.dispatchEvent(pointer("pointermove", 300)));
193
+ act(() => handle.dispatchEvent(pointer("pointerup", clientX, 1, clientY)));
194
+ expect(rest.onSetElementAttributeQuiet).not.toHaveBeenCalled();
195
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
196
+ expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
197
+ expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
198
+ "0,0 100,0 0,100",
199
+ );
200
+ act(() => root.unmount());
201
+ });
202
+
182
203
  it("does not let a press on the dot start the clip's own move gesture", () => {
183
204
  const outer = vi.fn();
184
205
  const { host, root } = render(clip, { onClipPointerDown: outer });
@@ -14,6 +14,7 @@ import {
14
14
  } from "@hyperframes/core/audio-fade";
15
15
  import type { TimelineElement } from "../store/playerStore";
16
16
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
17
+ import { releasedOutsideWindow } from "./timelinePointerRelease";
17
18
 
18
19
  type FadeEdge = "in" | "out";
19
20
 
@@ -136,7 +137,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
136
137
  const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
137
138
  const g = endGesture(e);
138
139
  if (!g) return;
139
- if (cancelled || !g.moved) return revertGesture(g);
140
+ if (cancelled || !g.moved || releasedOutsideWindow(e)) return revertGesture(g);
140
141
  setDraft({ edge: g.edge, seconds: g.last });
141
142
  void onSetElementAttributeQuiet?.(
142
143
  el,
@@ -4,18 +4,20 @@ import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
4
4
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
5
5
  import type { TimelineTheme } from "./timelineTheme";
6
6
  import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
7
+ import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
7
8
 
8
9
  export interface TimelineClipRenderContext {
9
10
  priority: "overscan" | "visible" | "interaction";
10
11
  rich: boolean;
11
12
  }
12
13
 
13
- export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
14
+ export interface TimelineProps
15
+ extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
14
16
  /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
15
17
  sessionEpoch?: number;
16
- /** keepPlaying: true preserves the current play state across the seek. */
17
18
  onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
18
19
  onDrillDown?: (element: TimelineElement) => void;
20
+ /** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
19
21
  renderClipContent?: (
20
22
  element: TimelineElement,
21
23
  style: { clip: string; label: string },
@@ -1,5 +1,10 @@
1
- /** Bare keys Studio's app hotkeys bind; the shortcuts list names them from here. */
2
- export const STUDIO_PLAIN_KEYS = { fullscreen: "f", split: "s", record: "r" } as const;
1
+ /** Bare keys Studio's hotkeys bind; the shortcuts list names them from here. */
2
+ export const STUDIO_PLAIN_KEYS = {
3
+ fullscreen: "f",
4
+ split: "s",
5
+ record: "r",
6
+ addKeyframe: "k",
7
+ } as const;
3
8
 
4
9
  export interface ShortcutHint {
5
10
  key: string;
@@ -32,10 +37,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
32
37
  {
33
38
  title: "Keyframes (when an element is selected)",
34
39
  hints: [
35
- { key: "K", label: "Add keyframe at playhead" },
40
+ { key: hintKey(STUDIO_PLAIN_KEYS.addKeyframe), label: "Add / remove keyframe at playhead" },
36
41
  { key: "Del", label: "Delete selected keyframe" },
37
- { key: "H", label: "Toggle hold / bezier" },
38
- { key: "U", label: "Expand / collapse properties" },
39
42
  { key: hintKey(STUDIO_PLAIN_KEYS.record), label: "Record gesture" },
40
43
  ],
41
44
  },
@@ -1,7 +1,17 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, createElement } from "react";
4
+ import { createRoot } from "react-dom/client";
1
5
  import { describe, expect, it } from "vitest";
2
- import { resolveClipRenderContext } from "./timelineClipChildren";
6
+ import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
7
+ import { getTrackStyle } from "./timelineIcons";
3
8
  import type { TimelineElement } from "../store/playerStore";
4
9
 
10
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
11
+ configurable: true,
12
+ value: true,
13
+ });
14
+
5
15
  const clip: TimelineElement = {
6
16
  id: "clip",
7
17
  tag: "video",
@@ -29,3 +39,26 @@ describe("resolveClipRenderContext", () => {
29
39
  });
30
40
  });
31
41
  });
42
+
43
+ describe("renderClipChildren", () => {
44
+ // happy-dom cannot hit-test, so this pins what decides the hit: the picture takes no input.
45
+ it("lets a press on a picture stacked above the trim handles fall through to them", () => {
46
+ const host = document.createElement("div");
47
+ document.body.append(host);
48
+ const root = createRoot(host);
49
+ const picture = () =>
50
+ createElement("div", { "data-picture": "", style: { position: "absolute", zIndex: 10 } });
51
+
52
+ act(() => {
53
+ root.render(renderClipChildren(clip, getTrackStyle("video"), picture, undefined));
54
+ });
55
+
56
+ // happy-dom does not inherit pointer-events, so walk up to the layer that turns input off.
57
+ let node = host.querySelector<HTMLElement>("[data-picture]");
58
+ expect(node).not.toBeNull();
59
+ while (node && node !== host && node.style.pointerEvents !== "none") node = node.parentElement;
60
+ expect(node?.style.pointerEvents).toBe("none");
61
+ act(() => root.unmount());
62
+ host.remove();
63
+ });
64
+ });
@@ -57,10 +57,13 @@ export function renderClipChildren(
57
57
  {renderClipOverlay?.(element)}
58
58
  {!renderClipContent && <ClipLintDot element={element} />}
59
59
  {renderClipContent && (
60
- // borderRadius: inherit — the clip itself is overflow-visible (keyframe
61
- // diamonds hang outside its bounds), so the thumbnail layer must clip
62
- // itself to the clip's rounded corners or sharp corners poke out.
63
- <div className="absolute inset-0 overflow-hidden" style={{ borderRadius: "inherit" }}>
60
+ // The picture can paint above the trim handles, so it takes no input and presses reach them.
61
+ // borderRadius: inherit clips it to the clip's rounded corners; the clip itself is
62
+ // overflow-visible because keyframe diamonds hang outside its bounds.
63
+ <div
64
+ className="absolute inset-0 overflow-hidden"
65
+ style={{ borderRadius: "inherit", pointerEvents: "none" }}
66
+ >
64
67
  {renderClipContent(element, clipStyle, context)}
65
68
  </div>
66
69
  )}
@@ -11,86 +11,108 @@ afterEach(() => {
11
11
  usePlayerStore.getState().reset();
12
12
  });
13
13
 
14
+ const element: TimelineElement = {
15
+ id: "clip-1",
16
+ tag: "div",
17
+ start: 0,
18
+ duration: 2,
19
+ track: 0,
20
+ };
21
+
22
+ function mountGesture(kind: "drag" | "resize") {
23
+ const drag: DraggedClipState = {
24
+ pointerId: 0,
25
+ element,
26
+ originClientX: 0,
27
+ originClientY: 0,
28
+ originScrollLeft: 0,
29
+ originScrollTop: 0,
30
+ pointerClientX: 0,
31
+ pointerClientY: 0,
32
+ pointerOffsetX: 0,
33
+ pointerOffsetY: 0,
34
+ previewStart: 1,
35
+ previewTrack: 0,
36
+ desiredTrack: 0,
37
+ insertRow: null,
38
+ snapTime: null,
39
+ snapType: null,
40
+ started: true,
41
+ };
42
+ const resize: ResizingClipState = {
43
+ pointerId: 0,
44
+ element,
45
+ edge: "end",
46
+ originClientX: 0,
47
+ previewStart: 0,
48
+ previewDuration: 3,
49
+ started: true,
50
+ };
51
+ const draggedClipRef = { current: kind === "drag" ? drag : null };
52
+ const resizingClipRef = { current: kind === "resize" ? resize : null };
53
+ const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
54
+ draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
55
+ };
56
+ const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
57
+ ...previous,
58
+ previewStart: 3,
59
+ }));
60
+ const onMoveElement = vi.fn();
61
+ const onResizeElement = vi.fn();
62
+ const updateElement = vi.fn();
63
+ const stopAutoScroll = vi.fn();
64
+ const cancelGestureRef = { current: () => false };
65
+ const dispose = mountTimelineClipDragGestureLifecycle({
66
+ lifecycleRef: { current: { kind, phase: "active", pointerId: null, sessionEpoch: 0 } },
67
+ sessionEpochRef: { current: 0 },
68
+ cancelGestureRef,
69
+ scrollRef: { current: null },
70
+ draggedClipRef,
71
+ resizingClipRef,
72
+ blockedClipRef: { current: null },
73
+ groupResizeRef: { current: null },
74
+ suppressClickRef: { current: false },
75
+ gestureSelectedKeysRef: { current: new Set() },
76
+ elementsRef: { current: [element] },
77
+ trackOrderRef: { current: [0] },
78
+ setDraggedClipState: setDraggedClip,
79
+ setResizingClipState: () => {},
80
+ setShowPopover: () => {},
81
+ setRangeSelectionRef: { current: null },
82
+ applyResizePointerRef: { current: () => {} },
83
+ syncClipDragAutoScrollRef: { current: () => {} },
84
+ stopClipDragAutoScrollRef: { current: stopAutoScroll },
85
+ updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
86
+ publishDraggedClip: setDraggedClip,
87
+ updateElement,
88
+ onMoveElementRef: { current: onMoveElement },
89
+ onMoveElementsRef: { current: undefined },
90
+ onResizeElementRef: { current: onResizeElement },
91
+ onResizeElementsRef: { current: undefined },
92
+ onBlockedEditAttemptRef: { current: undefined },
93
+ readZIndexRef: { current: undefined },
94
+ onStackingPatchesRef: { current: undefined },
95
+ refreshAfterLaneMoveRef: { current: undefined },
96
+ });
97
+ return {
98
+ draggedClipRef,
99
+ resizingClipRef,
100
+ updateDraggedClipPreview,
101
+ onMoveElement,
102
+ onResizeElement,
103
+ updateElement,
104
+ stopAutoScroll,
105
+ cancelGestureRef,
106
+ dispose,
107
+ };
108
+ }
109
+
110
+ const release = (clientX: number, clientY: number) =>
111
+ window.dispatchEvent(new MouseEvent("pointerup", { clientX, clientY }));
112
+
14
113
  describe("timeline clip drag gesture lifecycle", () => {
15
114
  it("finishes a drag after virtualization unmounts its source row", () => {
16
- const element: TimelineElement = {
17
- id: "clip-1",
18
- tag: "div",
19
- start: 0,
20
- duration: 2,
21
- track: 0,
22
- };
23
- const drag: DraggedClipState = {
24
- pointerId: 0,
25
- element,
26
- originClientX: 0,
27
- originClientY: 0,
28
- originScrollLeft: 0,
29
- originScrollTop: 0,
30
- pointerClientX: 0,
31
- pointerClientY: 0,
32
- pointerOffsetX: 0,
33
- pointerOffsetY: 0,
34
- previewStart: 1,
35
- previewTrack: 0,
36
- desiredTrack: 0,
37
- insertRow: null,
38
- snapTime: null,
39
- snapType: null,
40
- started: true,
41
- };
42
- const draggedClipRef = { current: drag as DraggedClipState | null };
43
- const resizingClipRef = { current: null as ResizingClipState | null };
44
- const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
45
- draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
46
- };
47
- const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
48
- ...previous,
49
- previewStart: 3,
50
- }));
51
- const onMoveElement = vi.fn();
52
- const stopAutoScroll = vi.fn();
53
- const cancelGestureRef = { current: () => false };
54
- const dispose = mountTimelineClipDragGestureLifecycle({
55
- lifecycleRef: {
56
- current: {
57
- kind: "drag",
58
- phase: "active",
59
- pointerId: null,
60
- sessionEpoch: 0,
61
- },
62
- },
63
- sessionEpochRef: { current: 0 },
64
- cancelGestureRef,
65
- scrollRef: { current: null },
66
- draggedClipRef,
67
- resizingClipRef,
68
- blockedClipRef: { current: null },
69
- groupResizeRef: { current: null },
70
- suppressClickRef: { current: false },
71
- gestureSelectedKeysRef: { current: new Set() },
72
- elementsRef: { current: [element] },
73
- trackOrderRef: { current: [0] },
74
- setDraggedClipState: setDraggedClip,
75
- setResizingClipState: () => {},
76
- setShowPopover: () => {},
77
- setRangeSelectionRef: { current: null },
78
- applyResizePointerRef: { current: () => {} },
79
- syncClipDragAutoScrollRef: { current: () => {} },
80
- stopClipDragAutoScrollRef: { current: stopAutoScroll },
81
- updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
82
- publishDraggedClip: setDraggedClip,
83
- updateElement: vi.fn(),
84
- onMoveElementRef: { current: onMoveElement },
85
- onMoveElementsRef: { current: undefined },
86
- onResizeElementRef: { current: undefined },
87
- onResizeElementsRef: { current: undefined },
88
- onBlockedEditAttemptRef: { current: undefined },
89
- readZIndexRef: { current: undefined },
90
- onStackingPatchesRef: { current: undefined },
91
- refreshAfterLaneMoveRef: { current: undefined },
92
- });
93
-
115
+ const g = mountGesture("drag");
94
116
  const sourceRow = document.createElement("div");
95
117
  sourceRow.dataset.timelineRow = "";
96
118
  sourceRow.append(document.createElement("div"));
@@ -98,15 +120,49 @@ describe("timeline clip drag gesture lifecycle", () => {
98
120
  sourceRow.remove();
99
121
 
100
122
  window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
101
- expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
102
- expect(draggedClipRef.current?.previewStart).toBe(3);
123
+ expect(g.updateDraggedClipPreview).toHaveBeenCalledTimes(1);
124
+ expect(g.draggedClipRef.current?.previewStart).toBe(3);
103
125
 
104
126
  window.dispatchEvent(new MouseEvent("pointerup"));
105
- expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
106
- expect(draggedClipRef.current).toBeNull();
107
- expect(stopAutoScroll).toHaveBeenCalledTimes(1);
127
+ expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
128
+ expect(g.draggedClipRef.current).toBeNull();
129
+ expect(g.stopAutoScroll).toHaveBeenCalledTimes(1);
130
+
131
+ g.dispose();
132
+ expect(g.cancelGestureRef.current()).toBe(false);
133
+ });
134
+
135
+ it.each([
136
+ ["above the window", 20, -40],
137
+ ["past the window's right edge", window.innerWidth + 40, 10],
138
+ ["on the window's right edge", window.innerWidth, 10],
139
+ ["on the window's bottom edge", 20, window.innerHeight],
140
+ ])("cancels a drag released %s and leaves the clip where it was", (_, clientX, clientY) => {
141
+ const g = mountGesture("drag");
142
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
143
+ release(clientX, clientY);
144
+ expect(g.onMoveElement).not.toHaveBeenCalled();
145
+ expect(g.updateElement).not.toHaveBeenCalled();
146
+ expect(g.draggedClipRef.current).toBeNull();
147
+ // The cancel ended the gesture, so a later release has nothing to commit.
148
+ release(20, 10);
149
+ expect(g.onMoveElement).not.toHaveBeenCalled();
150
+ g.dispose();
151
+ });
152
+
153
+ it("cancels a trim released outside the window", () => {
154
+ const g = mountGesture("resize");
155
+ release(20, -40);
156
+ expect(g.onResizeElement).not.toHaveBeenCalled();
157
+ expect(g.updateElement).not.toHaveBeenCalled();
158
+ expect(g.resizingClipRef.current).toBeNull();
159
+ g.dispose();
160
+ });
108
161
 
109
- dispose();
110
- expect(cancelGestureRef.current()).toBe(false);
162
+ it("still commits a drag released on the window's last pixel", () => {
163
+ const g = mountGesture("drag");
164
+ release(window.innerWidth - 1, window.innerHeight - 1);
165
+ expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 1, track: 0 });
166
+ g.dispose();
111
167
  });
112
168
  });
@@ -8,6 +8,7 @@ import type {
8
8
  } from "./timelineClipDragTypes";
9
9
  import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
10
10
  import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
11
+ import { releasedOutsideWindow } from "./timelinePointerRelease";
11
12
  import {
12
13
  beginTimelineOptimisticGesture,
13
14
  rollbackLatestTimelineOptimisticGesture,
@@ -349,6 +350,7 @@ export function mountTimelineClipDragGestureLifecycle({
349
350
  };
350
351
 
351
352
  const handleWindowPointerUp = (event: PointerEvent) => {
353
+ if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
352
354
  const claimed = claimActiveGesture(event);
353
355
  if (claimed === "ignored") return;
354
356
  if (claimed) {
@@ -0,0 +1,4 @@
1
+ export function releasedOutsideWindow(point: Pick<MouseEvent, "clientX" | "clientY">): boolean {
2
+ const { clientX: x, clientY: y } = point;
3
+ return x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight;
4
+ }
@@ -71,6 +71,8 @@ export function useTimelineProviderState({
71
71
  canPasteClip,
72
72
  theme: themeOverrides,
73
73
  sessionEpoch = 0,
74
+ previewIframeRef,
75
+ onZIndexReorder,
74
76
  }: TimelineProps = {}): TimelineContextValue {
75
77
  const {
76
78
  onMoveElement,
@@ -184,6 +186,8 @@ export function useTimelineProviderState({
184
186
  });
185
187
  const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
186
188
  expandedElementsRef: timelineElementsRef,
189
+ previewIframeRef,
190
+ onZIndexReorder,
187
191
  });
188
192
  const {
189
193
  draggedClip,
@@ -73,4 +73,50 @@ describe("useTimelineStackingSync", () => {
73
73
  mocks.actions = null;
74
74
  iframe.remove();
75
75
  });
76
+
77
+ it("uses a host's iframe and commit over Studio's own", async () => {
78
+ const studioCommit = vi.fn().mockResolvedValue(undefined);
79
+ mocks.actions = {
80
+ previewIframeRef: { current: null },
81
+ handleDomZIndexReorderCommit: studioCommit,
82
+ };
83
+ const iframe = document.createElement("iframe");
84
+ document.body.appendChild(iframe);
85
+ const node = iframe.contentDocument!.createElement("div");
86
+ node.id = "a";
87
+ iframe.contentDocument!.body.appendChild(node);
88
+ const hostCommit = vi.fn().mockResolvedValue(undefined);
89
+ const element: TimelineElement = {
90
+ id: "a",
91
+ key: "a",
92
+ tag: "div",
93
+ start: 0,
94
+ duration: 2,
95
+ track: 0,
96
+ };
97
+ let sync: ReturnType<typeof useTimelineStackingSync> | null = null;
98
+ function Harness() {
99
+ sync = useTimelineStackingSync({
100
+ expandedElementsRef: { current: [element] },
101
+ previewIframeRef: { current: iframe },
102
+ onZIndexReorder: hostCommit,
103
+ });
104
+ return null;
105
+ }
106
+ const root = mountReactHarness(<Harness />);
107
+
108
+ await act(async () => {
109
+ await sync!.applyStackingPatches([{ key: "a", zIndex: 3 }]);
110
+ });
111
+
112
+ expect(sync!.zSyncEnabled).toBe(true);
113
+ expect(hostCommit).toHaveBeenCalledWith(
114
+ [expect.objectContaining({ element: node, zIndex: 3 })],
115
+ undefined,
116
+ );
117
+ expect(studioCommit).not.toHaveBeenCalled();
118
+ act(() => root.unmount());
119
+ mocks.actions = null;
120
+ iframe.remove();
121
+ });
76
122
  });