@hyperframes/studio 0.8.105 → 0.8.107

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-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.105",
3
+ "version": "0.8.107",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "@codemirror/language": "^6.12.2",
51
51
  "@codemirror/search": "^6.6.0",
52
52
  "@codemirror/state": "^6.6.0",
53
- "@codemirror/theme-one-dark": "^6.1.2",
54
53
  "@codemirror/view": "6.40.0",
54
+ "@lezer/highlight": "^1.2.3",
55
55
  "@mcp-b/global": "^5.0.1",
56
56
  "@phosphor-icons/react": "^2.1.10",
57
57
  "@tanstack/react-virtual": "^3.14.13",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.105",
64
- "@hyperframes/player": "0.8.105",
65
- "@hyperframes/parsers": "0.8.105",
66
- "@hyperframes/shader-transitions": "0.8.105",
67
- "@hyperframes/studio-server": "0.8.105",
68
- "@hyperframes/sdk": "0.8.105"
63
+ "@hyperframes/parsers": "0.8.107",
64
+ "@hyperframes/core": "0.8.107",
65
+ "@hyperframes/sdk": "0.8.107",
66
+ "@hyperframes/shader-transitions": "0.8.107",
67
+ "@hyperframes/studio-server": "0.8.107",
68
+ "@hyperframes/player": "0.8.107"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.105"
84
+ "@hyperframes/producer": "0.8.107"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
package/src/App.tsx CHANGED
@@ -30,6 +30,7 @@ import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
30
30
  import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
31
31
  import { useBlockHandlers } from "./hooks/useBlockHandlers";
32
32
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
33
+ import { trackedStudioEdit } from "./utils/studioPendingEdits";
33
34
  import { useClipboard } from "./hooks/useClipboard";
34
35
  import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
35
36
  import { useCaptionDetection } from "./hooks/useCaptionDetection";
@@ -256,7 +257,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
256
257
  showToast,
257
258
  syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
258
259
  showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow,
259
- waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
260
+ settlePendingEdits: previewPersistence.settlePendingEdits,
260
261
  handleCopy,
261
262
  handlePaste,
262
263
  handleCut,
@@ -315,9 +316,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
315
316
  readOnlyPreview,
316
317
  });
317
318
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
318
- handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
319
+ const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession;
320
+ handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit);
319
321
  clearDomSelectionRef.current = domEditSession.clearDomSelection;
320
- handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
322
+ handleDomEditElementDeleteRef.current = trackedStudioEdit(del);
321
323
  resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
322
324
  invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
323
325
  deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession);
@@ -0,0 +1,8 @@
1
+ import { expect, it } from "vitest";
2
+ import { buildStudioTools, collectStudioLookScene } from "@hyperframes/studio";
3
+
4
+ it("exports Studio's agent tools for a host's own edit session", () => {
5
+ const names = buildStudioTools({ current: {} as never }).map((tool) => tool.name);
6
+ expect(names).toEqual(expect.arrayContaining(["studio_look", "studio_seek"]));
7
+ expect(typeof collectStudioLookScene).toBe("function");
8
+ });
@@ -38,6 +38,12 @@ describe("shouldHandleCaptionNudgeKey", () => {
38
38
  expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
39
39
  });
40
40
 
41
+ it("ignores arrows a focused slider owns", () => {
42
+ const slider = document.createElement("div");
43
+ slider.setAttribute("role", "slider");
44
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), slider)).toBe(false);
45
+ });
46
+
41
47
  it("ignores arrows when the event target is an editable element", () => {
42
48
  const input = document.createElement("input");
43
49
  const textarea = document.createElement("textarea");
@@ -47,4 +53,10 @@ describe("shouldHandleCaptionNudgeKey", () => {
47
53
  expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), div)).toBe(true);
48
54
  expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), null)).toBe(true);
49
55
  });
56
+
57
+ it.each(["video", "audio"])("leaves the arrows to a focused <%s controls>", (tag) => {
58
+ const player = document.createElement(tag);
59
+ player.setAttribute("controls", "");
60
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"), player)).toBe(false);
61
+ });
50
62
  });
@@ -1,4 +1,5 @@
1
1
  import { isEditableTarget } from "../utils/timelineDiscovery";
2
+ import { ownsPlainKeys } from "../utils/typingTarget";
2
3
 
3
4
  const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
4
5
 
@@ -9,7 +10,7 @@ export function shouldHandleCaptionNudgeKey(
9
10
  target?: EventTarget | null,
10
11
  ): boolean {
11
12
  if (event.metaKey || event.ctrlKey || event.altKey) return false;
12
- if (target != null && isEditableTarget(target)) return false;
13
+ if (target != null && ownsPlainKeys(target)) return false;
13
14
  return CAPTION_NUDGE_KEYS.has(event.key);
14
15
  }
15
16
 
@@ -42,6 +42,7 @@ vi.mock("../contexts/PanelLayoutContext", () => ({
42
42
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
43
43
 
44
44
  const { StudioHeader } = await import("./StudioHeader");
45
+ const { ShowThemeToggle } = await import("./ThemeToggle");
45
46
 
46
47
  let mounted: { root: Root; host: HTMLElement } | null = null;
47
48
 
@@ -54,6 +55,7 @@ beforeEach(() => {
54
55
  });
55
56
 
56
57
  afterEach(() => {
58
+ delete document.documentElement.dataset.theme;
57
59
  if (!mounted) return;
58
60
  const { root, host } = mounted;
59
61
  mounted = null;
@@ -61,21 +63,30 @@ afterEach(() => {
61
63
  host.remove();
62
64
  });
63
65
 
64
- function mount(props: { inspectorButtonActive?: boolean } = {}): HTMLElement {
66
+ function mount(
67
+ props: { inspectorButtonActive?: boolean; themeToggle?: boolean } = {},
68
+ ): HTMLElement {
65
69
  const host = document.createElement("div");
66
70
  document.body.append(host);
67
71
  const root = createRoot(host);
68
72
  mounted = { root, host };
73
+ const header = (
74
+ <StudioHeader
75
+ captureFrameHref="blob:frame"
76
+ captureFrameFilename="frame.png"
77
+ handleCaptureFrameClick={vi.fn()}
78
+ refreshCaptureFrameTime={vi.fn()}
79
+ inspectorButtonActive={props.inspectorButtonActive ?? false}
80
+ inspectorPanelActive={false}
81
+ />
82
+ );
69
83
  act(() =>
70
84
  root.render(
71
- <StudioHeader
72
- captureFrameHref="blob:frame"
73
- captureFrameFilename="frame.png"
74
- handleCaptureFrameClick={vi.fn()}
75
- refreshCaptureFrameTime={vi.fn()}
76
- inspectorButtonActive={props.inspectorButtonActive ?? false}
77
- inspectorPanelActive={false}
78
- />,
85
+ props.themeToggle ? (
86
+ <ShowThemeToggle.Provider value>{header}</ShowThemeToggle.Provider>
87
+ ) : (
88
+ header
89
+ ),
79
90
  ),
80
91
  );
81
92
  return host;
@@ -179,3 +190,14 @@ it("classifies the new header controls for the hotkey filters as the old ones we
179
190
  expect(shouldIgnorePlaybackShortcutTarget(el), el.tagName).toBe(true);
180
191
  }
181
192
  });
193
+
194
+ it("shows no theme toggle and leaves the host's data-theme alone when embedded", () => {
195
+ document.documentElement.dataset.theme = "host";
196
+ const host = mount();
197
+ expect(host.querySelector('[aria-label^="Switch to"]')).toBeNull();
198
+ expect(document.documentElement.dataset.theme).toBe("host");
199
+ });
200
+
201
+ it("shows the theme toggle in Studio's own app", () => {
202
+ expect(mount({ themeToggle: true }).querySelector('[aria-label^="Switch to"]')).not.toBeNull();
203
+ });
@@ -1,4 +1,4 @@
1
- import type { MouseEvent } from "react";
1
+ import { useContext, type MouseEvent } from "react";
2
2
  import { Camera } from "../icons/SystemIcons";
3
3
  import { useStudioShellContext } from "../contexts/StudioContext";
4
4
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -7,6 +7,7 @@ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./
7
7
  import { Dock } from "./dock/Dock";
8
8
  import { InspectorIcon } from "./icons/InspectorIcon";
9
9
  import { HyperframesLogo } from "./ui/HyperframesLogo";
10
+ import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
10
11
 
11
12
  export interface StudioHeaderProps {
12
13
  captureFrameHref: string;
@@ -44,6 +45,7 @@ export function StudioHeader({
44
45
  inspectorPanelActive,
45
46
  onExport,
46
47
  }: StudioHeaderProps) {
48
+ const showThemeToggle = useContext(ShowThemeToggle);
47
49
  const { projectId, renderQueue } = useStudioShellContext();
48
50
  const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
49
51
  const isRendering = renderQueue.isRendering;
@@ -147,6 +149,7 @@ export function StudioHeader({
147
149
  </Button>
148
150
  </Tooltip>
149
151
  </div>
152
+ {showThemeToggle && <ThemeToggle />}
150
153
  <Dock.WindowMenu />
151
154
  <Tooltip
152
155
  label={
@@ -62,6 +62,7 @@ export function StudioRightPanels({
62
62
  handleDomAttributeQuietCommit,
63
63
  handleDomHtmlAttributeCommit,
64
64
  handleDomAttributesCommit,
65
+ handleDomAttributeBatchCommit,
65
66
  handleDomPathOffsetCommit,
66
67
  handleDomBoxSizeCommit,
67
68
  handleDomRotationCommit,
@@ -210,6 +211,7 @@ export function StudioRightPanels({
210
211
  onSetStyle={handleDomStyleCommit}
211
212
  onSetAttribute={handleDomAttributeCommit}
212
213
  onSetAttributes={handleDomAttributesCommit}
214
+ onSetAttributeBatch={handleDomAttributeBatchCommit}
213
215
  onSetAttributeLive={setAttributeWhileDragging}
214
216
  onSetAttributeQuiet={handleDomAttributeQuietCommit}
215
217
  onApplyColorGradingScope={handleApplyColorGradingScope}
@@ -0,0 +1,99 @@
1
+ import { createContext } from "react";
2
+ import { setStudioTheme, useShownStudioTheme } from "../utils/studioTheme";
3
+ import { IconButton, Tooltip } from "./ui";
4
+
5
+ // hyperframes.dev's theme toggle: the same sun and moon, revealing the new theme in a circle from
6
+ // the button. A page that paints no frame aborts the transition; the theme is applied by then.
7
+ let latest: ViewTransition | null = null;
8
+
9
+ function reveal(transition: ViewTransition, at: string, radiusPct: number) {
10
+ latest = transition;
11
+ const done = () => {
12
+ if (latest === transition) delete document.documentElement.dataset.themeReveal;
13
+ };
14
+ void transition.finished.then(done, done);
15
+ void transition.ready.then(
16
+ () => {
17
+ document.documentElement.animate(
18
+ { clipPath: [`circle(0% ${at})`, `circle(${radiusPct}% ${at})`] },
19
+ { duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
20
+ );
21
+ },
22
+ () => {},
23
+ );
24
+ }
25
+
26
+ function origin(e: React.MouseEvent<HTMLButtonElement>): [number, number] {
27
+ if (e.detail > 0) return [e.clientX, e.clientY];
28
+ const box = e.currentTarget.getBoundingClientRect();
29
+ return [box.left + box.width / 2, box.top + box.height / 2];
30
+ }
31
+
32
+ /** On only in Studio's own app; a host that embeds Studio owns its theme. */
33
+ export const ShowThemeToggle = createContext(false);
34
+
35
+ function cannotReveal(): boolean {
36
+ return (
37
+ !document.startViewTransition ||
38
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
39
+ );
40
+ }
41
+
42
+ const iconProps = {
43
+ width: "14",
44
+ height: "14",
45
+ viewBox: "0 0 24 24",
46
+ fill: "none",
47
+ stroke: "currentColor",
48
+ strokeWidth: "2",
49
+ strokeLinecap: "round",
50
+ strokeLinejoin: "round",
51
+ "aria-hidden": true,
52
+ } as const;
53
+
54
+ export function ThemeToggle() {
55
+ const isDark = useShownStudioTheme() === "dark";
56
+ const label = `Switch to ${isDark ? "light" : "dark"} theme`;
57
+
58
+ function toggle(e: React.MouseEvent<HTMLButtonElement>) {
59
+ const apply = () => setStudioTheme(isDark ? "light" : "dark");
60
+ if (cannotReveal()) return apply();
61
+ const [x, y] = origin(e);
62
+ const w = window.innerWidth;
63
+ const h = window.innerHeight;
64
+ const maxRadius = Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
65
+ const at = `at ${(x / w) * 100}% ${(y / h) * 100}%`;
66
+ const radiusPct = (maxRadius / (Math.hypot(w, h) / Math.SQRT2)) * 100;
67
+ // The switch stays synchronous inside the callback: rendering is paused during it.
68
+ document.documentElement.dataset.themeReveal = "";
69
+ reveal(document.startViewTransition(apply), at, radiusPct);
70
+ }
71
+
72
+ return (
73
+ <Tooltip label={label} side="bottom">
74
+ <IconButton
75
+ onClick={toggle}
76
+ aria-label={label}
77
+ icon={
78
+ isDark ? (
79
+ <svg {...iconProps}>
80
+ <circle cx="12" cy="12" r="5" />
81
+ <line x1="12" y1="1" x2="12" y2="3" />
82
+ <line x1="12" y1="21" x2="12" y2="23" />
83
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
84
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
85
+ <line x1="1" y1="12" x2="3" y2="12" />
86
+ <line x1="21" y1="12" x2="23" y2="12" />
87
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
88
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
89
+ </svg>
90
+ ) : (
91
+ <svg {...iconProps}>
92
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
93
+ </svg>
94
+ )
95
+ }
96
+ />
97
+ </Tooltip>
98
+ );
99
+ }
@@ -211,6 +211,33 @@ describe("TimelineToolbar — keyframes on audio tracks", () => {
211
211
  });
212
212
  });
213
213
 
214
+ describe("TimelineToolbar snap key", () => {
215
+ let root: ReturnType<typeof createRoot> | null = null;
216
+ const snapBefore = usePlayerStore.getState().timelineSnapEnabled;
217
+ afterEach(() => {
218
+ act(() => root?.unmount());
219
+ root = null;
220
+ usePlayerStore.getState().setTimelineSnapEnabled(snapBefore);
221
+ });
222
+
223
+ it("N toggles snapping, but not while a picker that owns typing has focus", () => {
224
+ root = renderToolbar().root;
225
+ const pressN = (target: EventTarget) =>
226
+ act(() => {
227
+ target.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
228
+ });
229
+ const before = usePlayerStore.getState().timelineSnapEnabled;
230
+ const picker = document.createElement("button");
231
+ picker.setAttribute("role", "combobox");
232
+ document.body.append(picker);
233
+
234
+ pressN(picker);
235
+ expect(usePlayerStore.getState().timelineSnapEnabled).toBe(before);
236
+ pressN(document.body);
237
+ expect(usePlayerStore.getState().timelineSnapEnabled).toBe(!before);
238
+ });
239
+ });
240
+
214
241
  describe("TimelineToolbar Fit", () => {
215
242
  it("shows Fit as a named icon and says whether fit is on", () => {
216
243
  const { host, root } = renderToolbar();
@@ -33,6 +33,7 @@ import { canSplitElement } from "../utils/timelineElementSplit";
33
33
  import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
34
34
  import { useProjectHasAudio } from "../utils/audioMeterMath";
35
35
  import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
36
+ import { isTypingTarget } from "../utils/typingTarget";
36
37
 
37
38
  interface DomEditSessionSlice extends EnableKeyframesSession {
38
39
  domEditSelection: DomEditSelection | null;
@@ -196,14 +197,10 @@ export function TimelineToolbar({
196
197
  // "N" toggles timeline snapping (industry convention: Resolve/FCP).
197
198
  // Skip when typing in an input/contenteditable.
198
199
  useEffect(() => {
199
- // fallow-ignore-next-line complexity
200
200
  const onKeyDown = (e: KeyboardEvent) => {
201
201
  if (e.key !== "n" && e.key !== "N") return;
202
202
  if (e.metaKey || e.ctrlKey || e.altKey) return;
203
- const target = e.target instanceof HTMLElement ? e.target : null;
204
- if (target?.isContentEditable) return;
205
- const tag = target?.tagName?.toLowerCase() ?? "";
206
- if (tag === "input" || tag === "textarea" || tag === "select") return;
203
+ if (isTypingTarget(e.target)) return;
207
204
  const store = usePlayerStore.getState();
208
205
  store.setTimelineSnapEnabled(!store.timelineSnapEnabled);
209
206
  };
@@ -31,6 +31,8 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
31
31
  import { CanvasContextMenu } from "./CanvasContextMenu";
32
32
  import { useInlineTextEditing } from "./useInlineTextEditing";
33
33
  import { usePreviewReadOnly } from "./previewReadOnlyContext";
34
+ import { useMountEffect } from "../../hooks/useMountEffect";
35
+ import { countStudioManualEditSave as counted } from "./manualEditsDom";
34
36
  import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
35
37
  import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
36
38
  import { logSelect } from "../../utils/selectDebug";
@@ -193,16 +195,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
193
195
  onTextEditingChangeRef.current?.(true);
194
196
  return () => onTextEditingChangeRef.current?.(false);
195
197
  }, [inlineText.editing]);
198
+ // Each canvas save is counted, so a reload requested before it settles loads again.
196
199
  const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
197
- onPathOffsetCommitRef.current = onPathOffsetCommit;
200
+ onPathOffsetCommitRef.current = (sel, ...rest) =>
201
+ counted(sel.element, () => onPathOffsetCommit(sel, ...rest));
198
202
  const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
199
- onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit;
203
+ onGroupPathOffsetCommitRef.current = (updates) => {
204
+ const save = () => onGroupPathOffsetCommit(updates);
205
+ return updates[0] ? counted(updates[0].selection.element, save) : save();
206
+ };
200
207
  const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
201
- onBoxSizeCommitRef.current = onBoxSizeCommit;
208
+ onBoxSizeCommitRef.current = (sel, ...rest) =>
209
+ counted(sel.element, () => onBoxSizeCommit(sel, ...rest));
202
210
  const onRotationCommitRef = useRef(onRotationCommit);
203
- onRotationCommitRef.current = onRotationCommit;
211
+ onRotationCommitRef.current = (sel, next) =>
212
+ counted(sel.element, () => onRotationCommit(sel, next));
204
213
  const onStyleCommitRef = useRef(onStyleCommit);
205
- onStyleCommitRef.current = onStyleCommit;
214
+ onStyleCommitRef.current =
215
+ onStyleCommit &&
216
+ ((property, value) => {
217
+ const save = () => onStyleCommit(property, value);
218
+ return selectionRef.current ? counted(selectionRef.current.element, save) : save();
219
+ });
206
220
  const onBlockedMoveRef = useRef(onBlockedMove);
207
221
  onBlockedMoveRef.current = onBlockedMove;
208
222
  const onManualDragStartRef = useRef(onManualDragStart);
@@ -282,6 +296,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
282
296
  useEffect(() => {
283
297
  if (readOnly) gestures.clearPointerState(selectionRef);
284
298
  }, [gestures, readOnly, selectionRef]);
299
+ // A gesture that loses its pointer, the window or the overlay is cancelled, so its mark goes too.
300
+ const cancelGestureRef = useRef(() => {});
301
+ cancelGestureRef.current = () => gestures.clearPointerState(selectionRef);
302
+ useMountEffect(() => {
303
+ const cancel = () => cancelGestureRef.current();
304
+ window.addEventListener("blur", cancel);
305
+ return () => {
306
+ window.removeEventListener("blur", cancel);
307
+ cancel();
308
+ };
309
+ });
285
310
 
286
311
  // Arrow-key nudge (1px, Shift = 10px) — commits through the same
287
312
  // path-offset callbacks as a drag, one undo entry per key burst.
@@ -463,6 +488,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
463
488
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
464
489
  onPointerUp={marquee.onPointerUp}
465
490
  onPointerCancel={marquee.onPointerCancel}
491
+ onLostPointerCapture={() => cancelGestureRef.current()}
466
492
  onContextMenu={hostInput ? undefined : handleContextMenu}
467
493
  >
468
494
  {!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
@@ -58,7 +58,6 @@ export {
58
58
  export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelProps) {
59
59
  const {
60
60
  projectId,
61
- projectDir,
62
61
  assets,
63
62
  element,
64
63
  multiSelectCount = 0,
@@ -72,8 +71,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
72
71
  onSetAttribute,
73
72
  onSetAttributeLive,
74
73
  onApplyColorGradingScope,
75
- onSetHtmlAttribute,
76
- onRemoveBackground,
77
74
  onSetManualOffset,
78
75
  onSetManualSize,
79
76
  onSetManualRotation,
@@ -374,17 +371,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
374
371
  />
375
372
  )}
376
373
 
377
- {sections.media && (
378
- <MediaSection
379
- projectDir={projectDir}
380
- element={element}
381
- styles={styles}
382
- onSetStyle={onSetStyle}
383
- onSetAttribute={onSetAttribute}
384
- onSetHtmlAttribute={onSetHtmlAttribute}
385
- onRemoveBackground={onRemoveBackground}
386
- />
387
- )}
374
+ {sections.media && <MediaSection {...props} element={element} styles={styles} />}
388
375
 
389
376
  {sections.layout && (
390
377
  <Section title="Layout" icon={<Move size={15} />}>
@@ -61,7 +61,6 @@ export function PropertyPanelFlat({
61
61
  clipboardCopied,
62
62
  onCopyElementInfo,
63
63
  projectId,
64
- projectDir,
65
64
  assets,
66
65
  previewIframeRef,
67
66
  onClearSelection,
@@ -73,8 +72,6 @@ export function PropertyPanelFlat({
73
72
  onSetAttributeLive,
74
73
  onSetAttributeQuiet,
75
74
  onApplyColorGradingScope,
76
- onSetHtmlAttribute,
77
- onRemoveBackground,
78
75
  onSetText,
79
76
  onSetTextFieldStyle,
80
77
  onPreviewTextFieldStyle,
@@ -130,6 +127,7 @@ export function PropertyPanelFlat({
130
127
  onUpdateKeyframeEase,
131
128
  onUpdateSegmentEase,
132
129
  onSetAllKeyframeEases,
130
+ ...forwardedProps
133
131
  }: PropertyPanelFlatProps) {
134
132
  // PropertyPanel keys this component by selection, so the default is per element.
135
133
  const [openGroupId, setOpenGroupId] = useState<string>(() =>
@@ -502,13 +500,12 @@ export function PropertyPanelFlat({
502
500
  summary: element.tagName,
503
501
  content: (
504
502
  <FlatMediaSection
505
- projectDir={projectDir}
503
+ {...forwardedProps}
504
+ projectId={projectId}
506
505
  element={element}
507
506
  styles={styles}
508
507
  onSetStyle={onSetStyle}
509
508
  onSetAttribute={onSetAttribute}
510
- onSetHtmlAttribute={onSetHtmlAttribute}
511
- onRemoveBackground={onRemoveBackground}
512
509
  {...volumeAutomation}
513
510
  />
514
511
  ),
@@ -52,7 +52,7 @@ function AppHotkeyHarness() {
52
52
  writeProjectFile: vi.fn(async () => undefined),
53
53
  showToast: vi.fn(),
54
54
  syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
55
- waitForPendingDomEditSaves: vi.fn(async () => undefined),
55
+ settlePendingEdits: vi.fn(async () => undefined),
56
56
  handleCopy: vi.fn(() => false),
57
57
  handlePaste: vi.fn(async () => undefined),
58
58
  handleCut: vi.fn(async () => false),
@@ -133,6 +133,37 @@ describe("SnapToolbar keyboard shortcuts", () => {
133
133
  });
134
134
  });
135
135
 
136
+ describe("SnapToolbar keys and a focused control", () => {
137
+ let root: ReturnType<typeof createRoot> | null = null;
138
+ afterEach(() => {
139
+ act(() => root?.unmount());
140
+ root = null;
141
+ });
142
+
143
+ const controls: Array<[string, string, string, string]> = [
144
+ ["a combobox", "button", "role", "combobox"],
145
+ ["a select", "select", "name", "font"],
146
+ ["a switch", "button", "role", "switch"],
147
+ ["a video player", "video", "controls", ""],
148
+ ];
149
+
150
+ it.each(controls)("leaves S and G alone while %s has focus", (_name, tag, attr, value) => {
151
+ root = renderToolbar().root;
152
+ const control = document.body.appendChild(document.createElement(tag));
153
+ control.setAttribute(attr, value);
154
+ act(() => {
155
+ for (const key of ["s", "g"])
156
+ control.dispatchEvent(
157
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
158
+ );
159
+ });
160
+
161
+ const prefs = JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}");
162
+ expect(prefs.snapEnabled).not.toBe(false);
163
+ expect(prefs.gridVisible).not.toBe(true);
164
+ });
165
+ });
166
+
136
167
  describe("SnapToolbar ruler and safe-margin toggles", () => {
137
168
  it.each([
138
169
  ["Toggle ruler", "rulerVisible"],
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
3
3
  import { usePlayerStore } from "../../player/store/playerStore";
4
4
  import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
5
+ import { ownsPlainKeys } from "../../utils/typingTarget";
5
6
 
6
7
  // fallow-ignore-next-line complexity
7
8
  export const SnapToolbar = memo(function SnapToolbar() {
@@ -35,10 +36,8 @@ export const SnapToolbar = memo(function SnapToolbar() {
35
36
  // fallow-ignore-next-line complexity
36
37
  const handleKeyDown = (e: KeyboardEvent) => {
37
38
  if (e.defaultPrevented) return;
38
- const t = e.target;
39
- if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
40
- if (t instanceof HTMLElement && t.isContentEditable) return;
41
- if (t instanceof HTMLIFrameElement) return;
39
+ if (ownsPlainKeys(e.target)) return;
40
+ if (e.target instanceof HTMLIFrameElement) return;
42
41
  if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
43
42
  e.preventDefault();
44
43
  updatePrefs({ snapEnabled: !prefs.snapEnabled });
@@ -11,12 +11,12 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
11
11
  import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
12
12
  import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
13
13
  import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
14
- import { oneDark } from "@codemirror/theme-one-dark";
15
14
  import type { Extension } from "@codemirror/state";
16
15
  import { html } from "@codemirror/lang-html";
17
16
  import { css } from "@codemirror/lang-css";
18
17
  import { javascript } from "@codemirror/lang-javascript";
19
18
  import { markdown } from "@codemirror/lang-markdown";
19
+ import { studioCodeTheme } from "./studioCodeTheme";
20
20
 
21
21
  // Marks a programmatic doc sync (external content push — e.g. a manual-edit
22
22
  // commit writing the source) so the update listener doesn't mistake it for a
@@ -114,13 +114,9 @@ export const SourceEditor = memo(function SourceEditor({
114
114
  highlightSelectionMatches(),
115
115
  keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap]),
116
116
  getLanguageExtension(lang),
117
- oneDark,
117
+ studioCodeTheme,
118
118
  updateListener,
119
119
  EditorState.readOnly.of(readOnly),
120
- EditorView.theme({
121
- "&": { height: "100%" },
122
- ".cm-scroller": { overflow: "auto" },
123
- }),
124
120
  ],
125
121
  });
126
122