@hyperframes/studio 0.8.81 → 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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
  4. package/dist/assets/{index-CS5SImRT.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 +834 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +26378 -28802
  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/TimelineHistoryButtons.dom.test.tsx +38 -1
  29. package/src/components/TimelineHistoryButtons.tsx +26 -14
  30. package/src/components/TimelineToolbar.test.tsx +9 -1
  31. package/src/components/TimelineToolbar.tsx +13 -5
  32. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  33. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  34. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  35. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  36. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  37. package/src/components/editor/DomEditOverlay.tsx +36 -21
  38. package/src/components/editor/LayersPanel.tsx +1 -1
  39. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  40. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  41. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  42. package/src/components/editor/clipAudioClock.test.ts +61 -0
  43. package/src/components/editor/clipAudioClock.ts +78 -0
  44. package/src/components/editor/manualEdits.test.ts +24 -0
  45. package/src/components/editor/manualEdits.ts +6 -2
  46. package/src/components/editor/manualEditsDom.ts +0 -65
  47. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  48. package/src/components/editor/marqueeCommit.ts +100 -37
  49. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
  51. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  52. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  53. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  54. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  55. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  56. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  57. package/src/components/editor/useFxCarve.ts +11 -15
  58. package/src/components/editor/useFxCarveNodes.ts +27 -15
  59. package/src/components/editor/useFxLevelling.ts +20 -31
  60. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  61. package/src/components/feedback/projectProvenance.ts +2 -3
  62. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  63. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  64. package/src/components/nle/PreviewOverlays.tsx +7 -188
  65. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  66. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  67. package/src/components/pointerTargetSize.test.tsx +1 -1
  68. package/src/components/studioColorGradingScope.ts +12 -36
  69. package/src/components/ui/HyperframesLogo.tsx +120 -0
  70. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  71. package/src/contexts/TimelineEditContext.tsx +27 -23
  72. package/src/domEditExports.test.tsx +69 -0
  73. package/src/editorGesturesExports.test.tsx +236 -0
  74. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  75. package/src/hooks/gsapMutationClient.ts +116 -0
  76. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  77. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  78. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  79. package/src/hooks/timelineEditingHelpers.ts +66 -61
  80. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  81. package/src/hooks/timelineTimingSync.test.ts +12 -0
  82. package/src/hooks/timelineTimingSync.ts +31 -123
  83. package/src/hooks/timelineTrackVisibility.ts +15 -29
  84. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  85. package/src/hooks/useAskAgentModal.ts +3 -1
  86. package/src/hooks/useClipboard.source.test.ts +32 -0
  87. package/src/hooks/useClipboard.ts +21 -20
  88. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  89. package/src/hooks/useDomEditCommits.ts +88 -97
  90. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  91. package/src/hooks/useEditorSave.ts +1 -1
  92. package/src/hooks/useElementLifecycleOps.ts +37 -38
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  94. package/src/hooks/useFileManager.ts +21 -12
  95. package/src/hooks/useGroupCommits.ts +23 -24
  96. package/src/hooks/useGsapAnimationOps.ts +26 -8
  97. package/src/hooks/useGsapScriptCommits.ts +1 -0
  98. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  99. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  100. package/src/hooks/usePreviewPersistence.ts +1 -1
  101. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  102. package/src/hooks/useThumbnailStripSize.ts +31 -0
  103. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  104. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  105. package/src/hooks/useTimelineEditing.ts +3 -1
  106. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  107. package/src/index.ts +53 -32
  108. package/src/lightEntries.test.ts +32 -0
  109. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  110. package/src/player/components/CompositionThumbnail.tsx +6 -21
  111. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  112. package/src/player/components/ImageThumbnail.tsx +4 -18
  113. package/src/player/components/PlayerControls.test.tsx +52 -0
  114. package/src/player/components/PlayerControls.tsx +2 -1
  115. package/src/player/components/Timeline.test.ts +28 -3
  116. package/src/player/components/TimelineCanvas.tsx +5 -3
  117. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  118. package/src/player/components/TimelineClipFades.tsx +2 -1
  119. package/src/player/components/TimelineTypes.ts +4 -2
  120. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  121. package/src/player/components/VideoThumbnail.tsx +7 -29
  122. package/src/player/components/studioShortcuts.ts +8 -5
  123. package/src/player/components/thumbnailUtils.test.ts +16 -20
  124. package/src/player/components/thumbnailUtils.ts +6 -7
  125. package/src/player/components/timelineClipChildren.test.ts +34 -1
  126. package/src/player/components/timelineClipChildren.tsx +7 -4
  127. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  128. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  129. package/src/player/components/timelineEditing.test.ts +55 -0
  130. package/src/player/components/timelineGroupEditing.ts +13 -6
  131. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  132. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  133. package/src/player/components/timelinePointerRelease.ts +4 -0
  134. package/src/player/components/useTimelinePlayhead.ts +26 -8
  135. package/src/player/components/useTimelineProviderState.tsx +4 -0
  136. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  137. package/src/player/components/useTimelineStackingSync.ts +18 -11
  138. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  139. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  140. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  141. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  142. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  143. package/src/player/lib/playbackShortcuts.ts +18 -0
  144. package/src/player/lib/playbackTypes.ts +1 -0
  145. package/src/player/lib/timelineDOM.test.ts +92 -0
  146. package/src/player/lib/timelineDOM.ts +0 -2
  147. package/src/player/lib/timelineElementHelpers.ts +47 -10
  148. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  149. package/src/player/store/timelineElement.ts +2 -1
  150. package/src/playerEntry.ts +1 -0
  151. package/src/timelineChromeExports.test.tsx +79 -0
  152. package/src/timelineStackingSyncExport.test.tsx +67 -0
  153. package/src/ui.ts +31 -0
  154. package/src/utils/authoredSource.ts +15 -0
  155. package/src/utils/blockInstaller.ts +32 -27
  156. package/src/utils/mediaTypes.test.ts +33 -0
  157. package/src/utils/mediaTypes.ts +12 -5
  158. package/src/utils/studioFileHistory.test.ts +14 -14
  159. package/src/utils/studioFileHistory.ts +31 -2
  160. package/dist/chunk-I7XNAIOF.js.map +0 -1
  161. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -1,4 +1,4 @@
1
- import { useStudioShellContext } from "../contexts/StudioContext";
1
+ import { useStudioShellContextOptional } from "../contexts/StudioContext";
2
2
  import { RotateCcw, RotateCw } from "../icons/SystemIcons";
3
3
  import { trackStudioEvent } from "../utils/studioTelemetry";
4
4
  import { historyTooltipLabel } from "../utils/studioHelpers";
@@ -7,13 +7,14 @@ import { Tooltip } from "./ui";
7
7
 
8
8
  interface HistoryButtonProps {
9
9
  action: "undo" | "redo";
10
- enabled: boolean;
10
+ can: boolean;
11
11
  label: string | undefined;
12
- onClick: () => Promise<void> | void;
12
+ onClick: (() => Promise<void> | void) | undefined;
13
13
  }
14
14
 
15
- function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps) {
15
+ function HistoryButton({ action, can, label, onClick }: HistoryButtonProps) {
16
16
  const Icon = action === "undo" ? RotateCcw : RotateCw;
17
+ const enabled = Boolean(onClick) && can;
17
18
  return (
18
19
  <Tooltip label={historyTooltipLabel(action, label)}>
19
20
  <button
@@ -23,7 +24,7 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
23
24
  className={enabled ? flatIdle : flatDisabled}
24
25
  onClick={() => {
25
26
  trackStudioEvent("toolbar_action", { action });
26
- void onClick();
27
+ void onClick?.();
27
28
  }}
28
29
  >
29
30
  <Icon size={16} />
@@ -32,22 +33,33 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
32
33
  );
33
34
  }
34
35
 
35
- /** Undo and Redo, on the one edit-history path the shell already owns. */
36
- export function TimelineHistoryButtons() {
37
- const { editHistory, handleUndo, handleRedo } = useStudioShellContext();
36
+ export interface TimelineHistoryButtonsProps {
37
+ canUndo?: boolean;
38
+ canRedo?: boolean;
39
+ undoLabel?: string;
40
+ redoLabel?: string;
41
+ onUndo?: () => Promise<void> | void;
42
+ onRedo?: () => Promise<void> | void;
43
+ }
44
+
45
+ /** Undo and Redo: a host's props win, else the shell's edit history. */
46
+ export function TimelineHistoryButtons(props: TimelineHistoryButtonsProps) {
47
+ const shell = useStudioShellContextOptional();
48
+ const onUndo = props.onUndo ?? shell?.handleUndo;
49
+ const onRedo = props.onRedo ?? shell?.handleRedo;
38
50
  return (
39
51
  <>
40
52
  <HistoryButton
41
53
  action="undo"
42
- enabled={editHistory.canUndo}
43
- label={editHistory.undoLabel}
44
- onClick={handleUndo}
54
+ can={props.canUndo ?? shell?.editHistory.canUndo ?? false}
55
+ label={props.undoLabel ?? shell?.editHistory.undoLabel}
56
+ onClick={onUndo}
45
57
  />
46
58
  <HistoryButton
47
59
  action="redo"
48
- enabled={editHistory.canRedo}
49
- label={editHistory.redoLabel}
50
- onClick={handleRedo}
60
+ can={props.canRedo ?? shell?.editHistory.canRedo ?? false}
61
+ label={props.redoLabel ?? shell?.editHistory.redoLabel}
62
+ onClick={onRedo}
51
63
  />
52
64
  </>
53
65
  );
@@ -12,7 +12,7 @@ import { AudioMeterStrip } from "./nle/AudioMeterStrip";
12
12
  import { TimelineToolbar } from "./TimelineToolbar";
13
13
 
14
14
  vi.mock("../contexts/StudioContext", () => ({
15
- useStudioShellContext: () => ({
15
+ useStudioShellContextOptional: () => ({
16
16
  previewIframeRef: { current: null },
17
17
  editHistory: { canUndo: false, canRedo: false },
18
18
  handleUndo: vi.fn(),
@@ -214,3 +214,11 @@ describe("TimelineToolbar audio meters", () => {
214
214
  }
215
215
  });
216
216
  });
217
+
218
+ describe("TimelineToolbar add beat", () => {
219
+ it("shows Add beat by default, as Studio does", () => {
220
+ const { host, root } = renderToolbar();
221
+ expect(host.querySelector('button[aria-label="Add beat at playhead"]')).not.toBeNull();
222
+ act(() => root.unmount());
223
+ });
224
+ });
@@ -19,7 +19,7 @@ import { Tooltip } from "./ui";
19
19
  import { AudioMetersIcon } from "./icons/AudioMetersIcon";
20
20
  import { RippleEditIcon } from "./icons/RippleEditIcon";
21
21
  import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
22
- import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
22
+ import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
23
23
  import { Scissors } from "../icons/SystemIcons";
24
24
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
25
25
  import type { DomEditSelection } from "./editor/domEditingTypes";
@@ -33,9 +33,11 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
33
33
  selectedGsapAnimations: GsapAnimation[];
34
34
  }
35
35
 
36
- interface TimelineToolbarProps {
36
+ export interface TimelineToolbarProps {
37
37
  domEditSession?: DomEditSessionSlice;
38
38
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
39
+ history?: TimelineHistoryButtonsProps;
40
+ showAddBeat?: boolean;
39
41
  }
40
42
 
41
43
  interface KeyframeToggleState {
@@ -127,7 +129,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
127
129
  }
128
130
 
129
131
  // fallow-ignore-next-line complexity
130
- export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineToolbarProps) {
132
+ export function TimelineToolbar({
133
+ domEditSession,
134
+ onSplitElement,
135
+ history,
136
+ showAddBeat = true,
137
+ }: TimelineToolbarProps) {
131
138
  const activeTool = usePlayerStore((s) => s.activeTool);
132
139
  const setActiveTool = usePlayerStore((s) => s.setActiveTool);
133
140
  const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
@@ -195,7 +202,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
195
202
  <div className="border-b border-neutral-800/60">
196
203
  <div className="flex items-center justify-between px-2 py-0.5">
197
204
  <div className="flex items-center gap-0.5">
198
- <TimelineHistoryButtons />
205
+ <TimelineHistoryButtons {...history} />
199
206
  <Tooltip label="Selection tool (V)">
200
207
  <button
201
208
  type="button"
@@ -416,7 +423,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
416
423
  );
417
424
  })()}
418
425
  {(() => {
419
- // Always rendered (CapCut-style): before beat analysis loads (or when
426
+ if (!showAddBeat) return null;
427
+ // Rendered whenever shown (CapCut-style): before beat analysis loads (or when
420
428
  // the project has no analyzed music) the button fades to a disabled
421
429
  // state instead of unmounting, so the toolbar layout never shifts.
422
430
  const canAdd = beatAnalysisReady && canAddBeatAt(currentTime);
@@ -3,6 +3,7 @@ import type { BlockParam } from "@hyperframes/core/registry";
3
3
  import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
4
4
  import { useStudioPlaybackContext } from "../../contexts/StudioContext";
5
5
  import { trackBlockParamCommit } from "../../telemetry/events";
6
+ import { serializeStudioFileMutation } from "../../utils/studioFileMutationCoordinator";
6
7
 
7
8
  interface BlockParamsPanelProps {
8
9
  blockName: string;
@@ -77,7 +78,11 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
77
78
  trackBlockParamCommit({ tone: "error", blockName, key });
78
79
  return;
79
80
  }
80
- await fileManager.writeProjectFile(compositionPath, content.replace(matcher, nextValue));
81
+ await fileManager.writeProjectFile(
82
+ compositionPath,
83
+ content.replace(matcher, nextValue),
84
+ content,
85
+ );
81
86
  appliedRef.current[key] = nextValue;
82
87
  setCommitState({ tone: "saved" });
83
88
  trackBlockParamCommit({ tone: "saved", blockName, key });
@@ -90,16 +95,15 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
90
95
  [fileManager, compositionPath, setRefreshKey, blockName],
91
96
  );
92
97
 
93
- // Commits are serialized: two params committing concurrently would each
94
- // read-modify-write the same file and the second write would drop the first.
95
- const commitChainRef = useRef<Promise<void>>(Promise.resolve());
98
+ // Commits join the file's queue: two read-modify-writes of one file must not interleave.
96
99
  const commitParam = useCallback(
97
- (key: string, nextValue: string) => {
98
- const run = commitChainRef.current.then(() => commitParamNow(key, nextValue));
99
- commitChainRef.current = run.catch(() => undefined);
100
- return run;
101
- },
102
- [commitParamNow],
100
+ (key: string, nextValue: string) =>
101
+ fileManager
102
+ ? serializeStudioFileMutation(fileManager.writeProjectFile, compositionPath, () =>
103
+ commitParamNow(key, nextValue),
104
+ )
105
+ : Promise.resolve(),
106
+ [commitParamNow, compositionPath, fileManager],
103
107
  );
104
108
 
105
109
  const handleChange = useCallback(
@@ -3,7 +3,7 @@ import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6
- import { resolveZIndexEntries } from "../nle/PreviewOverlays";
6
+ import { resolveZIndexEntries } from "./ConnectedDomEditOverlay";
7
7
  import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
8
8
  import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
9
9
  import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
@@ -138,7 +138,7 @@ describe("CanvasContextMenu — handler gating", () => {
138
138
  });
139
139
  });
140
140
 
141
- // ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
141
+ // ── Menu z-action → commit path (wired the way ConnectedDomEditOverlay wires the app) ──
142
142
 
143
143
  function pressMenuItem(label: string) {
144
144
  const button = zOrderButtons().find((b) => b.textContent === label);
@@ -200,7 +200,7 @@ describe("CanvasContextMenu — z-action commit path", () => {
200
200
  const captured: CapturedBatchCall[] = [];
201
201
  const { commit, cleanup } = renderCommitHook(captured);
202
202
 
203
- // Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
203
+ // Wire onApplyZIndex the way the app does (ConnectedDomEditOverlay → the commit
204
204
  // hook), asserting the menu has NOT touched the DOM when it fires — the
205
205
  // hook must capture true pre-change styles for its rollback.
206
206
  const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
@@ -16,7 +16,7 @@
16
16
  * flow, and captures the TRUE prior styles for its failure rollback.
17
17
  *
18
18
  * The prop MUST be wired at the call site to route through the full persist
19
- * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
19
+ * path. ConnectedDomEditOverlay.tsx builds the per-patch PatchTargets (the selected
20
20
  * element carries its full selection identity; sibling elements are iframe DOM
21
21
  * nodes, so their id / selector are derived from the node and they share the
22
22
  * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
@@ -0,0 +1,163 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { getPreviewState, iframeRef } from "../nle/PreviewOverlaysTestMocks";
7
+ import { PreviewOverlays } from "../nle/PreviewOverlays";
8
+ import { ConnectedDomEditOverlay } from "./ConnectedDomEditOverlay";
9
+
10
+ // Overlay prop -> session handler, as PreviewOverlays wired DomEditOverlay itself.
11
+ const WIRING = {
12
+ onCanvasMouseDown: "handlePreviewCanvasMouseDown",
13
+ onCanvasPointerMove: "handlePreviewCanvasPointerMove",
14
+ onCanvasPointerLeave: "handlePreviewCanvasPointerLeave",
15
+ onSelectionChange: "applyDomSelection",
16
+ onBlockedMove: "handleBlockedDomMove",
17
+ onManualDragStart: "handleDomManualDragStart",
18
+ onPathOffsetCommit: "handleDomPathOffsetCommit",
19
+ onGroupPathOffsetCommit: "handleDomGroupPathOffsetCommit",
20
+ onBoxSizeCommit: "handleDomBoxSizeCommit",
21
+ onRotationCommit: "handleDomRotationCommit",
22
+ onStyleCommit: "handleDomStyleCommit",
23
+ onDeleteSelection: "handleDomEditElementDelete",
24
+ onMarqueeSelect: "applyMarqueeSelection",
25
+ } as const;
26
+
27
+ const ctx = vi.hoisted(() => ({
28
+ overlayProps: null as Record<string, unknown> | null,
29
+ actions: {} as Record<string, unknown>,
30
+ selection: {
31
+ domEditHoverSelection: { label: "hover" },
32
+ domEditSelection: { label: "selected" },
33
+ domEditGroupSelections: [{ label: "member" }],
34
+ },
35
+ }));
36
+
37
+ vi.mock("../../contexts/DomEditContext", () => ({
38
+ useDomEditSelectionContext: () => ctx.selection,
39
+ useDomEditActionsContext: () => ctx.actions,
40
+ }));
41
+ vi.mock("./DomEditOverlay", () => ({
42
+ DomEditOverlay: (props: Record<string, unknown>) => {
43
+ ctx.overlayProps = props;
44
+ return null;
45
+ },
46
+ }));
47
+ vi.mock("./TopologyLens", () => ({ TopologyLens: () => null }));
48
+ vi.mock("./MotionPathOverlay", () => ({ MotionPathOverlay: () => null }));
49
+ vi.mock("./SnapToolbar", () => ({ SnapToolbar: () => null }));
50
+ vi.mock("./GridOverlay", () => ({ GridOverlay: () => null }));
51
+
52
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
53
+
54
+ let root: Root;
55
+
56
+ function mount(node: React.ReactNode): Record<string, unknown> {
57
+ ctx.actions = {
58
+ previewIframeRef: iframeRef,
59
+ handleDomZIndexReorderCommit: vi.fn(() => Promise.resolve(undefined)),
60
+ };
61
+ for (const name of Object.values(WIRING)) ctx.actions[name] = vi.fn();
62
+ const host = document.createElement("div");
63
+ document.body.append(host);
64
+ root = createRoot(host);
65
+ act(() => root.render(node));
66
+ return ctx.overlayProps!;
67
+ }
68
+
69
+ afterEach(() => {
70
+ act(() => root.unmount());
71
+ ctx.overlayProps = null;
72
+ getPreviewState().isPlaying = false;
73
+ document.body.replaceChildren();
74
+ });
75
+
76
+ describe("ConnectedDomEditOverlay", () => {
77
+ it("is what PreviewOverlays renders, with every handler from the session", () => {
78
+ const props = mount(
79
+ <PreviewOverlays
80
+ shouldShowMotionPath={false}
81
+ shouldShowSelectedDomBounds={true}
82
+ recordingState={"idle" as never}
83
+ onToggleRecording={vi.fn()}
84
+ />,
85
+ );
86
+ // The overlay never read these, so the connected wrapper does not pass them.
87
+ expect(props).not.toHaveProperty("recordingState");
88
+ expect(props).not.toHaveProperty("onToggleRecording");
89
+ for (const [prop, handler] of Object.entries(WIRING)) {
90
+ expect(props[prop], prop).toBe(ctx.actions[handler]);
91
+ }
92
+ expect(props).toMatchObject({
93
+ iframeRef,
94
+ activeCompositionPath: "index.html",
95
+ hoverSelection: ctx.selection.domEditHoverSelection,
96
+ selection: ctx.selection.domEditSelection,
97
+ groupSelections: ctx.selection.domEditGroupSelections,
98
+ allowCanvasMovement: true,
99
+ canvasInput: undefined,
100
+ onTextEditingChange: undefined,
101
+ });
102
+ });
103
+
104
+ it("shows no hover box while playback runs", () => {
105
+ getPreviewState().isPlaying = true;
106
+ const props = mount(
107
+ <PreviewOverlays shouldShowMotionPath={false} shouldShowSelectedDomBounds={true} />,
108
+ );
109
+ expect(props.hoverSelection).toBeNull();
110
+ expect(props.selection).toBe(ctx.selection.domEditSelection);
111
+ });
112
+
113
+ it("hides the selection and stops movement while a gesture records", () => {
114
+ const props = mount(
115
+ <PreviewOverlays
116
+ shouldShowMotionPath={false}
117
+ shouldShowSelectedDomBounds={false}
118
+ isGestureRecording={true}
119
+ />,
120
+ );
121
+ expect(props).toMatchObject({
122
+ selection: null,
123
+ groupSelections: [],
124
+ allowCanvasMovement: false,
125
+ });
126
+ });
127
+
128
+ it("persists a z-order change through the session's reorder commit", () => {
129
+ const props = mount(
130
+ <PreviewOverlays shouldShowMotionPath={false} shouldShowSelectedDomBounds={true} />,
131
+ );
132
+ const element = document.createElement("div");
133
+ element.id = "card";
134
+ const sel = { element, id: "card", selector: "#card", sourceFile: "index.html" };
135
+ const apply = props.onApplyZIndex as (...args: unknown[]) => void;
136
+ act(() => apply(sel, [{ element, zIndex: 4 }], "bring-forward", null));
137
+ expect(ctx.actions.handleDomZIndexReorderCommit).toHaveBeenCalledWith(
138
+ [expect.objectContaining({ element, zIndex: 4, id: "card", sourceFile: "index.html" })],
139
+ expect.stringContaining("z-reorder:bring-forward"),
140
+ "bring-forward",
141
+ );
142
+ });
143
+
144
+ it("passes a host's canvas input, text-editing listener and hover gate to the overlay", () => {
145
+ const onTextEditingChange = vi.fn();
146
+ const props = mount(
147
+ <ConnectedDomEditOverlay
148
+ activeCompositionPath={null}
149
+ showHoverSelection={false}
150
+ shouldShowSelectedDomBounds={true}
151
+ canvasInput="host"
152
+ onTextEditingChange={onTextEditingChange}
153
+ />,
154
+ );
155
+ expect(props).toMatchObject({
156
+ canvasInput: "host",
157
+ onTextEditingChange,
158
+ hoverSelection: null,
159
+ selection: ctx.selection.domEditSelection,
160
+ iframeRef,
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,206 @@
1
+ import type { ComponentProps } from "react";
2
+ import { DomEditOverlay } from "./DomEditOverlay";
3
+ import {
4
+ useDomEditActionsContext,
5
+ useDomEditSelectionContext,
6
+ } from "../../contexts/DomEditContext";
7
+ import { readHfId, type DomEditSelection } from "./domEditing";
8
+ import { buildStableSelector } from "./domEditingDom";
9
+ import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
10
+ import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
11
+ import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
12
+ import { runZLaneGesture } from "../nle/zLaneGesture";
13
+
14
+ type HostInputProps = Pick<
15
+ ComponentProps<typeof DomEditOverlay>,
16
+ "canvasInput" | "onSelectionBoxClick"
17
+ >;
18
+
19
+ export interface ConnectedDomEditOverlayProps extends HostInputProps {
20
+ activeCompositionPath: string | null;
21
+ showHoverSelection: boolean;
22
+ shouldShowSelectedDomBounds: boolean;
23
+ isGestureRecording?: boolean;
24
+ /** True when an in-place text edit opens its caret, false when it ends or unmounts. */
25
+ onTextEditingChange?: (editing: boolean) => void;
26
+ }
27
+
28
+ type ZIndexReorderEntry = {
29
+ element: HTMLElement;
30
+ zIndex: number;
31
+ id?: string;
32
+ selector?: string;
33
+ selectorIndex?: number;
34
+ sourceFile: string;
35
+ /** Timeline store key — lets the commit update the store zIndex synchronously. */
36
+ key?: string;
37
+ };
38
+
39
+ /** Can this element be robustly re-targeted for a persisted z change? */
40
+ function canTargetZIndexElement(
41
+ element: HTMLElement,
42
+ id: string | undefined,
43
+ selector: string | undefined,
44
+ ): boolean {
45
+ return Boolean(id || selector || readHfId(element));
46
+ }
47
+
48
+ /** The selected element carries its full selection identity. */
49
+ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
50
+ return {
51
+ element: sel.element,
52
+ zIndex,
53
+ id: sel.id ?? undefined,
54
+ selector: sel.selector,
55
+ selectorIndex: sel.selectorIndex,
56
+ sourceFile: sel.sourceFile,
57
+ key: deriveTimelineStoreKey({
58
+ domId: sel.id ?? undefined,
59
+ selector: sel.selector,
60
+ selectorIndex: sel.selectorIndex,
61
+ sourceFile: sel.sourceFile,
62
+ }),
63
+ };
64
+ }
65
+
66
+ /** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). */
67
+ function siblingZIndexEntry(
68
+ element: HTMLElement,
69
+ zIndex: number,
70
+ sourceFile: string,
71
+ ): ZIndexReorderEntry | null {
72
+ const id = element.id || undefined;
73
+ const selector = buildStableSelector(element);
74
+ if (!canTargetZIndexElement(element, id, selector)) return null;
75
+ return {
76
+ element,
77
+ zIndex,
78
+ id,
79
+ selector,
80
+ selectorIndex: undefined,
81
+ sourceFile,
82
+ key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
83
+ };
84
+ }
85
+
86
+ /** Short human-readable label for a dropped sibling, for the console warning below. */
87
+ function describeZIndexElement(element: HTMLElement): string {
88
+ if (element.id) return `#${element.id}`;
89
+ const firstClass = element.classList.item(0);
90
+ return firstClass
91
+ ? `${element.tagName.toLowerCase()}.${firstClass}`
92
+ : element.tagName.toLowerCase();
93
+ }
94
+
95
+ // Resolve z-index patches into commit entries; a sibling with no stable
96
+ // id/selector can't be written to source, so it is returned as `dropped` for
97
+ // the revert-on-reload warning. Exported so tests can drive the menu → commit path.
98
+ export function resolveZIndexEntries(
99
+ sel: DomEditSelection,
100
+ patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
101
+ ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
102
+ const entries: ZIndexReorderEntry[] = [];
103
+ const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
104
+ for (const patch of patches) {
105
+ if (patch.element === sel.element) {
106
+ entries.push(selectedZIndexEntry(sel, patch.zIndex));
107
+ continue;
108
+ }
109
+ const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
110
+ if (entry) entries.push(entry);
111
+ else dropped.push(patch);
112
+ }
113
+ return { entries, dropped };
114
+ }
115
+
116
+ // Studio's DomEditOverlay wired to the session in DomEditProvider, so a host
117
+ // outside EditorShell mounts the same canvas editing without its own callbacks.
118
+ export function ConnectedDomEditOverlay({
119
+ activeCompositionPath,
120
+ showHoverSelection,
121
+ shouldShowSelectedDomBounds,
122
+ isGestureRecording,
123
+ canvasInput,
124
+ onTextEditingChange,
125
+ onSelectionBoxClick,
126
+ }: ConnectedDomEditOverlayProps) {
127
+ const { domEditHoverSelection, domEditSelection, domEditGroupSelections } =
128
+ useDomEditSelectionContext();
129
+ const {
130
+ previewIframeRef,
131
+ handlePreviewCanvasMouseDown,
132
+ handlePreviewCanvasPointerMove,
133
+ handlePreviewCanvasPointerLeave,
134
+ applyDomSelection,
135
+ handleBlockedDomMove,
136
+ handleDomManualDragStart,
137
+ handleDomPathOffsetCommit,
138
+ handleDomGroupPathOffsetCommit,
139
+ handleDomBoxSizeCommit,
140
+ handleDomRotationCommit,
141
+ handleDomStyleCommit,
142
+ applyMarqueeSelection,
143
+ handleDomEditElementDelete,
144
+ handleDomZIndexReorderCommit,
145
+ } = useDomEditActionsContext();
146
+ const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
147
+
148
+ return (
149
+ <DomEditOverlay
150
+ iframeRef={previewIframeRef}
151
+ activeCompositionPath={activeCompositionPath}
152
+ hoverSelection={showHoverSelection ? domEditHoverSelection : null}
153
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
154
+ groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
155
+ allowCanvasMovement={!isGestureRecording}
156
+ canvasInput={canvasInput}
157
+ onTextEditingChange={onTextEditingChange}
158
+ onSelectionBoxClick={onSelectionBoxClick}
159
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
160
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
161
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
162
+ onSelectionChange={applyDomSelection}
163
+ onBlockedMove={handleBlockedDomMove}
164
+ onManualDragStart={handleDomManualDragStart}
165
+ onPathOffsetCommit={handleDomPathOffsetCommit}
166
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
167
+ onBoxSizeCommit={handleDomBoxSizeCommit}
168
+ onRotationCommit={handleDomRotationCommit}
169
+ onStyleCommit={handleDomStyleCommit}
170
+ onDeleteSelection={handleDomEditElementDelete}
171
+ onApplyZIndex={(sel, patches, action, crossed) => {
172
+ const { entries, dropped } = resolveZIndexEntries(sel, patches);
173
+ if (dropped.length > 0) {
174
+ // These siblings can't be written to source. Apply their live z
175
+ // anyway so the resolved stacking order renders coherently — it
176
+ // just reverts to the prior order on the next reload.
177
+ for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
178
+ console.warn(
179
+ "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
180
+ "(will revert on reload):",
181
+ dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
182
+ );
183
+ }
184
+ if (entries.length === 0) return;
185
+ // One coalesce key for the z persist AND the lane mirror folds both into one undo entry;
186
+ // passed explicitly so the mirror shares it by construction, not by formula duplication.
187
+ const coalesceKey = zReorderCoalesceKey(entries, action);
188
+ // One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
189
+ // no second gesture interleaves (see runZLaneGesture). A failed z commit has already
190
+ // toasted and rolled back, so the catch only keeps its rejection from going unhandled.
191
+ runZLaneGesture({
192
+ commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
193
+ mirror: () =>
194
+ mirrorZOrderToTimeline({
195
+ selectionKey: entries.find((e) => e.element === sel.element)?.key,
196
+ action,
197
+ crossed,
198
+ sourceFile: sel.sourceFile,
199
+ coalesceKey,
200
+ }),
201
+ }).catch(() => undefined);
202
+ }}
203
+ onMarqueeSelect={applyMarqueeSelection}
204
+ />
205
+ );
206
+ }