@hyperframes/studio 0.8.86 → 0.8.87

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 (83) hide show
  1. package/dist/assets/hyperframes-player-CtNVImMi.js +473 -0
  2. package/dist/assets/{index-Cz3E_ZSj.js → index-B_MF86RE.js} +1 -1
  3. package/dist/assets/{index-8Fgryj7q.js → index-DJ-Ol7YS.js} +1 -1
  4. package/dist/assets/{index-d5DKK15Q.js → index-DZKcDAtf.js} +203 -203
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  7. package/dist/chunk-PQFEGZWM.js.map +1 -0
  8. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  9. package/dist/index.d.ts +203 -160
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +1958 -1611
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/editor/DomEditOverlay.tsx +1 -2
  16. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  18. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  19. package/src/components/editor/LayersPanel.test.ts +14 -0
  20. package/src/components/editor/LayersPanel.tsx +2 -2
  21. package/src/components/editor/TopologyLens.tsx +2 -2
  22. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  25. package/src/components/editor/domEditing.test.ts +29 -0
  26. package/src/components/editor/domEditingDom.ts +1 -0
  27. package/src/components/editor/domEditingTypes.ts +1 -0
  28. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  29. package/src/components/editor/marqueeCommit.ts +13 -1
  30. package/src/components/editor/useColorGradingController.test.ts +24 -0
  31. package/src/components/editor/useColorGradingController.ts +3 -2
  32. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  33. package/src/components/nle/useCompositionStack.test.tsx +53 -0
  34. package/src/components/nle/useCompositionStack.ts +4 -8
  35. package/src/hooks/domStyleCommit.ts +129 -0
  36. package/src/hooks/gsapEditOutcome.ts +2 -2
  37. package/src/hooks/gsapElementMatch.ts +38 -0
  38. package/src/hooks/gsapResizeIntercept.ts +10 -31
  39. package/src/hooks/gsapResizePreflight.ts +56 -0
  40. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  41. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  42. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  43. package/src/hooks/useDomEditCommits.ts +17 -182
  44. package/src/hooks/useDomEditPersist.ts +225 -0
  45. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  46. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  47. package/src/hooks/useDomEditSession.test.tsx +51 -1
  48. package/src/hooks/useDomEditSession.ts +11 -2
  49. package/src/hooks/useDomEditTextCommits.ts +15 -99
  50. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  51. package/src/hooks/useDomStyleCommit.ts +74 -0
  52. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  53. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  54. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  55. package/src/hooks/useGsapAwareEditing.ts +1 -1
  56. package/src/hooks/useGsapTweenCache.ts +2 -48
  57. package/src/index.ts +10 -1
  58. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  59. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  60. package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
  61. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  62. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  63. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  64. package/src/player/components/TimelineGroupRow.tsx +4 -1
  65. package/src/player/components/TimelineLanes.tsx +5 -5
  66. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  67. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  68. package/src/player/components/TimelineTypes.ts +1 -0
  69. package/src/player/components/timelineLaneProps.ts +1 -0
  70. package/src/player/components/timelineMountTestEnv.ts +29 -0
  71. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  72. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  73. package/src/player/components/useTimelineProviderState.tsx +6 -12
  74. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  75. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  78. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  79. package/src/player/sceneSwap.test.ts +95 -0
  80. package/src/player/sceneSwap.ts +53 -0
  81. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
  82. package/dist/chunk-3IOCVWB6.js.map +0 -1
  83. /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
@@ -35,6 +35,15 @@ import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection
35
35
  import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
36
36
  import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
37
37
 
38
+ export function notifyBlockedPress(
39
+ e: React.PointerEvent<HTMLElement>,
40
+ opts: UseDomEditOverlayGesturesOptions,
41
+ selection: DomEditSelection,
42
+ ): void {
43
+ if (e.button !== 0 || selection.capabilities.commitCheckPending) return;
44
+ opts.onBlockedMoveRef.current(selection);
45
+ }
46
+
38
47
  export function startGroupDrag(
39
48
  e: React.PointerEvent<HTMLElement>,
40
49
  opts: UseDomEditOverlayGesturesOptions,
@@ -48,7 +57,7 @@ export function startGroupDrag(
48
57
  if (blockedSelection) {
49
58
  e.preventDefault();
50
59
  e.stopPropagation();
51
- opts.onBlockedMoveRef.current(blockedSelection);
60
+ notifyBlockedPress(e, opts, blockedSelection);
52
61
  return false;
53
62
  }
54
63
 
@@ -17,6 +17,7 @@ import {
17
17
  resolveDomEditCapabilities,
18
18
  resolveDomEditSelection,
19
19
  } from "./domEditing";
20
+ import { normalizeTimelineCompositionSource } from "./domEditingDom";
20
21
 
21
22
  function createDocument(markup: string): Document {
22
23
  const window = new Window();
@@ -910,6 +911,34 @@ describe("resolveDomEditSelection", () => {
910
911
  expect(first).not.toBe(second);
911
912
  });
912
913
 
914
+ it("reads a preview prefix only from a URL, not from a project folder named preview", () => {
915
+ expect(normalizeTimelineCompositionSource("sections/preview/x.html")).toBe(
916
+ "sections/preview/x.html",
917
+ );
918
+ expect(
919
+ normalizeTimelineCompositionSource(
920
+ "http://localhost:5190/api/projects/p/preview/sections/x.html",
921
+ ),
922
+ ).toBe("sections/x.html");
923
+ expect(normalizeTimelineCompositionSource("/api/projects/p/preview/comp/sections/x.html")).toBe(
924
+ "sections/x.html",
925
+ );
926
+ });
927
+
928
+ it("finds a composition host authored with a ./ path", () => {
929
+ const document = createDocument(`
930
+ <div data-composition-id="scene" data-composition-file="./compositions/scene.html"></div>
931
+ `);
932
+
933
+ expect(
934
+ findElementForTimelineElement(
935
+ document,
936
+ { id: "scene", compositionSrc: "./compositions/scene.html" },
937
+ { activeCompositionPath: null, isMasterView: true },
938
+ )?.getAttribute("data-composition-id"),
939
+ ).toBe("scene");
940
+ });
941
+
913
942
  it("resolves generated timeline ids without throwing", () => {
914
943
  const document = createDocument(`
915
944
  <div data-composition-id="hook">
@@ -199,6 +199,7 @@ export function getSourceFileForElement(
199
199
  export function normalizeTimelineCompositionSource(value: string | undefined): string | undefined {
200
200
  const trimmed = value?.trim();
201
201
  if (!trimmed) return undefined;
202
+ if (!/^([a-z][a-z0-9+.-]*:|\/)/i.test(trimmed)) return trimmed;
202
203
 
203
204
  let pathname = trimmed;
204
205
  try {
@@ -61,6 +61,7 @@ export interface DomEditCapabilities {
61
61
  canApplyManualSize: boolean;
62
62
  canApplyManualRotation: boolean;
63
63
  reasonIfDisabled?: string;
64
+ commitCheckPending?: boolean;
64
65
  }
65
66
 
66
67
  export interface DomEditTextField {
@@ -0,0 +1,84 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, useRef } from "react";
3
+ import type { Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, expect, it } from "vitest";
5
+ import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
6
+ import { useMarqueeGestures } from "./marqueeCommit";
7
+
8
+ installReactActEnvironment();
9
+ HTMLElement.prototype.setPointerCapture ??= () => {};
10
+ HTMLElement.prototype.releasePointerCapture ??= () => {};
11
+
12
+ function Overlay() {
13
+ const overlayRef = useRef<HTMLDivElement>(null);
14
+ const marquee = useMarqueeGestures({
15
+ iframeRef: useRef<HTMLIFrameElement>(null),
16
+ overlayRef,
17
+ activeCompositionPathRef: useRef<string | null>("index.html"),
18
+ onMarqueeSelectRef: useRef(undefined),
19
+ });
20
+ return (
21
+ <div
22
+ ref={overlayRef}
23
+ data-overlay
24
+ onPointerDown={marquee.begin}
25
+ onPointerMove={marquee.onPointerMove}
26
+ onPointerUp={marquee.onPointerUp}
27
+ >
28
+ {marquee.marqueeRect && <div data-band />}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ const pointer = (type: string, clientX: number, clientY: number) =>
34
+ act(() => {
35
+ document
36
+ .querySelector("[data-overlay]")!
37
+ .dispatchEvent(
38
+ new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX, clientY }),
39
+ );
40
+ });
41
+ const escape = () => {
42
+ const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
43
+ act(() => {
44
+ document.body.dispatchEvent(event);
45
+ });
46
+ return event;
47
+ };
48
+
49
+ let root: Root;
50
+ let hostEscapes: KeyboardEvent[];
51
+ const hostListener = (e: KeyboardEvent) => {
52
+ if (e.key === "Escape") hostEscapes.push(e);
53
+ };
54
+
55
+ beforeEach(() => {
56
+ hostEscapes = [];
57
+ window.addEventListener("keydown", hostListener);
58
+ root = mountReactHarness(<Overlay />);
59
+ });
60
+
61
+ afterEach(() => {
62
+ window.removeEventListener("keydown", hostListener);
63
+ act(() => root.unmount());
64
+ document.body.innerHTML = "";
65
+ });
66
+
67
+ it("an escape cancels a preview band while the pointer is still down, and stops there", () => {
68
+ pointer("pointerdown", 10, 10);
69
+ pointer("pointermove", 120, 90);
70
+ expect(document.querySelector("[data-band]")).not.toBeNull();
71
+
72
+ const event = escape();
73
+
74
+ expect(document.querySelector("[data-band]")).toBeNull();
75
+ expect(event.defaultPrevented).toBe(true);
76
+ expect(hostEscapes).toHaveLength(0);
77
+ });
78
+
79
+ it("an escape with no band in flight still reaches the host", () => {
80
+ const event = escape();
81
+
82
+ expect(event.defaultPrevented).toBe(false);
83
+ expect(hostEscapes).toHaveLength(1);
84
+ });
@@ -1,5 +1,5 @@
1
1
  // fallow-ignore-file code-duplication
2
- import { useCallback, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
5
5
  import { isElementComputedVisible } from "./domEditingElement";
@@ -209,6 +209,18 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
209
209
  reset();
210
210
  }, [reset]);
211
211
 
212
+ useEffect(() => {
213
+ const cancelBandBeforeHostEscape = (e: KeyboardEvent) => {
214
+ if (e.key !== "Escape" || !marqueeRef.current) return;
215
+ e.preventDefault();
216
+ e.stopPropagation();
217
+ cancel();
218
+ };
219
+ window.addEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
220
+ return () =>
221
+ window.removeEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
222
+ }, [cancel]);
223
+
212
224
  const onPointerMove = useCallback(
213
225
  (event: React.PointerEvent<HTMLDivElement>) => {
214
226
  const m = marqueeRef.current;
@@ -7,6 +7,7 @@ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
7
7
  import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
8
8
  import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
9
9
  import { useColorGradingController } from "./useColorGradingController";
10
+ import { sceneSwapFor } from "../../player/sceneSwap";
10
11
  import type { DomEditSelection } from "./domEditing";
11
12
 
12
13
  function brightPopGrading() {
@@ -390,6 +391,29 @@ describe("useColorGradingController", () => {
390
391
  vi.useRealTimers();
391
392
  });
392
393
 
394
+ it("re-sends the grade to the preview after edited scenes are swapped in", async () => {
395
+ vi.useFakeTimers();
396
+ const { contentWindow, iframe } = createPreviewFrame();
397
+ (contentWindow as unknown as { __hfSwapScenes: () => Promise<void> }).__hfSwapScenes =
398
+ async () => {};
399
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html></html>"));
400
+ const postMessage = vi.spyOn(contentWindow, "postMessage");
401
+ const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
402
+ act(() => getState().commitColorGrading(brightPopGrading()));
403
+ await act(async () => {
404
+ vi.advanceTimersByTime(100);
405
+ await vi.runAllTimersAsync();
406
+ });
407
+ postMessage.mockClear();
408
+
409
+ await act(() => sceneSwapFor(iframe)!("/preview", () => true));
410
+ const replayed = colorGradingMessages(postMessage.mock.calls);
411
+ expect(replayed.at(-1)?.grading).toMatchObject({ preset: "bright-pop" });
412
+
413
+ act(() => root.unmount());
414
+ vi.useRealTimers();
415
+ });
416
+
393
417
  it("animates only the selected video card without starting the project player", async () => {
394
418
  vi.useFakeTimers();
395
419
  const { contentWindow, iframe } = createPreviewFrame();
@@ -1,5 +1,6 @@
1
1
  import { mediaMetadataUrl } from "../../utils/studioHelpers";
2
2
  import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
3
+ import { onPreviewContentReplaced } from "../../player/sceneSwap";
3
4
  import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
4
5
  import {
5
6
  HF_COLOR_GRADING_ATTR,
@@ -445,11 +446,11 @@ export function useColorGradingController({
445
446
  if (!acceptStudioRuntimeMessage(data)) return;
446
447
  refreshAndReplay();
447
448
  };
448
- iframe.addEventListener("load", refreshAndReplay);
449
449
  window.addEventListener("message", onMessage);
450
450
  const timer = window.setTimeout(refreshAndReplay, 80);
451
+ const stopReplay = onPreviewContentReplaced(iframe, refreshAndReplay);
451
452
  return () => {
452
- iframe.removeEventListener("load", refreshAndReplay);
453
+ stopReplay();
453
454
  window.removeEventListener("message", onMessage);
454
455
  window.clearTimeout(timer);
455
456
  };
@@ -45,6 +45,7 @@ import {
45
45
  import { resolveCenterResizeSize } from "./domEditResizeLocal";
46
46
  import { resolveResizeDraftRect } from "./resizeDraft";
47
47
  import {
48
+ notifyBlockedPress,
48
49
  startGesture as _startGesture,
49
50
  startGroupDrag as _startGroupDrag,
50
51
  } from "./domEditOverlayStartGesture";
@@ -103,6 +104,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
103
104
  },
104
105
  ) => _startGesture(kind, e, opts, options);
105
106
 
107
+ // A press on a box that cannot move says why at once.
108
+ const startBlockedMove = (e: React.PointerEvent<HTMLElement>, selection: DomEditSelection) => {
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ e.currentTarget.setPointerCapture(e.pointerId);
112
+ opts.blockedMoveRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY };
113
+ notifyBlockedPress(e, opts, selection);
114
+ };
115
+
106
116
  const moveGroupDrag = createGroupDragMover(opts, setDraftGroupOverlayItems);
107
117
 
108
118
  // fallow-ignore-next-line complexity
@@ -116,13 +126,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
116
126
  opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
117
127
  }
118
128
 
119
- if (blockedMove && sel) {
129
+ if (blockedMove) {
120
130
  const dx = e.clientX - blockedMove.startX;
121
131
  const dy = e.clientY - blockedMove.startY;
122
- if (!blockedMove.notified && Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
123
- blockedMove.notified = true;
132
+ if (Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
124
133
  opts.suppressNextBoxClickRef.current = true;
125
- opts.onBlockedMoveRef.current(sel);
126
134
  }
127
135
  return;
128
136
  }
@@ -545,5 +553,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
545
553
  opts.rafPausedRef.current = false;
546
554
  };
547
555
 
548
- return { startGesture, startGroupDrag, onPointerMove, onPointerUp, clearPointerState };
556
+ return {
557
+ startGesture,
558
+ startGroupDrag,
559
+ startBlockedMove,
560
+ onPointerMove,
561
+ onPointerUp,
562
+ clearPointerState,
563
+ };
549
564
  }
@@ -8,6 +8,59 @@ import { useCompositionStack } from "./useCompositionStack";
8
8
 
9
9
  installReactActEnvironment();
10
10
 
11
+ describe("useCompositionStack — drilling into a composition file", () => {
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ });
15
+
16
+ for (const compositionSrc of [
17
+ "sections/compositions/x.html",
18
+ "http://localhost:5190/api/projects/p/preview/sections/compositions/x.html",
19
+ "/api/projects/p/preview/comp/sections/compositions/x.html",
20
+ ]) {
21
+ it(`opens the full project path for ${compositionSrc}`, async () => {
22
+ const host = document.createElement("div");
23
+ document.body.append(host);
24
+ const root = createRoot(host);
25
+ let stack!: ReturnType<typeof useCompositionStack>;
26
+ function Harness() {
27
+ stack = useCompositionStack({ projectId: "p" });
28
+ return null;
29
+ }
30
+ await act(async () => {
31
+ root.render(<Harness />);
32
+ });
33
+ await act(async () => {
34
+ stack.handleDrillDown({ id: "x", compositionSrc });
35
+ });
36
+ expect(stack.compositionStack[1]).toMatchObject({
37
+ id: "sections/compositions/x.html",
38
+ previewUrl: "/api/projects/p/preview/comp/sections/compositions/x.html",
39
+ });
40
+ act(() => root.unmount());
41
+ });
42
+ }
43
+
44
+ it("keeps a top-level compositions/ path and drops a leading ./", async () => {
45
+ const host = document.createElement("div");
46
+ document.body.append(host);
47
+ const root = createRoot(host);
48
+ let stack!: ReturnType<typeof useCompositionStack>;
49
+ function Harness() {
50
+ stack = useCompositionStack({ projectId: "p" });
51
+ return null;
52
+ }
53
+ await act(async () => {
54
+ root.render(<Harness />);
55
+ });
56
+ await act(async () => {
57
+ stack.handleDrillDown({ id: "x", compositionSrc: "./compositions/x.html" });
58
+ });
59
+ expect(stack.compositionStack[1]?.id).toBe("compositions/x.html");
60
+ act(() => root.unmount());
61
+ });
62
+ });
63
+
11
64
  describe("useCompositionStack — project scoping", () => {
12
65
  afterEach(() => {
13
66
  document.body.innerHTML = "";
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
4
4
  import { liveTime, usePlayerStore } from "../../player/store/playerStore";
5
5
  import type { CompositionLevel } from "./CompositionBreadcrumb";
6
6
  import { encodePreviewPath } from "../../player/components/thumbnailUtils";
7
+ import { normalizeTimelineCompositionSource } from "../editor/domEditingDom";
7
8
 
8
9
  interface UseCompositionStackOptions {
9
10
  projectId: string;
@@ -79,14 +80,9 @@ export function useCompositionStack({
79
80
  (element: { id: string; compositionSrc?: string }) => {
80
81
  if (!element.compositionSrc) return;
81
82
 
82
- const compId = element.id;
83
- let resolvedPath = compIdToSrcRef.current.get(compId);
84
-
85
- if (!resolvedPath) {
86
- const src = element.compositionSrc;
87
- const compMatch = src.match(/compositions\/.*\.html/);
88
- resolvedPath = compMatch ? compMatch[0] : src;
89
- }
83
+ const src = compIdToSrcRef.current.get(element.id) ?? element.compositionSrc;
84
+ const normalized = normalizeTimelineCompositionSource(src) ?? src;
85
+ const resolvedPath = normalized.replace(/^(\.\/|\/)+/, "");
90
86
 
91
87
  usePlayerStore.getState().setElements([]);
92
88
 
@@ -0,0 +1,129 @@
1
+ import type { PatchOperation } from "../utils/sourcePatcher";
2
+ import {
3
+ isImageBackgroundValue,
4
+ isManualGeometryStyleProperty,
5
+ normalizeDomEditStyleValue,
6
+ } from "../utils/studioHelpers";
7
+ import {
8
+ injectPreviewGoogleFont,
9
+ injectPreviewImportedFont,
10
+ ensureImportedFontFace,
11
+ } from "../utils/studioFontHelpers";
12
+ import {
13
+ buildDomEditStylePatchOperation,
14
+ findElementForSelection,
15
+ getDomEditTargetKey,
16
+ type DomEditSelection,
17
+ } from "../components/editor/domEditing";
18
+ import type { ImportedFontAsset } from "../components/editor/fontAssets";
19
+ import type { PersistDomEditOperations } from "./domEditCommitTypes";
20
+ import { reportDomEditPersistFailure } from "./domEditPersistFailure";
21
+ import {
22
+ bumpDomEditCommitMapVersion,
23
+ domEditCommitDeclined,
24
+ runReportedDomEditCommit,
25
+ type DomEditCommitOutcome,
26
+ } from "./domEditCommitRunner";
27
+
28
+ const IMAGE_BACKGROUND_FIT: Array<[string, string]> = [
29
+ ["background-position", "center"],
30
+ ["background-repeat", "no-repeat"],
31
+ ["background-size", "contain"],
32
+ ];
33
+
34
+ export interface DomStyleCommitContext {
35
+ activeCompPath: string | null;
36
+ previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
37
+ persistDomEditOperations: PersistDomEditOperations;
38
+ showToast: (message: string, tone?: "error" | "info") => void;
39
+ /** Latest-commit versions per target and property set; keep one map per caller. */
40
+ versions: Map<string, symbol>;
41
+ resolveImportedFontAsset?: (fontFamilyValue: string) => ImportedFontAsset | null;
42
+ resync?: (selection: DomEditSelection) => void;
43
+ }
44
+
45
+ /** Applies inline styles live and saves them as one source patch and one undo step. */
46
+ // fallow-ignore-next-line complexity
47
+ export async function commitDomStyles(
48
+ context: DomStyleCommitContext,
49
+ selection: DomEditSelection,
50
+ styles: Record<string, string>,
51
+ ): Promise<DomEditCommitOutcome> {
52
+ const entries = Object.entries(styles);
53
+ if (entries.length === 0) return domEditCommitDeclined("no-selection");
54
+ if (entries.some(([property]) => isManualGeometryStyleProperty(property)))
55
+ return domEditCommitDeclined("geometry-property");
56
+ if (!selection.capabilities.canEditStyles) return domEditCommitDeclined("styles-not-editable");
57
+
58
+ const { activeCompPath, previewIframeRef, persistDomEditOperations, showToast } = context;
59
+ // One version per element and property, so a failed save never reverts a later edit's property.
60
+ const targetKey = getDomEditTargetKey(selection);
61
+ const owned = entries.map(([property]) => ({
62
+ property,
63
+ isLatest: bumpDomEditCommitMapVersion(context.versions, `${targetKey}:${property}`),
64
+ }));
65
+ const ownsAny = () => owned.some(({ isLatest }) => isLatest());
66
+ const fontFamily = styles["font-family"];
67
+ const importedFont =
68
+ fontFamily !== undefined ? (context.resolveImportedFontAsset?.(fontFamily) ?? null) : null;
69
+ const doc = previewIframeRef.current?.contentDocument;
70
+ const operations: PatchOperation[] = [];
71
+ const liveStyles: Array<[string, string]> = [];
72
+ for (const [property, value] of entries) {
73
+ const normalized = normalizeDomEditStyleValue(property, value);
74
+ operations.push(buildDomEditStylePatchOperation(property, normalized));
75
+ liveStyles.push([property, normalized]);
76
+ if (property === "background-image" && isImageBackgroundValue(value)) {
77
+ for (const [fit, fitValue] of IMAGE_BACKGROUND_FIT) {
78
+ if (fit in styles) continue;
79
+ operations.push(buildDomEditStylePatchOperation(fit, fitValue));
80
+ liveStyles.push([fit, fitValue]);
81
+ }
82
+ }
83
+ }
84
+ let editedElement: HTMLElement | null = null;
85
+ const previousInline = new Map<string, string>();
86
+
87
+ return runReportedDomEditCommit({
88
+ capture: () => {
89
+ const el = doc ? findElementForSelection(doc, selection, activeCompPath) : null;
90
+ if (!el) return;
91
+ editedElement = el;
92
+ for (const [property] of entries)
93
+ previousInline.set(property, el.style.getPropertyValue(property));
94
+ },
95
+ apply: () => {
96
+ if (!editedElement) return;
97
+ for (const [property, value] of liveStyles) editedElement.style.setProperty(property, value);
98
+ if (fontFamily !== undefined && doc) {
99
+ injectPreviewGoogleFont(doc, fontFamily);
100
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
101
+ }
102
+ },
103
+ persist: () =>
104
+ persistDomEditOperations(selection, operations, {
105
+ label: "Edit layer style",
106
+ // Inline styles are already live, so a reload would only blank the preview.
107
+ skipRefresh: true,
108
+ prepareContent: importedFont
109
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
110
+ : undefined,
111
+ }),
112
+ shouldRevert: ownsAny,
113
+ revert: () => {
114
+ const el = editedElement;
115
+ if (!el) return;
116
+ // ponytail: background-image fit styles are not reverted here.
117
+ for (const { property, isLatest } of owned) {
118
+ const previous = previousInline.get(property);
119
+ if (previous === undefined || !isLatest()) continue;
120
+ if (previous === "") el.style.removeProperty(property);
121
+ else el.style.setProperty(property, previous);
122
+ }
123
+ },
124
+ onError: (error) => reportDomEditPersistFailure(selection, operations, error, showToast),
125
+ shouldResync: ownsAny,
126
+ resync: () => context.resync?.(selection),
127
+ onFinally: () => owned.forEach(({ isLatest }) => isLatest.release()),
128
+ });
129
+ }
@@ -45,7 +45,7 @@ export type GsapEditOutcome =
45
45
  }
46
46
  | { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail };
47
47
 
48
- const COPY: Record<GsapEditBlockReason, string> = {
48
+ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
49
49
  "no-selector": "This layer needs a stable selector before Studio can save the edit.",
50
50
  "unroll-required":
51
51
  "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
@@ -57,7 +57,7 @@ export class GsapEditBlockedError extends Error {
57
57
  readonly reason: GsapEditBlockReason,
58
58
  readonly detail?: GsapEditBlockDetail,
59
59
  ) {
60
- super(COPY[reason]);
60
+ super(GSAP_EDIT_BLOCK_COPY[reason]);
61
61
  this.name = "GsapEditBlockedError";
62
62
  }
63
63
  }
@@ -0,0 +1,38 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+
3
+ interface GsapElementTarget {
4
+ id?: string | null;
5
+ selector?: string | null;
6
+ }
7
+
8
+ /**
9
+ * Tweens whose target addresses the element: by id, by its selection selector, as one part of a
10
+ * group selector, or, given the live element, by any selector part the element matches.
11
+ */
12
+ export function getAnimationsForElement(
13
+ animations: GsapAnimation[],
14
+ target: GsapElementTarget,
15
+ element?: Element | null,
16
+ ): GsapAnimation[] {
17
+ const matchers = new Set<string>();
18
+ if (target.id) matchers.add(`#${target.id}`);
19
+ if (target.selector) matchers.add(target.selector);
20
+ if (matchers.size === 0 && !element) return [];
21
+ return animations.filter((a) =>
22
+ a.targetSelector.split(",").some((part) => {
23
+ const trimmed = part.trim();
24
+ if (!trimmed) return false;
25
+ if (matchers.has(trimmed)) return true;
26
+ const lastSimple = trimmed.split(/\s+/).pop();
27
+ if (lastSimple && matchers.has(lastSimple)) return true;
28
+ if (element) {
29
+ try {
30
+ if (element.matches(trimmed)) return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ return false;
36
+ }),
37
+ );
38
+ }
@@ -5,7 +5,7 @@
5
5
  * frame can't jump. Split from gsapRuntimeBridge, which owns the shared
6
6
  * group-tween resolution used by the drag/resize/rotate intercepts.
7
7
  */
8
- import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
8
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import { clearStudioBoxSize } from "../components/editor/manualEdits";
11
11
  import {
@@ -14,7 +14,6 @@ import {
14
14
  } from "../components/editor/manualEditsTypes";
15
15
  import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
16
16
  import { usePlayerStore } from "../player/store/playerStore";
17
- import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
18
17
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
19
18
  import {
20
19
  commitStaticGsapPosition,
@@ -35,11 +34,8 @@ import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsa
35
34
  import { roundTo3 } from "../utils/rounding";
36
35
  import { resolveGroupTween } from "./gsapRuntimeBridge";
37
36
  import { logResize } from "../utils/resizeDebug";
38
- import {
39
- animationWritesAnyProperty,
40
- directEditOutcomeForProperties,
41
- type GsapEditOutcome,
42
- } from "./gsapEditOutcome";
37
+ import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
38
+ import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
43
39
 
44
40
  const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
45
41
 
@@ -103,17 +99,12 @@ export async function tryGsapResizeIntercept(
103
99
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
104
100
  ): Promise<GsapEditOutcome> {
105
101
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
106
- const allKnownAnimations = [...animations, ...fetchedAnimations];
107
- // If the element already has a scale-group tween, resize should modify scale
108
- // (the user is resizing something whose visual size is driven by scale).
109
- // Otherwise, use the size group (width/height).
110
- const hasScaleGroup = allKnownAnimations.some((a) => a.propertyGroup === "scale");
111
- const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
112
- const resizeProperties =
113
- resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
114
- const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
115
- if (editability.status === "blocked") return editability;
116
- const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
102
+ const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
103
+ if (outcome.status === "blocked") return outcome;
104
+ const { resizeGroup, resizeProperties, workingAnimations } = resizeRoute(
105
+ animations,
106
+ fetchedAnimations,
107
+ );
117
108
  // The initial ownership fetch already supplied the complete parse. Only retain
118
109
  // the fetch callback when a legacy mixed tween may be split and must then be
119
110
  // re-read; otherwise resolveGroupTween would perform the same network read twice.
@@ -132,20 +123,8 @@ export async function tryGsapResizeIntercept(
132
123
  resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
133
124
  ? resolved.anim
134
125
  : null;
135
- const liveSelector = selectorFromSelection(selection);
136
- const hasLiveResizeTween = liveSelector
137
- ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
138
- : false;
139
- if (!anim && hasLiveResizeTween) {
140
- // Third twin of the position/rotation cases: a live tween with no source match.
141
- return {
142
- status: "blocked",
143
- reason: "source-uneditable",
144
- detail: "live-resize-no-source-tween",
145
- };
146
- }
147
126
  logResize("intercept-enter", {
148
- hasScaleGroup,
127
+ hasScaleGroup: resizeGroup === "scale",
149
128
  resizeGroup,
150
129
  animMethod: anim?.method ?? null,
151
130
  animId: anim?.id ?? null,