@hyperframes/studio 0.8.105 → 0.8.106

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 (79) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
  3. package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
  7. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  8. package/dist/index.d.ts +15 -4
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +6601 -6022
  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/captions/keyboard.test.ts +6 -0
  16. package/src/captions/keyboard.ts +2 -1
  17. package/src/components/StudioHeader.dom.test.tsx +31 -9
  18. package/src/components/StudioHeader.tsx +4 -1
  19. package/src/components/ThemeToggle.tsx +99 -0
  20. package/src/components/editor/DomEditOverlay.tsx +37 -5
  21. package/src/components/editor/SourceEditor.tsx +2 -6
  22. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  23. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  24. package/src/components/editor/manualEditsDom.ts +33 -0
  25. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  26. package/src/components/editor/marqueeCommit.ts +6 -1
  27. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  28. package/src/components/editor/overlayFrameLoop.ts +21 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  30. package/src/components/editor/propertyPanelFont.tsx +69 -46
  31. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  32. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  33. package/src/components/editor/studioCodeTheme.ts +99 -0
  34. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  35. package/src/components/editor/useDomEditNudge.ts +2 -2
  36. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  37. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  38. package/src/components/editor/useMotionPathData.ts +5 -1
  39. package/src/hooks/appHotkeysDispatch.ts +2 -1
  40. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  41. package/src/hooks/useAppHotkeys.test.ts +32 -0
  42. package/src/hooks/useAppHotkeys.ts +1 -2
  43. package/src/hooks/useClipboard.source.test.ts +99 -19
  44. package/src/hooks/useClipboard.ts +145 -124
  45. package/src/hooks/useDomEditSession.test.tsx +76 -72
  46. package/src/hooks/useDomEditSession.ts +7 -43
  47. package/src/hooks/useGsapTweenCache.ts +1 -2
  48. package/src/hooks/useInlineTextEdit.ts +16 -2
  49. package/src/hooks/useLivePreviewIframe.ts +18 -0
  50. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  51. package/src/hooks/usePreviewPersistence.ts +2 -5
  52. package/src/hooks/useRenderClipContent.ts +3 -3
  53. package/src/index.ts +2 -0
  54. package/src/livePreviewIframeExport.test.tsx +33 -0
  55. package/src/main.tsx +4 -1
  56. package/src/player/components/AudioWaveform.test.tsx +36 -15
  57. package/src/player/components/AudioWaveform.tsx +24 -7
  58. package/src/player/components/TimelineClip.test.tsx +38 -0
  59. package/src/player/components/TimelineClip.tsx +14 -6
  60. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  61. package/src/player/components/TimelineClipFades.tsx +368 -137
  62. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  63. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  64. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  65. package/src/player/lib/automationStoreSync.test.ts +18 -1
  66. package/src/player/lib/automationStoreSync.ts +29 -17
  67. package/src/player/sceneSwap.ts +18 -0
  68. package/src/styles/components.css +26 -0
  69. package/src/styles/studio.css +6 -0
  70. package/src/styles/theme.css +10 -0
  71. package/src/utils/clipboardPayload.ts +10 -1
  72. package/src/utils/studioPendingEdits.ts +21 -0
  73. package/src/utils/studioTheme.test.tsx +141 -0
  74. package/src/utils/studioTheme.ts +34 -0
  75. package/src/utils/studioUiPreferences.ts +3 -0
  76. package/src/utils/typingTarget.test.ts +8 -1
  77. package/src/utils/typingTarget.ts +5 -0
  78. package/dist/assets/index-Cre9X4RS.css +0 -1
  79. 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.106",
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/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"
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.106"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -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");
@@ -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={
@@ -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
+ }
@@ -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 { noteStudioManualEditSave as noteSave } 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,34 @@ 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 before it starts, so a reload requested earlier loads again.
196
199
  const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
197
- onPathOffsetCommitRef.current = onPathOffsetCommit;
200
+ onPathOffsetCommitRef.current = (sel, ...rest) => {
201
+ noteSave(sel.element);
202
+ return onPathOffsetCommit(sel, ...rest);
203
+ };
198
204
  const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
199
- onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit;
205
+ onGroupPathOffsetCommitRef.current = (updates) => {
206
+ if (updates[0]) noteSave(updates[0].selection.element);
207
+ return onGroupPathOffsetCommit(updates);
208
+ };
200
209
  const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
201
- onBoxSizeCommitRef.current = onBoxSizeCommit;
210
+ onBoxSizeCommitRef.current = (sel, ...rest) => {
211
+ noteSave(sel.element);
212
+ return onBoxSizeCommit(sel, ...rest);
213
+ };
202
214
  const onRotationCommitRef = useRef(onRotationCommit);
203
- onRotationCommitRef.current = onRotationCommit;
215
+ onRotationCommitRef.current = (sel, next) => {
216
+ noteSave(sel.element);
217
+ return onRotationCommit(sel, next);
218
+ };
204
219
  const onStyleCommitRef = useRef(onStyleCommit);
205
- onStyleCommitRef.current = onStyleCommit;
220
+ onStyleCommitRef.current =
221
+ onStyleCommit &&
222
+ ((property, value) => {
223
+ if (selectionRef.current) noteSave(selectionRef.current.element);
224
+ return onStyleCommit(property, value);
225
+ });
206
226
  const onBlockedMoveRef = useRef(onBlockedMove);
207
227
  onBlockedMoveRef.current = onBlockedMove;
208
228
  const onManualDragStartRef = useRef(onManualDragStart);
@@ -282,6 +302,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
282
302
  useEffect(() => {
283
303
  if (readOnly) gestures.clearPointerState(selectionRef);
284
304
  }, [gestures, readOnly, selectionRef]);
305
+ // A gesture that loses its pointer, the window or the overlay is cancelled, so its mark goes too.
306
+ const cancelGestureRef = useRef(() => {});
307
+ cancelGestureRef.current = () => gestures.clearPointerState(selectionRef);
308
+ useMountEffect(() => {
309
+ const cancel = () => cancelGestureRef.current();
310
+ window.addEventListener("blur", cancel);
311
+ return () => {
312
+ window.removeEventListener("blur", cancel);
313
+ cancel();
314
+ };
315
+ });
285
316
 
286
317
  // Arrow-key nudge (1px, Shift = 10px) — commits through the same
287
318
  // path-offset callbacks as a drag, one undo entry per key burst.
@@ -463,6 +494,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
463
494
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
464
495
  onPointerUp={marquee.onPointerUp}
465
496
  onPointerCancel={marquee.onPointerCancel}
497
+ onLostPointerCapture={() => cancelGestureRef.current()}
466
498
  onContextMenu={hostInput ? undefined : handleContextMenu}
467
499
  >
468
500
  {!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
@@ -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
 
@@ -12,6 +12,7 @@ import {
12
12
  import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
13
13
  import {
14
14
  beginStudioManualEditGesture,
15
+ endStudioManualEditGesture,
15
16
  captureStudioBoxSize,
16
17
  captureStudioPathOffset,
17
18
  captureStudioRotation,
@@ -81,48 +82,54 @@ export function startGroupDrag(
81
82
  }
82
83
  members.push(result.member);
83
84
  }
84
- resetDragMoveLog();
85
- logDrag("group-start", {
86
- // A member whose mapping differs from its neighbours travels a different
87
- // distance for the same pointer delta, which is the group coming apart.
88
- members: Object.fromEntries(
89
- members.map((member) => [
90
- member.key,
91
- {
92
- map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
93
- base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
94
- offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
95
- },
96
- ]),
97
- ),
98
- at: readDragPositions(members),
99
- });
85
+ try {
86
+ resetDragMoveLog();
87
+ logDrag("group-start", {
88
+ // A member whose mapping differs from its neighbours travels a different
89
+ // distance for the same pointer delta, which is the group coming apart.
90
+ members: Object.fromEntries(
91
+ members.map((member) => [
92
+ member.key,
93
+ {
94
+ map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
95
+ base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
96
+ offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
97
+ },
98
+ ]),
99
+ ),
100
+ at: readDragPositions(members),
101
+ });
100
102
 
101
- const overlayEl = opts.overlayRef.current;
102
- const iframe = opts.iframeRef.current;
103
- const snapContext =
104
- overlayEl && iframe
105
- ? collectSnapContext({
106
- overlayEl,
107
- iframe,
108
- excludeElements: buildExcludeElements({
103
+ const overlayEl = opts.overlayRef.current;
104
+ const iframe = opts.iframeRef.current;
105
+ const snapContext =
106
+ overlayEl && iframe
107
+ ? collectSnapContext({
108
+ overlayEl,
109
109
  iframe,
110
- groupSelections: items.map((i) => i.selection),
111
- }),
112
- })
113
- : undefined;
110
+ excludeElements: buildExcludeElements({
111
+ iframe,
112
+ groupSelections: items.map((i) => i.selection),
113
+ }),
114
+ })
115
+ : undefined;
114
116
 
115
- e.preventDefault();
116
- e.stopPropagation();
117
- e.currentTarget.setPointerCapture(e.pointerId);
118
- opts.rafPausedRef.current = true;
119
- opts.groupGestureRef.current = {
120
- startX: e.clientX,
121
- startY: e.clientY,
122
- originItems: items,
123
- members,
124
- snapContext,
125
- };
117
+ e.preventDefault();
118
+ e.stopPropagation();
119
+ e.currentTarget.setPointerCapture(e.pointerId);
120
+ opts.rafPausedRef.current = true;
121
+ opts.groupGestureRef.current = {
122
+ startX: e.clientX,
123
+ startY: e.clientY,
124
+ originItems: items,
125
+ members,
126
+ snapContext,
127
+ };
128
+ } catch (error) {
129
+ opts.groupGestureRef.current = null;
130
+ restoreManualOffsetDragMembers(members);
131
+ throw error;
132
+ }
126
133
  return true;
127
134
  }
128
135
 
@@ -219,84 +226,89 @@ export function startGesture(
219
226
  }
220
227
  }
221
228
 
222
- // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
223
- // move, never asks GSAP: reading a property makes it bake the CSS into its transform.
224
- const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
225
- const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
226
- const rotation = { angle: readRotationBase(sel.element, plain) };
227
- const overlayBounds = overlayEl?.getBoundingClientRect();
228
- const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
229
- const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
229
+ // A throw before the gesture is armed would leave its mark, and the mark holds every reload.
230
+ try {
231
+ // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
232
+ // move, never asks GSAP: reading a property makes it bake the CSS into its transform.
233
+ const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
234
+ const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
235
+ const rotation = { angle: readRotationBase(sel.element, plain) };
236
+ const overlayBounds = overlayEl?.getBoundingClientRect();
237
+ const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
238
+ const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
230
239
 
231
- const iframe = opts.iframeRef.current;
240
+ const iframe = opts.iframeRef.current;
232
241
 
233
- // For a center-anchored corner resize, capture the element's rendered CENTER (the
234
- // centroid of its four real, rotation-aware corners) now, so per-frame anchoring
235
- // can pin that exact point instead of an axis-aligned width/height delta (which
236
- // only holds the center still when the element grows symmetrically from an
237
- // unrotated layout box). Present whenever an anchor member exists (all corners).
238
- let resizeFixedCenterStart: { x: number; y: number } | undefined;
239
- if (kind === "resize" && pathOffsetMember && overlayEl && iframe) {
240
- const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
241
- if (corners) resizeFixedCenterStart = overlayCornersCentroid(corners);
242
- }
243
- const snapContext =
244
- (kind === "drag" || kind === "resize") && overlayEl && iframe
245
- ? collectSnapContext({
246
- overlayEl,
247
- iframe,
248
- excludeElements: buildExcludeElements({ iframe, selection: sel }),
249
- })
250
- : undefined;
251
- e.preventDefault();
252
- e.stopPropagation();
253
- e.currentTarget.setPointerCapture(e.pointerId);
254
- opts.rafPausedRef.current = true;
255
- opts.gestureRef.current = {
256
- kind,
257
- mode,
258
- selection: sel,
259
- startX: e.clientX,
260
- startY: e.clientY,
261
- centerX,
262
- centerY,
263
- initialPathOffset,
264
- initialRotation: captureStudioRotation(sel.element),
265
- initialBoxSize: captureStudioBoxSize(sel.element),
266
- pathOffsetMember,
267
- originLeft: rect.left,
268
- originTop: rect.top,
269
- originWidth: rect.width,
270
- originHeight: rect.height,
271
- actualWidth,
272
- actualHeight,
273
- actualRotation: rotation.angle,
274
- plainRotation,
275
- editScaleX: rect.editScaleX,
276
- editScaleY: rect.editScaleY,
277
- contentScaleX,
278
- contentScaleY,
279
- manualEditDragToken,
280
- snapContext,
281
- resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
282
- resizeFixedCenterStart,
283
- };
284
- if (kind === "resize") {
285
- resetResizeMoveLog();
286
- logResize("start", {
287
- handle: options?.resizeHandle ?? "se",
288
- pointer: { x: e.clientX, y: e.clientY },
289
- center: { x: centerX, y: centerY },
290
- origin: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
291
- actual: { w: actualWidth, h: actualHeight },
292
- editScale: { x: rect.editScaleX, y: rect.editScaleY },
293
- contentScale: { x: contentScaleX, y: contentScaleY },
294
- rotation: rotation.angle,
295
- hasOffsetMember: !!pathOffsetMember,
296
- fixedCenterStart: resizeFixedCenterStart ?? null,
297
- initialBoxSize: opts.gestureRef.current?.initialBoxSize ?? null,
298
- initialInlineStyle: sel.element.getAttribute("style"),
299
- });
242
+ // A corner resize pins the centroid of the four rotation-aware corners: an axis-aligned
243
+ // size delta only holds the centre still for a symmetric, unrotated growth.
244
+ let resizeFixedCenterStart: { x: number; y: number } | undefined;
245
+ if (kind === "resize" && pathOffsetMember && overlayEl && iframe) {
246
+ const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
247
+ if (corners) resizeFixedCenterStart = overlayCornersCentroid(corners);
248
+ }
249
+ const snapContext =
250
+ (kind === "drag" || kind === "resize") && overlayEl && iframe
251
+ ? collectSnapContext({
252
+ overlayEl,
253
+ iframe,
254
+ excludeElements: buildExcludeElements({ iframe, selection: sel }),
255
+ })
256
+ : undefined;
257
+ e.preventDefault();
258
+ e.stopPropagation();
259
+ e.currentTarget.setPointerCapture(e.pointerId);
260
+ opts.rafPausedRef.current = true;
261
+ opts.gestureRef.current = {
262
+ kind,
263
+ mode,
264
+ selection: sel,
265
+ startX: e.clientX,
266
+ startY: e.clientY,
267
+ centerX,
268
+ centerY,
269
+ initialPathOffset,
270
+ initialRotation: captureStudioRotation(sel.element),
271
+ initialBoxSize: captureStudioBoxSize(sel.element),
272
+ pathOffsetMember,
273
+ originLeft: rect.left,
274
+ originTop: rect.top,
275
+ originWidth: rect.width,
276
+ originHeight: rect.height,
277
+ actualWidth,
278
+ actualHeight,
279
+ actualRotation: rotation.angle,
280
+ plainRotation,
281
+ editScaleX: rect.editScaleX,
282
+ editScaleY: rect.editScaleY,
283
+ contentScaleX,
284
+ contentScaleY,
285
+ manualEditDragToken,
286
+ snapContext,
287
+ resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
288
+ resizeFixedCenterStart,
289
+ };
290
+ if (kind === "resize") {
291
+ resetResizeMoveLog();
292
+ logResize("start", {
293
+ handle: options?.resizeHandle ?? "se",
294
+ pointer: { x: e.clientX, y: e.clientY },
295
+ center: { x: centerX, y: centerY },
296
+ origin: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
297
+ actual: { w: actualWidth, h: actualHeight },
298
+ editScale: { x: rect.editScaleX, y: rect.editScaleY },
299
+ contentScale: { x: contentScaleX, y: contentScaleY },
300
+ rotation: rotation.angle,
301
+ hasOffsetMember: !!pathOffsetMember,
302
+ fixedCenterStart: resizeFixedCenterStart ?? null,
303
+ initialBoxSize: opts.gestureRef.current?.initialBoxSize ?? null,
304
+ initialInlineStyle: sel.element.getAttribute("style"),
305
+ });
306
+ }
307
+ } catch (error) {
308
+ opts.gestureRef.current = null;
309
+ if (pathOffsetMember) restoreManualOffsetDragMembers([pathOffsetMember]);
310
+ else if (manualEditDragToken) endStudioManualEditGesture(sel.element, manualEditDragToken);
311
+ throw error;
300
312
  }
301
313
  return true;
302
314
  }