@hyperframes/studio 0.8.51 → 0.8.53-desktop.0

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 (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.51",
3
+ "version": "0.8.53-desktop.0",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,6 +30,7 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
+ "@base-ui/react": "1.7.0",
33
34
  "@codemirror/autocomplete": "^6.20.1",
34
35
  "@codemirror/commands": "^6.10.3",
35
36
  "@codemirror/lang-css": "^6.3.1",
@@ -45,14 +46,15 @@
45
46
  "@phosphor-icons/react": "^2.1.10",
46
47
  "@tanstack/react-virtual": "^3.14.6",
47
48
  "bpm-detective": "^2.0.5",
49
+ "cn": "0.2.4",
48
50
  "dockview-react": "8.3.1",
49
51
  "gsap": "^3.13.0",
50
52
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.51",
52
- "@hyperframes/player": "0.8.51",
53
- "@hyperframes/studio-server": "0.8.51",
54
- "@hyperframes/sdk": "0.8.51",
55
- "@hyperframes/parsers": "0.8.51"
53
+ "@hyperframes/parsers": "0.8.53-desktop.0",
54
+ "@hyperframes/core": "0.8.53-desktop.0",
55
+ "@hyperframes/studio-server": "0.8.53-desktop.0",
56
+ "@hyperframes/sdk": "0.8.53-desktop.0",
57
+ "@hyperframes/player": "0.8.53-desktop.0"
56
58
  },
57
59
  "devDependencies": {
58
60
  "@tailwindcss/vite": "^4.3.3",
@@ -68,7 +70,7 @@
68
70
  "vite": "^6.4.2",
69
71
  "vitest": "^4.1.11",
70
72
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.51"
73
+ "@hyperframes/producer": "0.8.53-desktop.0"
72
74
  },
73
75
  "peerDependencies": {
74
76
  "react": "19",
package/src/App.tsx CHANGED
@@ -1,6 +1,7 @@
1
+ import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
1
2
  import { buildProjectApiPath } from "./utils/projectRouting";
2
3
  import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
3
- import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
4
+ import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
4
5
  import { useRenderQueue } from "./components/renders/useRenderQueue";
5
6
  import { usePlayerStore } from "./player";
6
7
  import { StudioOverlays } from "./components/StudioOverlays";
@@ -47,9 +48,9 @@ import type { DomEditSelection } from "./components/editor/domEditing";
47
48
  import { StudioHeader } from "./components/StudioHeader";
48
49
  import { useGestureCommit } from "./hooks/useGestureCommit";
49
50
  import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
50
- import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
51
+ import { StudioLeftPanels } from "./components/StudioLeftPanels";
51
52
  import { EditorShell } from "./components/EditorShell";
52
- import { StudioRightPanel } from "./components/StudioRightPanel";
53
+ import { StudioRightPanels } from "./components/StudioRightPanels";
53
54
  import { TimelineToolbar } from "./components/TimelineToolbar";
54
55
  import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
55
56
  import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
@@ -68,13 +69,12 @@ export function StudioApp() {
68
69
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
69
70
  useStudioSessionStart(projectId, resolving, waitingForServer);
70
71
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
71
- const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
72
+ const previewIframe = useOwnPreviewIframe();
72
73
  const [compositionLoading, setCompositionLoading] = useState(true);
73
74
  const [refreshKey, setRefreshKey] = useState(0);
74
75
  const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
75
76
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
76
77
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
77
- const leftSidebarRef = useRef<LeftSidebarHandle>(null);
78
78
  const captionEditMode = useCaptionStore((s) => s.isEditMode);
79
79
  const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
80
80
  const captionSync = useCaptionSync(projectId);
@@ -201,6 +201,13 @@ export function StudioApp() {
201
201
  setRightCollapsed: panelLayout.setRightCollapsed,
202
202
  setRightPanelTab: panelLayout.setRightPanelTab,
203
203
  });
204
+ const dismissBlockParams = useCallback(() => setActiveBlockParams(null), [setActiveBlockParams]);
205
+ const setRightPanelTab = useDismissingTabSetter(panelLayout.setRightPanelTab, dismissBlockParams);
206
+ const layout = useMemo(
207
+ () => ({ ...panelLayout, setRightPanelTab }),
208
+ [panelLayout, setRightPanelTab],
209
+ );
210
+
204
211
  const clearDomSelectionRef = useRef<() => void>(() => {});
205
212
  const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
206
213
  type DomEditDelete = (s: DomEditSelection, o?: { expandGroup?: boolean }) => Promise<void>;
@@ -233,7 +240,6 @@ export function StudioApp() {
233
240
  showToast,
234
241
  syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
235
242
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
236
- leftSidebarRef,
237
243
  handleCopy,
238
244
  handlePaste,
239
245
  handleCut,
@@ -247,10 +253,6 @@ export function StudioApp() {
247
253
  forceReloadSdkSession: sdkHandle.forceReload,
248
254
  onToggleRecording: () => handleToggleRecordingRef.current(),
249
255
  });
250
- const sidebarTabRef = useRef({
251
- select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t),
252
- get: () => leftSidebarRef.current?.getTab() ?? "compositions",
253
- });
254
256
  const domEditSession = useDomEditSession({
255
257
  projectId,
256
258
  activeCompPath,
@@ -263,7 +265,7 @@ export function StudioApp() {
263
265
  setSelectedTimelineElementId,
264
266
  setTimelineSelectionSet,
265
267
  setRightCollapsed: panelLayout.setRightCollapsed,
266
- setRightPanelTab: panelLayout.setRightPanelTab,
268
+ setRightPanelTab,
267
269
  showToast,
268
270
  isRecordingRef: isGestureRecordingRef,
269
271
  refreshPreviewDocumentVersion,
@@ -285,8 +287,6 @@ export function StudioApp() {
285
287
  reloadPreview,
286
288
  setRefreshKey,
287
289
  openSourceForSelection: fileManager.openSourceForSelection,
288
- selectSidebarTab: sidebarTabRef.current.select,
289
- getSidebarTab: sidebarTabRef.current.get,
290
290
  sdkSession: editFlowSdkSession,
291
291
  publishSdkSession: sdkHandle.publish,
292
292
  forceReloadSdkSession: sdkHandle.forceReload,
@@ -359,27 +359,25 @@ export function StudioApp() {
359
359
  const handlePreviewIframeRef = useCallback(
360
360
  (iframe: HTMLIFrameElement | null) => {
361
361
  previewIframeRef.current = iframe;
362
- setPreviewIframe(iframe);
362
+ usePreviewIframeStore.getState().setIframe(iframe);
363
363
  appHotkeys.syncPreviewHotkeys(iframe);
364
364
  resetConsoleErrors();
365
365
  refreshPreviewDocumentVersion();
366
366
  },
367
367
  [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
368
368
  );
369
- const {
370
- designPanelActive,
371
- inspectorPanelActive,
372
- inspectorButtonActive,
373
- shouldShowMotionPath,
374
- shouldShowSelectedDomBounds,
375
- } = useInspectorState(
376
- panelLayout.rightPanelTab,
377
- panelLayout.rightInspectorPanes,
378
- panelLayout.effectiveRightCollapsed,
379
- isPlaying,
380
- domEditSession.domEditSelection,
381
- gestureState === "recording",
382
- );
369
+ const rightPanel = useRightPanelIntent();
370
+ const { inspectorPanelActive, shouldShowMotionPath, shouldShowSelectedDomBounds } =
371
+ useInspectorState(
372
+ rightPanel,
373
+ isPlaying,
374
+ domEditSession.domEditSelection,
375
+ gestureState === "recording",
376
+ );
377
+ // The dock has no separate "railed by window width" state (it shrinks
378
+ // panels, never auto-hides the group), so rightCollapsed is already the
379
+ // value that decides whether the panel is actually showing.
380
+ const inspectorButtonActive = !panelLayout.rightCollapsed && inspectorPanelActive;
383
381
  useStudioUrlState({
384
382
  projectId,
385
383
  activeCompPath,
@@ -396,7 +394,7 @@ export function StudioApp() {
396
394
  applyMarqueeSelection: domEditSession.applyMarqueeSelection,
397
395
  buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
398
396
  applyDomSelection: domEditSession.applyDomSelection,
399
- setRightPanelTab: panelLayout.setRightPanelTab,
397
+ setRightPanelTab,
400
398
  initialState: initialUrlStateRef.current,
401
399
  });
402
400
  const studioCtxValue = buildStudioContextValue({
@@ -436,7 +434,7 @@ export function StudioApp() {
436
434
  return (
437
435
  <StudioShellProvider value={studioCtxValue}>
438
436
  <StudioPlaybackProvider value={studioCtxValue}>
439
- <PanelLayoutProvider value={panelLayout}>
437
+ <PanelLayoutProvider value={layout}>
440
438
  <FileManagerProvider value={fileManager}>
441
439
  <DomEditProvider value={domEditSession}>
442
440
  <div
@@ -467,26 +465,23 @@ export function StudioApp() {
467
465
  )}
468
466
  <ExternalFileConflictBanner coordinator={externalFileChanges} />
469
467
  <EditorShell
470
- left={
471
- <StudioLeftSidebar
472
- leftSidebarRef={leftSidebarRef}
473
- onSelectComposition={handleSelectComposition}
474
- onAddBlock={handleAddBlock}
475
- onPreviewBlock={setBlockPreview}
476
- onLint={handleLint}
477
- linting={linting}
478
- lintFindingCount={lintModal?.length ?? findingsByFile.size}
479
- lintFindingsByFile={findingsByFile}
480
- lintHasError={hasLintError}
481
- onAddAssetToTimeline={handleAddAssetAtPlayhead}
482
- onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
483
- />
484
- }
485
- right={
486
- panelLayout.effectiveRightCollapsed ? null : (
487
- <StudioRightPanel
488
- designPanelActive={designPanelActive}
468
+ panels={
469
+ <>
470
+ <StudioLeftPanels
471
+ onSelectComposition={handleSelectComposition}
472
+ onAddBlock={handleAddBlock}
473
+ onPreviewBlock={setBlockPreview}
474
+ onLint={handleLint}
475
+ linting={linting}
476
+ lintFindingCount={lintModal?.length ?? findingsByFile.size}
477
+ lintFindingsByFile={findingsByFile}
478
+ lintHasError={hasLintError}
479
+ onAddAssetToTimeline={handleAddAssetAtPlayhead}
480
+ onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
481
+ />
482
+ <StudioRightPanels
489
483
  activeBlockParams={activeBlockParams}
484
+ onDismissBlockParams={dismissBlockParams}
490
485
  onCloseBlockParams={() => {
491
486
  setActiveBlockParams(null);
492
487
  panelLayout.setRightPanelTab("design");
@@ -503,7 +498,7 @@ export function StudioApp() {
503
498
  onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
504
499
  onAddMediaOverlay={handleAddMediaOverlay}
505
500
  />
506
- )
501
+ </>
507
502
  }
508
503
  timelineToolbar={timelineToolbar}
509
504
  renderClipContent={renderClipContent}
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
4
4
  import { expect, it, vi } from "vitest";
5
5
  import { CaptionOverlay } from "./CaptionOverlay";
6
6
  import { useCaptionStore } from "../store";
7
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
7
8
 
8
9
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
10
 
@@ -45,3 +46,54 @@ it("only schedules caption layout for messages from its preview", () => {
45
46
  vi.unstubAllGlobals();
46
47
  }
47
48
  });
49
+
50
+ it("observes and accepts messages from the promoted iframe, not the old one", () => {
51
+ const host = document.createElement("div");
52
+ const a = document.createElement("iframe");
53
+ const b = document.createElement("iframe");
54
+ document.body.append(host, a, b);
55
+ const ref = { current: a };
56
+ const root = createRoot(host);
57
+ const schedule = vi.fn(() => 1);
58
+ const observed: Element[] = [];
59
+ vi.stubGlobal("requestAnimationFrame", schedule);
60
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
61
+ vi.stubGlobal(
62
+ "ResizeObserver",
63
+ class {
64
+ observe(el: Element) {
65
+ observed.push(el);
66
+ }
67
+ disconnect() {}
68
+ },
69
+ );
70
+ useCaptionStore.getState().setEditMode(true);
71
+ const send = (source: MessageEventSource | null) =>
72
+ act(() => {
73
+ window.dispatchEvent(new MessageEvent("message", { source, data: { source: "hf-preview" } }));
74
+ });
75
+ try {
76
+ act(() => usePreviewIframeStore.getState().setIframe(a));
77
+ act(() => root.render(<CaptionOverlay iframeRef={ref} />));
78
+ expect(observed).toContain(a);
79
+ expect(observed).not.toContain(b);
80
+ act(() => {
81
+ ref.current = b;
82
+ usePreviewIframeStore.getState().setIframe(b);
83
+ });
84
+ expect(observed).toContain(b);
85
+ schedule.mockClear();
86
+ send(a.contentWindow);
87
+ expect(schedule).not.toHaveBeenCalled();
88
+ send(b.contentWindow);
89
+ expect(schedule).toHaveBeenCalledOnce();
90
+ } finally {
91
+ act(() => root.unmount());
92
+ usePreviewIframeStore.setState({ iframe: null });
93
+ useCaptionStore.getState().reset();
94
+ host.remove();
95
+ a.remove();
96
+ b.remove();
97
+ vi.unstubAllGlobals();
98
+ }
99
+ });
@@ -1,7 +1,7 @@
1
- import { memo, useState, useCallback, useRef } from "react";
1
+ import { memo, useEffect, useState, useCallback, useRef } from "react";
2
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
2
3
  import { useCaptionStore } from "../store";
3
4
  import { usePlayerStore } from "../../player";
4
- import { useMountEffect } from "../../hooks/useMountEffect";
5
5
  import { shouldHandleCaptionNudgeKey, isEditableEventTarget } from "../keyboard";
6
6
  import {
7
7
  readWordBoxes,
@@ -32,6 +32,7 @@ function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
32
32
 
33
33
  export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: CaptionOverlayProps) {
34
34
  const isEditMode = useCaptionStore((s) => s.isEditMode);
35
+ const livePreviewIframe = useLivePreviewIframe();
35
36
  const model = useCaptionStore((s) => s.model);
36
37
  const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
37
38
  const selectSegment = useCaptionStore((s) => s.selectSegment);
@@ -81,7 +82,9 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
81
82
  | null
82
83
  >(null);
83
84
 
84
- useMountEffect(() => {
85
+ // The observer and message filter bind to the live iframe element, which a promoted reload replaces.
86
+ // eslint-disable-next-line no-restricted-syntax
87
+ useEffect(() => {
85
88
  if (!isEditMode) return;
86
89
 
87
90
  // Let undo/redo (useAppHotkeys) reapply restored models to this iframe.
@@ -240,7 +243,8 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
240
243
  window.removeEventListener("keydown", handleKeyDown);
241
244
  cancelDragRef.current = null;
242
245
  };
243
- });
246
+ // eslint-disable-next-line react-hooks/exhaustive-deps
247
+ }, [livePreviewIframe]);
244
248
 
245
249
  const getCssScale = useCallback(() => {
246
250
  const iframe = iframeRef.current;
@@ -42,7 +42,6 @@ vi.mock("./nle/NLEContext", () => ({
42
42
  useNLEContext: () => ({
43
43
  compositionStack: [],
44
44
  updateCompositionStack: vi.fn(),
45
- containerRef: { current: null },
46
45
  }),
47
46
  }));
48
47
  vi.mock("./nle/useTimelineEditCallbacks", () => ({
@@ -53,7 +52,14 @@ vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
53
52
  vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
54
53
  vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
55
54
 
56
- Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
55
+ Object.assign(globalThis, {
56
+ IS_REACT_ACT_ENVIRONMENT: true,
57
+ ResizeObserver: class {
58
+ observe() {}
59
+ unobserve() {}
60
+ disconnect() {}
61
+ },
62
+ });
57
63
 
58
64
  afterEach(() => {
59
65
  document.body.innerHTML = "";
@@ -69,8 +75,7 @@ describe("EditorShell timeline selection sync", () => {
69
75
  act(() => {
70
76
  root.render(
71
77
  <EditorShell
72
- left={null}
73
- right={null}
78
+ panels={null}
74
79
  timelineToolbar={null}
75
80
  renderClipContent={() => null}
76
81
  handleTimelineElementDelete={vi.fn()}
@@ -17,6 +17,7 @@ import type { GestureRecordingState } from "./editor/GestureRecordControl";
17
17
  import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
18
18
  import { StudioAgentTools } from "../webmcp/StudioAgentTools";
19
19
  import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
20
+ import { Dock } from "./dock/Dock";
20
21
 
21
22
  type RenderClipContent = (
22
23
  element: TimelineElement,
@@ -26,10 +27,8 @@ type RenderClipContent = (
26
27
  // The seven move/resize/split/razor handlers come from TimelineEditCallbackDeps
27
28
  // (shared with useTimelineEditCallbacks); the rest are drop + wiring props.
28
29
  export interface EditorShellProps extends TimelineEditCallbackDeps {
29
- /** Left sidebar (media/library), rendered in the top row. */
30
- left: ReactNode;
31
- /** Right panel (inspector/design) or null when collapsed, in the top row. */
32
- right: ReactNode;
30
+ /** Dock.Panel elements for every panel except the built-in preview and timeline. */
31
+ panels: ReactNode;
33
32
  timelineToolbar: ReactNode;
34
33
  renderClipContent: RenderClipContent;
35
34
  handleTimelineElementDelete: (element: TimelineElement) => Promise<void> | void;
@@ -65,15 +64,18 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
65
64
  isGestureRecording?: boolean;
66
65
  recordingState?: GestureRecordingState;
67
66
  onToggleRecording?: () => void;
67
+ /**
68
+ * Host layer over the preview, positioned with `usePreviewCompositionRect`. Hidden in
69
+ * fullscreen and during a block preview; below the selection overlay past z-index 10.
70
+ */
68
71
  gestureOverlay?: ReactNode;
69
72
  }
70
73
 
71
- // The CapCut-style shell: [left | preview | right] in a top row, with a
72
- // full-width timeline spanning the bottom. Owns the shared player +
73
- // composition-stack state via NLEProvider so both rows share one player.
74
+ // The dockable shell: every panel lives in one Dock, arranged by the user's
75
+ // saved layout. Owns the shared player + composition-stack state via
76
+ // NLEProvider so every panel shares one player.
74
77
  export function EditorShell({
75
- left,
76
- right,
78
+ panels,
77
79
  timelineToolbar,
78
80
  renderClipContent,
79
81
  handleTimelineElementDelete,
@@ -174,8 +176,8 @@ export function EditorShell({
174
176
  }}
175
177
  >
176
178
  <EditorShellBody
177
- left={left}
178
- right={right}
179
+ projectId={projectId}
180
+ panels={panels}
179
181
  captionEditMode={captionEditMode}
180
182
  onSelectTimelineElement={handleTimelineElementSelect}
181
183
  onPreviewBlockDrop={handlePreviewBlockDrop}
@@ -209,8 +211,8 @@ export function EditorShell({
209
211
  }
210
212
 
211
213
  interface EditorShellBodyProps {
212
- left: ReactNode;
213
- right: ReactNode;
214
+ panels: ReactNode;
215
+ projectId: string;
214
216
  captionEditMode: boolean;
215
217
  previewOverlay: ReactNode;
216
218
  onSelectTimelineElement: (element: TimelineElement | null) => void;
@@ -235,8 +237,8 @@ interface EditorShellBodyProps {
235
237
  }
236
238
 
237
239
  function EditorShellBody({
238
- left,
239
- right,
240
+ panels,
241
+ projectId,
240
242
  captionEditMode,
241
243
  previewOverlay,
242
244
  onSelectTimelineElement,
@@ -253,7 +255,7 @@ function EditorShellBody({
253
255
  onDuplicateClip,
254
256
  canPasteClip,
255
257
  }: EditorShellBodyProps) {
256
- const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
258
+ const { compositionStack, updateCompositionStack } = useNLEContext();
257
259
 
258
260
  // The caption track's blocks are seek targets; CaptionTimeline took an onSeek
259
261
  // prop that nothing ever passed, so clicking a block did nothing.
@@ -273,7 +275,6 @@ function EditorShellBody({
273
275
 
274
276
  return (
275
277
  <div
276
- ref={containerRef}
277
278
  // Shell canvas is a step LIGHTER than the near-black panel cards so the
278
279
  // gaps between panels read as visible seams (CapCut-style).
279
280
  className="flex flex-col flex-1 min-h-0 bg-panel-surface"
@@ -283,47 +284,46 @@ function EditorShellBody({
283
284
  {/* Renders nothing; exposes Studio's state to an agentic browser. Mounted
284
285
  here rather than in App because it needs the DomEdit contexts. */}
285
286
  <StudioAgentTools />
286
- {/* Top row: [left | preview | right] — outer padding + the 8px resize
287
- seams give the panels CapCut-style separation on the dark canvas. */}
288
- <div className="flex flex-row flex-1 min-h-0 px-px pt-px">
289
- {left}
290
- <div className="flex-1 min-w-0 flex flex-col relative">
291
- <PreviewPane
292
- previewOverlay={previewOverlay}
287
+ <Dock.Root projectId={projectId}>
288
+ <Dock.Panel id="preview">
289
+ <div className="relative flex h-full w-full min-w-0 flex-col">
290
+ <PreviewPane
291
+ previewOverlay={previewOverlay}
292
+ onSelectTimelineElement={onSelectTimelineElement}
293
+ onPreviewBlockDrop={onPreviewBlockDrop}
294
+ />
295
+ </div>
296
+ </Dock.Panel>
297
+ <Dock.Panel id="timeline">
298
+ <TimelinePane
299
+ timelineToolbar={timelineToolbar}
300
+ renderClipContent={renderClipContent}
301
+ onFileDrop={onFileDrop}
302
+ onAssetDrop={onAssetDrop}
303
+ onBlockDrop={onBlockDrop}
304
+ onCompositionDrop={onCompositionDrop}
305
+ onDeleteElement={onDeleteElement}
306
+ onCopyClip={onCopyClip}
307
+ onPasteClip={onPasteClip}
308
+ onDuplicateClip={onDuplicateClip}
309
+ canPasteClip={canPasteClip}
293
310
  onSelectTimelineElement={onSelectTimelineElement}
294
- onPreviewBlockDrop={onPreviewBlockDrop}
311
+ timelineFooter={
312
+ captionEditMode ? (
313
+ <div className="border-t border-neutral-800/30 shrink-0" style={{ height: 60 }}>
314
+ <div className="flex items-center gap-1.5 px-2 py-0.5">
315
+ <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
316
+ Captions
317
+ </span>
318
+ </div>
319
+ <CaptionTimeline pixelsPerSecond={100} onSeek={seekCaptionTime} />
320
+ </div>
321
+ ) : undefined
322
+ }
295
323
  />
296
- </div>
297
- {right}
298
- </div>
299
-
300
- {/* Full-width timeline row */}
301
- <TimelinePane
302
- timelineToolbar={timelineToolbar}
303
- renderClipContent={renderClipContent}
304
- onFileDrop={onFileDrop}
305
- onAssetDrop={onAssetDrop}
306
- onBlockDrop={onBlockDrop}
307
- onCompositionDrop={onCompositionDrop}
308
- onDeleteElement={onDeleteElement}
309
- onCopyClip={onCopyClip}
310
- onPasteClip={onPasteClip}
311
- onDuplicateClip={onDuplicateClip}
312
- canPasteClip={canPasteClip}
313
- onSelectTimelineElement={onSelectTimelineElement}
314
- timelineFooter={
315
- captionEditMode ? (
316
- <div className="border-t border-neutral-800/30 shrink-0" style={{ height: 60 }}>
317
- <div className="flex items-center gap-1.5 px-2 py-0.5">
318
- <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
319
- Captions
320
- </span>
321
- </div>
322
- <CaptionTimeline pixelsPerSecond={100} onSeek={seekCaptionTime} />
323
- </div>
324
- ) : undefined
325
- }
326
- />
324
+ </Dock.Panel>
325
+ {panels}
326
+ </Dock.Root>
327
327
  </div>
328
328
  );
329
329
  }