@hyperframes/studio 0.8.106 → 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 (110) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
  3. package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
  5. package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
  6. package/dist/chunk-4OZC426H.js.map +1 -0
  7. package/dist/index.d.ts +367 -11
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1064 -714
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +5 -3
  14. package/src/agentToolsExport.test.ts +8 -0
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/components/StudioRightPanels.tsx +2 -0
  17. package/src/components/TimelineToolbar.test.tsx +27 -0
  18. package/src/components/TimelineToolbar.tsx +2 -5
  19. package/src/components/editor/DomEditOverlay.tsx +12 -18
  20. package/src/components/editor/PropertyPanel.tsx +1 -14
  21. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  22. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  23. package/src/components/editor/SnapToolbar.tsx +3 -4
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  25. package/src/components/editor/fontAssets.test.ts +12 -22
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  27. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  28. package/src/components/editor/manualEdits.test.ts +4 -3
  29. package/src/components/editor/manualEditsDom.ts +28 -5
  30. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  31. package/src/components/editor/manualOffsetDrag.ts +4 -1
  32. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  33. package/src/components/editor/mediaAudioEdits.ts +253 -0
  34. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  35. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  36. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  37. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  40. package/src/components/editor/useDomEditNudge.ts +41 -23
  41. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  42. package/src/components/panels/SlideshowPanel.tsx +12 -7
  43. package/src/contexts/DomEditContext.test.tsx +62 -0
  44. package/src/contexts/DomEditContext.tsx +94 -77
  45. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  46. package/src/hooks/domEditCommitTypes.ts +10 -0
  47. package/src/hooks/domEditPersistFailure.ts +12 -1
  48. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  49. package/src/hooks/domStyleCommit.ts +2 -8
  50. package/src/hooks/gsapDragCommit.ts +3 -8
  51. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  52. package/src/hooks/gsapEditOutcome.ts +2 -1
  53. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  55. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  56. package/src/hooks/gsapResizeIntercept.ts +6 -2
  57. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  58. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  59. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  60. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  61. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  62. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  63. package/src/hooks/useAppHotkeys.test.ts +17 -0
  64. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  65. package/src/hooks/useAppHotkeys.ts +3 -3
  66. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  67. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  68. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  69. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  70. package/src/hooks/useDomEditCommits.ts +2 -0
  71. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  72. package/src/hooks/useDomEditPersist.ts +28 -13
  73. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  74. package/src/hooks/useDomEditSession.ts +2 -3
  75. package/src/hooks/useDomEditTextCommits.ts +4 -8
  76. package/src/hooks/useDomGeometryCommits.ts +11 -2
  77. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  78. package/src/hooks/useGsapAwareEditing.ts +6 -6
  79. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  80. package/src/hooks/useGsapScriptCommits.ts +5 -4
  81. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  82. package/src/hooks/useInlineTextEdit.ts +9 -7
  83. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  84. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  85. package/src/hooks/usePreviewPersistence.ts +6 -0
  86. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  87. package/src/index.ts +7 -0
  88. package/src/player/components/Timeline.test.ts +0 -21
  89. package/src/player/components/Timeline.tsx +0 -1
  90. package/src/player/components/timelineLayout.ts +0 -28
  91. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  92. package/src/player/lib/playbackShortcuts.ts +3 -7
  93. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  94. package/src/player/lib/timelineElementHelpers.ts +16 -2
  95. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  96. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  97. package/src/utils/gsapSoftReload.ts +3 -2
  98. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  99. package/src/utils/gsapUndoRestore.test.ts +145 -1
  100. package/src/utils/gsapUndoRestore.ts +70 -21
  101. package/src/utils/sourcePatcher.ts +1 -1
  102. package/src/utils/studioFontHelpers.ts +2 -23
  103. package/src/utils/studioHelpers.test.ts +30 -0
  104. package/src/utils/studioHelpers.ts +18 -4
  105. package/src/utils/studioPendingEdits.test.ts +63 -0
  106. package/src/utils/studioPendingEdits.ts +16 -7
  107. package/src/utils/typingTarget.test.ts +13 -0
  108. package/src/utils/typingTarget.ts +5 -2
  109. package/src/webmcp/useStudioAgentTools.ts +3 -1
  110. package/dist/chunk-ICTZJPQQ.js.map +0 -1
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-ICTZJPQQ.js";
3
+ } from "./chunk-4OZC426H.js";
4
4
  import "./chunk-NCUBAVGK.js";
5
5
  import "./chunk-DI4LCJHL.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.106",
3
+ "version": "0.8.107",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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.106",
64
- "@hyperframes/parsers": "0.8.106",
65
- "@hyperframes/sdk": "0.8.106",
66
- "@hyperframes/player": "0.8.106",
67
- "@hyperframes/shader-transitions": "0.8.106",
68
- "@hyperframes/studio-server": "0.8.106"
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.106"
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
+ });
@@ -53,4 +53,10 @@ describe("shouldHandleCaptionNudgeKey", () => {
53
53
  expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), div)).toBe(true);
54
54
  expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), null)).toBe(true);
55
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
+ });
56
62
  });
@@ -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}
@@ -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
  };
@@ -32,7 +32,7 @@ import { CanvasContextMenu } from "./CanvasContextMenu";
32
32
  import { useInlineTextEditing } from "./useInlineTextEditing";
33
33
  import { usePreviewReadOnly } from "./previewReadOnlyContext";
34
34
  import { useMountEffect } from "../../hooks/useMountEffect";
35
- import { noteStudioManualEditSave as noteSave } from "./manualEditsDom";
35
+ import { countStudioManualEditSave as counted } from "./manualEditsDom";
36
36
  import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
37
37
  import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
38
38
  import { logSelect } from "../../utils/selectDebug";
@@ -195,33 +195,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
195
195
  onTextEditingChangeRef.current?.(true);
196
196
  return () => onTextEditingChangeRef.current?.(false);
197
197
  }, [inlineText.editing]);
198
- // Each canvas save is counted before it starts, so a reload requested earlier loads again.
198
+ // Each canvas save is counted, so a reload requested before it settles loads again.
199
199
  const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
200
- onPathOffsetCommitRef.current = (sel, ...rest) => {
201
- noteSave(sel.element);
202
- return onPathOffsetCommit(sel, ...rest);
203
- };
200
+ onPathOffsetCommitRef.current = (sel, ...rest) =>
201
+ counted(sel.element, () => onPathOffsetCommit(sel, ...rest));
204
202
  const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
205
203
  onGroupPathOffsetCommitRef.current = (updates) => {
206
- if (updates[0]) noteSave(updates[0].selection.element);
207
- return onGroupPathOffsetCommit(updates);
204
+ const save = () => onGroupPathOffsetCommit(updates);
205
+ return updates[0] ? counted(updates[0].selection.element, save) : save();
208
206
  };
209
207
  const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
210
- onBoxSizeCommitRef.current = (sel, ...rest) => {
211
- noteSave(sel.element);
212
- return onBoxSizeCommit(sel, ...rest);
213
- };
208
+ onBoxSizeCommitRef.current = (sel, ...rest) =>
209
+ counted(sel.element, () => onBoxSizeCommit(sel, ...rest));
214
210
  const onRotationCommitRef = useRef(onRotationCommit);
215
- onRotationCommitRef.current = (sel, next) => {
216
- noteSave(sel.element);
217
- return onRotationCommit(sel, next);
218
- };
211
+ onRotationCommitRef.current = (sel, next) =>
212
+ counted(sel.element, () => onRotationCommit(sel, next));
219
213
  const onStyleCommitRef = useRef(onStyleCommit);
220
214
  onStyleCommitRef.current =
221
215
  onStyleCommit &&
222
216
  ((property, value) => {
223
- if (selectionRef.current) noteSave(selectionRef.current.element);
224
- return onStyleCommit(property, value);
217
+ const save = () => onStyleCommit(property, value);
218
+ return selectionRef.current ? counted(selectionRef.current.element, save) : save();
225
219
  });
226
220
  const onBlockedMoveRef = useRef(onBlockedMove);
227
221
  onBlockedMoveRef.current = onBlockedMove;
@@ -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 });
@@ -219,10 +219,13 @@ export function startGesture(
219
219
  // Hold a % translate as the same px now, so a growing box can't drag it along mid-frame.
220
220
  if (result.member.plainTranslate) applyManualOffsetDragDraft(result.member, 0, 0);
221
221
  } else {
222
- manualEditDragToken = beginStudioManualEditGesture(sel.element);
222
+ manualEditDragToken = beginStudioManualEditGesture(sel.element, "resize");
223
223
  }
224
224
  } else {
225
- manualEditDragToken = beginStudioManualEditGesture(sel.element);
225
+ manualEditDragToken = beginStudioManualEditGesture(
226
+ sel.element,
227
+ kind === "rotate" ? "rotate" : "resize",
228
+ );
226
229
  }
227
230
  }
228
231
 
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { expect, it } from "vitest";
3
- import { ensureImportedFontFace, injectPreviewImportedFont } from "../../utils/studioFontHelpers";
3
+ import { importedFontFaceCssFor, injectPreviewImportedFont } from "../../utils/studioFontHelpers";
4
4
  import { importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
5
5
 
6
6
  const face = (file: string, extra: Partial<ImportedFontAsset> = {}): ImportedFontAsset => ({
@@ -30,24 +30,25 @@ it("a face says which weight and style it draws, a variable file its weight rang
30
30
  );
31
31
  });
32
32
 
33
- it("two weights of one family are both kept in the file and both drawn in the preview", () => {
33
+ it("two weights of one family are both drawn in the preview", () => {
34
34
  const bold = face("Poppins-Bold.ttf", { weight: "700", style: "normal" });
35
35
  const regular = face("Poppins-Regular.ttf", { weight: "400", style: "normal" });
36
- const html = "<html><head></head><body></body></html>";
37
- const saved = ensureImportedFontFace(
38
- ensureImportedFontFace(html, bold, "index.html"),
39
- regular,
40
- "index.html",
41
- );
42
- expect(saved).toContain("font-weight: 700;");
43
- expect(saved).toContain("font-weight: 400;");
44
- expect(ensureImportedFontFace(saved, bold, "index.html")).toBe(saved);
45
36
  injectPreviewImportedFont(document, bold);
46
37
  injectPreviewImportedFont(document, regular);
47
38
  injectPreviewImportedFont(document, bold);
48
39
  expect(document.head.querySelectorAll("style")).toHaveLength(2);
49
40
  });
50
41
 
42
+ it("the saved rule points at the font from the file that uses it", () => {
43
+ const bold = face("Poppins-Bold.ttf");
44
+ expect(importedFontFaceCssFor(bold, "index.html")).toContain(
45
+ 'url("assets/fonts/Poppins/Poppins-Bold.ttf")',
46
+ );
47
+ expect(importedFontFaceCssFor(bold, "compositions/intro.html")).toContain(
48
+ 'url("../assets/fonts/Poppins/Poppins-Bold.ttf")',
49
+ );
50
+ });
51
+
51
52
  it("a weight the browser would drop is left out, and a family cannot close the style block", () => {
52
53
  for (const weight of ["0", "5000", "bold", "700;}"])
53
54
  expect(importedFontFaceCss(face("x.ttf", { weight }))).not.toContain("font-weight");
@@ -56,14 +57,3 @@ it("a weight the browser would drop is left out, and a family cannot close the s
56
57
  "</style>",
57
58
  );
58
59
  });
59
-
60
- it("a file name with a replacement pattern is saved as written, not expanded into the page", () => {
61
- const html = "<html><head></head><body>rest</body></html>";
62
- const odd = { family: "Cash", path: "assets/Cash$'Font.ttf", url: "/Cash$'Font.ttf" };
63
- const saved = ensureImportedFontFace(html, odd, "index.html");
64
- expect(saved).toContain("Cash$'Font.ttf");
65
- expect(saved.match(/<body>/g)).toHaveLength(1);
66
- const again = ensureImportedFontFace(saved, { ...odd, path: "assets/Cash$&.ttf" }, "index.html");
67
- expect(again).toContain("Cash$&.ttf");
68
- expect(again.match(/<body>/g)).toHaveLength(1);
69
- });
@@ -72,18 +72,22 @@ function pointer(target: Element, type: string, x: number, y: number) {
72
72
  }
73
73
 
74
74
  /** The real player with a two-layer preview, and the real overlay editing it. */
75
- function mountEditor(group: boolean) {
75
+ function mountEditor(
76
+ group: boolean,
77
+ saving: { landed: Promise<void> } = { landed: Promise.resolve() },
78
+ ) {
76
79
  file = { title: "", sub: "" };
77
80
  const live = makePreview(TWO_LAYERS);
78
81
  player = mountPlayerWithPreview(live);
79
82
  const selections = ["title", "sub"].map((id) => makeSelection(id, byId(live, id)));
83
+ for (const s of selections) s.capabilities.canApplyManualRotation = true;
84
+ const saveStyle = (sel: DomEditSelection | null, style: string) =>
85
+ saving.landed.then(() => void (file = { ...file, [sel?.id ?? "title"]: style }));
80
86
  layout.group = group
81
87
  ? selections.map((s) => ({ key: s.id, selection: s, element: s.element, rect: RECT }))
82
88
  : [];
83
- const write = (sel: DomEditSelection, next: { x: number; y: number }) => {
84
- file = { ...file, [sel.id!]: `translate: ${next.x}px ${next.y}px` };
85
- return Promise.resolve();
86
- };
89
+ const write = (sel: DomEditSelection, next: { x: number; y: number }) =>
90
+ saveStyle(sel, `translate: ${next.x}px ${next.y}px`);
87
91
  const onPathOffsetCommit = vi.fn(write);
88
92
  const onGroupPathOffsetCommit = vi.fn(
89
93
  async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
@@ -109,8 +113,9 @@ function mountEditor(group: boolean) {
109
113
  onBlockedMove={() => undefined}
110
114
  onPathOffsetCommit={onPathOffsetCommit}
111
115
  onGroupPathOffsetCommit={onGroupPathOffsetCommit}
112
- onBoxSizeCommit={() => undefined}
113
- onRotationCommit={() => undefined}
116
+ onBoxSizeCommit={(sel, size) => saveStyle(sel, `width: ${size.width}px`)}
117
+ onRotationCommit={(sel, next) => saveStyle(sel, `rotate: ${next.angle}deg`)}
118
+ onStyleCommit={(property, value) => saveStyle(selections[0]!, `${property}: ${value}`)}
114
119
  />
115
120
  </PreviewReadOnlyProvider>,
116
121
  ),
@@ -177,18 +182,70 @@ describe("a reload during a drag", () => {
177
182
  },
178
183
  );
179
184
 
180
- it("that paints after the drop shows the drop from a fresh load, not the file before it", async () => {
181
- const { live, overlay, box } = mountEditor(false);
182
- pointer(box, "pointerdown", 150, 150);
183
- pointer(overlay, "pointermove", 190, 170);
185
+ const handle = (overlay: HTMLElement, selector: string) => overlay.querySelector(selector)!;
186
+ const corner = (overlay: HTMLElement) =>
187
+ [...overlay.querySelectorAll<HTMLElement>("div.h-4.w-4")].reduce((a, b) =>
188
+ parseFloat(b.style.left) + parseFloat(b.style.top) >
189
+ parseFloat(a.style.left) + parseFloat(a.style.top)
190
+ ? b
191
+ : a,
192
+ );
193
+ // Each edit presses at `from`, drags to `to`, and releases on `on`.
194
+ const edits = {
195
+ move: (o: HTMLElement) => ({ press: handle(o, BOX), on: o, from: [150, 150], to: [190, 170] }),
196
+ resize: (o: HTMLElement) => ({ press: corner(o), on: o, from: [300, 200], to: [340, 220] }),
197
+ rotate: (o: HTMLElement) => ({
198
+ press: handle(o, '[aria-label="Rotate selection"]'),
199
+ on: o,
200
+ from: [200, 80],
201
+ to: [270, 150],
202
+ }),
203
+ crop: (o: HTMLElement) => {
204
+ const edge = handle(o, '[aria-label="Crop left"]');
205
+ return { press: edge, on: edge, from: [100, 150], to: [140, 150] };
206
+ },
207
+ };
208
+
209
+ function drag(edit: { press: Element; on: Element; from: number[]; to: number[] }) {
210
+ pointer(edit.press, "pointerdown", edit.from[0]!, edit.from[1]!);
211
+ pointer(edit.on, "pointermove", edit.to[0]!, edit.to[1]!);
212
+ }
213
+
214
+ it.each(Object.keys(edits) as (keyof typeof edits)[])(
215
+ "that paints after a %s shows it from a fresh load, not the file before it",
216
+ async (kind) => {
217
+ const { live, overlay } = mountEditor(false);
218
+ const edit = edits[kind](overlay);
219
+ drag(edit);
220
+ act(() => api().refreshPlayer());
221
+ const beforeDrop = served("?_t=1");
222
+ pointer(edit.on, "pointerup", edit.to[0]!, edit.to[1]!);
223
+ await settle();
224
+ expect(file.title, "the edit saved").not.toBe("");
225
+
226
+ const requested = await paintShadow(api, beforeDrop);
227
+ expect(api().iframeRef.current, "the file before the edit").toBe(live);
228
+ const fresh = served("?_t=2");
229
+ expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
230
+ expect(byId(fresh, "title").getAttribute("style")).toBe(file.title);
231
+ },
232
+ );
233
+
234
+ it("requested while the drop is still saving shows the drop from a fresh load", async () => {
235
+ let land: () => void = () => {};
236
+ const { live, overlay } = mountEditor(false, { landed: new Promise((r) => (land = r)) });
237
+ const edit = edits.move(overlay);
238
+ drag(edit);
239
+ pointer(edit.on, "pointerup", edit.to[0]!, edit.to[1]!);
240
+ await settle();
184
241
  act(() => api().refreshPlayer());
185
- const beforeDrop = served("?_t=1");
186
- pointer(overlay, "pointerup", 190, 170);
242
+ const beforeSave = served("?_t=1");
243
+ await act(async () => land());
187
244
  await settle();
188
245
  expect(file.title).toBe("translate: 40px 20px");
189
246
 
190
- const requested = await paintShadow(api, beforeDrop);
191
- expect(api().iframeRef.current, "the file before the drop").toBe(live);
247
+ const requested = await paintShadow(api, beforeSave);
248
+ expect(api().iframeRef.current, "the file before the save").toBe(live);
192
249
  const fresh = served("?_t=2");
193
250
  expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
194
251
  expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
@@ -222,24 +279,30 @@ describe("every way a drag ends without a drop clears its mark and promotes the
222
279
  );
223
280
  });
224
281
 
225
- it("a press that throws before the drag is armed leaves no mark behind", async () => {
226
- const editor = mountEditor(false);
227
- HTMLElement.prototype.setPointerCapture = () => {
228
- throw new Error("capture refused");
229
- };
230
- const swallow = (event: ErrorEvent) => event.preventDefault();
231
- window.addEventListener("error", swallow);
232
- try {
233
- pointer(editor.box, "pointerdown", 150, 150);
234
- } catch {
235
- // React may rethrow the handler's error; either way the mark must be gone.
236
- } finally {
237
- window.removeEventListener("error", swallow);
238
- }
239
- expect(marked(editor.live.contentDocument!)).toHaveLength(0);
240
- const { shadow } = await reloadMidGesture();
241
- expect(api().iframeRef.current).toBe(shadow);
242
- });
282
+ it.each([
283
+ ["one layer", false],
284
+ ["a group", true],
285
+ ])(
286
+ "a press on %s that throws before the drag is armed leaves no mark behind",
287
+ async (_, group) => {
288
+ const editor = mountEditor(group);
289
+ HTMLElement.prototype.setPointerCapture = () => {
290
+ throw new Error("capture refused");
291
+ };
292
+ const swallow = (event: ErrorEvent) => event.preventDefault();
293
+ window.addEventListener("error", swallow);
294
+ try {
295
+ pointer(editor.box, "pointerdown", 150, 150);
296
+ } catch {
297
+ // React may rethrow the handler's error; either way the mark must be gone.
298
+ } finally {
299
+ window.removeEventListener("error", swallow);
300
+ }
301
+ expect(marked(editor.live.contentDocument!)).toHaveLength(0);
302
+ const { shadow } = await reloadMidGesture();
303
+ expect(api().iframeRef.current).toBe(shadow);
304
+ },
305
+ );
243
306
  });
244
307
 
245
308
  describe("an inline text edit", () => {
@@ -1,3 +1,6 @@
1
+ export const gsapRendersTransform = (el: Element) =>
2
+ !!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
3
+
1
4
  /**
2
5
  * Checks whether GSAP actively animates one or more CSS/GSAP properties on
3
6
  * the given element by inspecting all registered `__timelines`.
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { describe, expect, it, vi } from "vitest";
2
3
  import { Window } from "happy-dom";
3
4
  import {
@@ -285,7 +286,7 @@ describe("studio manual edits", () => {
285
286
  expect(set).not.toHaveBeenCalled();
286
287
  expect(tickerTick).not.toHaveBeenCalled();
287
288
 
288
- beginStudioManualEditGesture(card);
289
+ beginStudioManualEditGesture(card, "move");
289
290
  applyStudioPathOffsetDraft(card, { x: 35, y: -6 });
290
291
 
291
292
  expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
@@ -395,8 +396,8 @@ describe("studio manual edits", () => {
395
396
  const card = document.getElementById("card") as HTMLElement;
396
397
 
397
398
  applyStudioPathOffset(card, { x: 40, y: 24 });
398
- const firstToken = beginStudioManualEditGesture(card);
399
- const secondToken = beginStudioManualEditGesture(card);
399
+ const firstToken = beginStudioManualEditGesture(card, "move");
400
+ const secondToken = beginStudioManualEditGesture(card, "move");
400
401
  endStudioManualEditGesture(card, firstToken);
401
402
 
402
403
  // Gesture still active — offset should remain