@hyperframes/studio 0.6.88 → 0.6.90

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 (49) hide show
  1. package/dist/assets/index-BKuDHMYl.js +146 -0
  2. package/dist/assets/index-D2NkPomd.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +33 -193
  6. package/src/components/StudioLeftSidebar.tsx +6 -0
  7. package/src/components/StudioRightPanel.tsx +8 -0
  8. package/src/components/TimelineToolbar.tsx +54 -31
  9. package/src/components/editor/AnimationCard.tsx +15 -3
  10. package/src/components/editor/DomEditOverlay.test.ts +34 -1
  11. package/src/components/editor/FileTree.tsx +5 -1
  12. package/src/components/editor/FileTreeNodes.tsx +17 -3
  13. package/src/components/editor/LayersPanel.tsx +19 -4
  14. package/src/components/editor/PropertyPanel.tsx +82 -170
  15. package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
  16. package/src/components/editor/gsapAnimatesProperty.ts +52 -0
  17. package/src/components/editor/manualEditsDom.ts +11 -57
  18. package/src/components/editor/manualOffsetDrag.test.ts +18 -1
  19. package/src/components/editor/manualOffsetDrag.ts +16 -10
  20. package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
  21. package/src/components/editor/propertyPanelHelpers.ts +76 -0
  22. package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
  23. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  24. package/src/components/editor/useLayerDrag.ts +6 -3
  25. package/src/components/renders/RenderQueueItem.tsx +47 -46
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -2
  27. package/src/components/sidebar/LeftSidebar.tsx +11 -0
  28. package/src/hooks/gsapDragCommit.ts +294 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +49 -402
  31. package/src/hooks/gsapRuntimeReaders.ts +201 -0
  32. package/src/hooks/timelineEditingHelpers.ts +148 -0
  33. package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
  34. package/src/hooks/useBlockHandlers.ts +150 -0
  35. package/src/hooks/useClipboard.ts +1 -10
  36. package/src/hooks/useDomEditPreviewSync.ts +126 -0
  37. package/src/hooks/useDomEditSession.ts +11 -79
  38. package/src/hooks/useGestureCommit.ts +166 -0
  39. package/src/hooks/useGestureRecording.ts +271 -169
  40. package/src/hooks/useGsapScriptCommits.ts +7 -80
  41. package/src/hooks/useLintModal.ts +97 -25
  42. package/src/hooks/useTimelineEditing.ts +10 -132
  43. package/src/player/components/TimelineCanvas.tsx +24 -7
  44. package/src/player/components/useTimelinePlayhead.ts +2 -1
  45. package/src/player/store/playerStore.ts +12 -0
  46. package/src/utils/gsapSoftReload.ts +18 -1
  47. package/src/utils/studioUrlState.test.ts +9 -0
  48. package/dist/assets/index-B9_ctmee.js +0 -143
  49. package/dist/assets/index-CGlIm_-E.css +0 -1
@@ -0,0 +1,148 @@
1
+ import type { TimelineElement } from "../player";
2
+ import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
3
+ import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
4
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
+ import type { EditHistoryKind } from "../utils/editHistory";
6
+
7
+ // ── Types ──
8
+
9
+ interface RecordEditInput {
10
+ label: string;
11
+ kind: EditHistoryKind;
12
+ coalesceKey?: string;
13
+ files: Record<string, { before: string; after: string }>;
14
+ }
15
+
16
+ export function buildPatchTarget(element: {
17
+ domId?: string;
18
+ hfId?: string;
19
+ selector?: string;
20
+ selectorIndex?: number;
21
+ }) {
22
+ if (element.domId) {
23
+ return {
24
+ id: element.domId,
25
+ hfId: element.hfId,
26
+ selector: element.selector,
27
+ selectorIndex: element.selectorIndex,
28
+ };
29
+ }
30
+ if (element.hfId) {
31
+ return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
32
+ }
33
+ if (element.selector) {
34
+ return { selector: element.selector, selectorIndex: element.selectorIndex };
35
+ }
36
+ return null;
37
+ }
38
+
39
+ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
40
+
41
+ // The runtime re-reads data-start/data-duration from the DOM on each sync tick
42
+ // (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
43
+ // picked up automatically on the next frame without a rebind call.
44
+ export function patchIframeDomTiming(
45
+ iframe: HTMLIFrameElement | null,
46
+ element: TimelineElement,
47
+ attrs: Array<[string, string]>,
48
+ ): void {
49
+ try {
50
+ const doc = iframe?.contentDocument;
51
+ if (!doc) return;
52
+ const el = element.domId
53
+ ? doc.getElementById(element.domId)
54
+ : element.selector
55
+ ? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
56
+ : null;
57
+ if (!el) return;
58
+ for (const [name, value] of attrs) el.setAttribute(name, value);
59
+ } catch {
60
+ // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
61
+ }
62
+ }
63
+
64
+ export function resolveResizePlaybackStart(
65
+ original: string,
66
+ target: PatchTarget,
67
+ element: TimelineElement,
68
+ updates: Pick<TimelineElement, "start" | "playbackStart">,
69
+ ): { attrName: string; value: number } | null {
70
+ if (updates.playbackStart != null) {
71
+ const attrName =
72
+ element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
73
+ return { attrName, value: updates.playbackStart };
74
+ }
75
+ const trimDelta = updates.start - element.start;
76
+ if (trimDelta === 0) return null;
77
+ const raw =
78
+ readAttributeByTarget(original, target, "playback-start") ??
79
+ readAttributeByTarget(original, target, "media-start");
80
+ const current = raw != null ? parseFloat(raw) : undefined;
81
+ if (current == null || !Number.isFinite(current)) return null;
82
+ const attrName =
83
+ element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
84
+ return {
85
+ attrName,
86
+ value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
87
+ };
88
+ }
89
+
90
+ export interface PersistTimelineEditInput {
91
+ projectId: string;
92
+ element: TimelineElement;
93
+ activeCompPath: string | null;
94
+ label: string;
95
+ buildPatches: (original: string, target: PatchTarget) => string;
96
+ writeProjectFile: (path: string, content: string) => Promise<void>;
97
+ recordEdit: (input: RecordEditInput) => Promise<void>;
98
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
99
+ pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
100
+ }
101
+
102
+ export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
103
+ const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
104
+ const originalContent = await readFileContent(input.projectId, targetPath);
105
+
106
+ const patchTarget = buildPatchTarget(input.element);
107
+ if (!patchTarget) {
108
+ throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
109
+ }
110
+
111
+ const patchedContent = input.buildPatches(originalContent, patchTarget);
112
+ if (patchedContent === originalContent) {
113
+ throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
114
+ }
115
+
116
+ input.pendingTimelineEditPathRef.current.add(targetPath);
117
+ input.domEditSaveTimestampRef.current = Date.now();
118
+ await saveProjectFilesWithHistory({
119
+ projectId: input.projectId,
120
+ label: input.label,
121
+ kind: "timeline",
122
+ files: { [targetPath]: patchedContent },
123
+ readFile: async () => originalContent,
124
+ writeFile: input.writeProjectFile,
125
+ recordEdit: input.recordEdit,
126
+ });
127
+ input.domEditSaveTimestampRef.current = Date.now();
128
+ }
129
+
130
+ export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
131
+ if (targetPath.includes("\0") || targetPath.includes("..")) {
132
+ throw new Error(`Unsafe path: ${targetPath}`);
133
+ }
134
+ const response = await fetch(
135
+ `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
136
+ );
137
+ if (!response.ok) {
138
+ throw new Error(`Failed to read ${targetPath}`);
139
+ }
140
+ const data = (await response.json()) as { content?: string };
141
+ if (typeof data.content !== "string") {
142
+ throw new Error(`Missing file contents for ${targetPath}`);
143
+ }
144
+ return data.content;
145
+ }
146
+
147
+ // Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
148
+ export { applyPatchByTarget, formatTimelineAttributeNumber };
@@ -36,15 +36,46 @@ interface CommitAnimatedPropertyDeps {
36
36
  bumpGsapCache: () => void;
37
37
  }
38
38
 
39
- function computePercentage(selection: DomEditSelection): number {
39
+ function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number {
40
+ const currentTime = usePlayerStore.getState().currentTime;
41
+ const tweenPos = typeof anim?.position === "number" ? anim.position : 0;
42
+ const tweenDur = anim?.duration ?? 0;
43
+ if (tweenDur > 0) {
44
+ return Math.max(
45
+ 0,
46
+ Math.min(100, Math.round(((currentTime - tweenPos) / tweenDur) * 1000) / 10),
47
+ );
48
+ }
40
49
  const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
41
50
  const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
42
- const currentTime = usePlayerStore.getState().currentTime;
43
51
  return elDuration > 0
44
52
  ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
45
53
  : 0;
46
54
  }
47
55
 
56
+ function pickBestAnimation(
57
+ animations: GsapAnimation[],
58
+ selector: string | null,
59
+ ): GsapAnimation | undefined {
60
+ if (animations.length <= 1) return animations[0];
61
+ const currentTime = usePlayerStore.getState().currentTime;
62
+
63
+ const scored = animations.map((a) => {
64
+ let score = 0;
65
+ if (a.keyframes) score += 10;
66
+ // Prefer single-element selectors over comma-separated groups
67
+ if (selector && a.targetSelector === selector) score += 5;
68
+ else if (a.targetSelector.includes(",")) score -= 3;
69
+ // Prefer tweens active at the current time
70
+ const pos = typeof a.position === "number" ? a.position : 0;
71
+ const dur = a.duration ?? 0;
72
+ if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 8;
73
+ return { anim: a, score };
74
+ });
75
+ scored.sort((a, b) => b.score - a.score);
76
+ return scored[0]?.anim;
77
+ }
78
+
48
79
  function selectorFor(selection: DomEditSelection): string | null {
49
80
  if (selection.id) return `#${selection.id}`;
50
81
  if (selection.selector) return selection.selector;
@@ -70,10 +101,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
70
101
 
71
102
  const iframe = previewIframeRef.current;
72
103
  const selector = selectorFor(selection);
73
- const pct = computePercentage(selection);
74
104
 
75
- let anim: GsapAnimation | undefined =
76
- selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
105
+ let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector);
77
106
 
78
107
  // Case 3: No animation — create one first
79
108
  if (!anim) {
@@ -97,6 +126,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
97
126
  );
98
127
  }
99
128
 
129
+ const pct = computePercentage(selection, anim);
130
+
100
131
  // Read all currently animated properties from runtime for backfill
101
132
  const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
102
133
 
@@ -112,15 +143,26 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
112
143
  }
113
144
  backfillDefaults[property] = typeof value === "number" ? value : value;
114
145
 
146
+ const existingKf = anim.keyframes?.keyframes.some(
147
+ (kf) => Math.abs(kf.percentage - pct) < 0.05,
148
+ );
149
+
115
150
  await gsapCommitMutation(
116
151
  selection,
117
- {
118
- type: "add-keyframe",
119
- animationId: anim.id,
120
- percentage: pct,
121
- properties,
122
- backfillDefaults,
123
- },
152
+ existingKf
153
+ ? {
154
+ type: "update-keyframe",
155
+ animationId: anim.id,
156
+ percentage: pct,
157
+ properties,
158
+ }
159
+ : {
160
+ type: "add-keyframe",
161
+ animationId: anim.id,
162
+ percentage: pct,
163
+ properties,
164
+ backfillDefaults,
165
+ },
124
166
  { label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
125
167
  );
126
168
  },
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Block drop/add handlers for the Studio.
3
+ * Extracted from App.tsx to keep file sizes under the 600-line limit.
4
+ */
5
+ import { useCallback, useMemo, useState } from "react";
6
+ import type { TimelineElement } from "../player";
7
+ import { usePlayerStore } from "../player";
8
+ import { addBlockToProject } from "../utils/blockInstaller";
9
+ import type { BlockParam } from "@hyperframes/core/registry";
10
+ import type { EditHistoryKind } from "../utils/editHistory";
11
+ import type { RightPanelTab } from "../utils/studioHelpers";
12
+
13
+ interface BlockCtxDeps {
14
+ activeCompPath: string | null;
15
+ timelineElements: TimelineElement[];
16
+ readProjectFile: (path: string) => Promise<string>;
17
+ writeProjectFile: (path: string, content: string) => Promise<void>;
18
+ recordEdit: (entry: {
19
+ label: string;
20
+ kind: EditHistoryKind;
21
+ coalesceKey?: string;
22
+ files: Record<string, { before: string; after: string }>;
23
+ }) => Promise<void>;
24
+ refreshFileTree: () => Promise<void>;
25
+ reloadPreview: () => void;
26
+ showToast: (message: string, tone?: "error" | "info") => void;
27
+ }
28
+
29
+ interface UseBlockHandlersParams {
30
+ projectId: string | null;
31
+ blockCtxDeps: BlockCtxDeps;
32
+ previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
33
+ setRightCollapsed: (collapsed: boolean) => void;
34
+ setRightPanelTab: (tab: RightPanelTab) => void;
35
+ }
36
+
37
+ export interface UseBlockHandlersResult {
38
+ activeBlockParams: {
39
+ blockName: string;
40
+ blockTitle: string;
41
+ params: BlockParam[];
42
+ compositionPath: string;
43
+ } | null;
44
+ setActiveBlockParams: React.Dispatch<
45
+ React.SetStateAction<UseBlockHandlersResult["activeBlockParams"]>
46
+ >;
47
+ handleAddBlock: (blockName: string) => void;
48
+ handleTimelineBlockDrop: (blockName: string, placement: { start: number; track: number }) => void;
49
+ handlePreviewBlockDrop: (blockName: string, position: { left: number; top: number }) => void;
50
+ }
51
+
52
+ export function useBlockHandlers({
53
+ projectId,
54
+ blockCtxDeps,
55
+ previewIframeRef,
56
+ setRightCollapsed,
57
+ setRightPanelTab,
58
+ }: UseBlockHandlersParams): UseBlockHandlersResult {
59
+ const [activeBlockParams, setActiveBlockParams] =
60
+ useState<UseBlockHandlersResult["activeBlockParams"]>(null);
61
+
62
+ const blockCtx = useMemo(
63
+ () => ({
64
+ activeCompPath: blockCtxDeps.activeCompPath,
65
+ timelineElements: blockCtxDeps.timelineElements,
66
+ readProjectFile: blockCtxDeps.readProjectFile,
67
+ writeProjectFile: blockCtxDeps.writeProjectFile,
68
+ recordEdit: blockCtxDeps.recordEdit,
69
+ refreshFileTree: blockCtxDeps.refreshFileTree,
70
+ reloadPreview: blockCtxDeps.reloadPreview,
71
+ showToast: blockCtxDeps.showToast,
72
+ }),
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ [
75
+ blockCtxDeps.activeCompPath,
76
+ blockCtxDeps.timelineElements,
77
+ blockCtxDeps.readProjectFile,
78
+ blockCtxDeps.writeProjectFile,
79
+ blockCtxDeps.recordEdit,
80
+ blockCtxDeps.refreshFileTree,
81
+ blockCtxDeps.reloadPreview,
82
+ blockCtxDeps.showToast,
83
+ ],
84
+ );
85
+
86
+ const handleAddBlock = useCallback(
87
+ (blockName: string) => {
88
+ if (!projectId) return;
89
+ void (async () => {
90
+ const result = await addBlockToProject({
91
+ projectId,
92
+ blockName,
93
+ ...blockCtx,
94
+ previewIframe: previewIframeRef.current,
95
+ currentTime: usePlayerStore.getState().currentTime,
96
+ });
97
+ const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
98
+ if (params?.length) {
99
+ setActiveBlockParams({
100
+ blockName: result!.block.name,
101
+ blockTitle: result!.block.title,
102
+ params,
103
+ compositionPath: result!.compositionPath,
104
+ });
105
+ setRightCollapsed(false);
106
+ setRightPanelTab("block-params");
107
+ }
108
+ })();
109
+ },
110
+ [projectId, blockCtx, previewIframeRef, setRightCollapsed, setRightPanelTab],
111
+ );
112
+
113
+ const handleTimelineBlockDrop = useCallback(
114
+ (blockName: string, placement: { start: number; track: number }) => {
115
+ if (!projectId) return;
116
+ void addBlockToProject({
117
+ projectId,
118
+ blockName,
119
+ placement,
120
+ ...blockCtx,
121
+ previewIframe: previewIframeRef.current,
122
+ currentTime: usePlayerStore.getState().currentTime,
123
+ });
124
+ },
125
+ [projectId, blockCtx, previewIframeRef],
126
+ );
127
+
128
+ const handlePreviewBlockDrop = useCallback(
129
+ (blockName: string, position: { left: number; top: number }) => {
130
+ if (!projectId) return;
131
+ void addBlockToProject({
132
+ projectId,
133
+ blockName,
134
+ visualPosition: position,
135
+ ...blockCtx,
136
+ previewIframe: previewIframeRef.current,
137
+ currentTime: usePlayerStore.getState().currentTime,
138
+ });
139
+ },
140
+ [projectId, blockCtx, previewIframeRef],
141
+ );
142
+
143
+ return {
144
+ activeBlockParams,
145
+ setActiveBlockParams,
146
+ handleAddBlock,
147
+ handleTimelineBlockDrop,
148
+ handlePreviewBlockDrop,
149
+ };
150
+ }
@@ -8,6 +8,7 @@ import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
8
8
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
9
9
  import type { EditHistoryKind } from "../utils/editHistory";
10
10
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
11
+ import { readFileContent } from "./timelineEditingHelpers";
11
12
 
12
13
  interface RecordEditInput {
13
14
  label: string;
@@ -30,16 +31,6 @@ interface UseClipboardOptions {
30
31
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
31
32
  }
32
33
 
33
- async function readFileContent(projectId: string, targetPath: string): Promise<string> {
34
- const response = await fetch(
35
- `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
36
- );
37
- if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
38
- const data = (await response.json()) as { content?: string };
39
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`);
40
- return data.content;
41
- }
42
-
43
34
  function getElementOuterHtml(
44
35
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
45
36
  selection: DomEditSelection,
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Side effects for syncing the DOM edit selection with the preview iframe on
3
+ * load/refresh, and for auto-revealing source in the Code tab.
4
+ * Extracted from useDomEditSession to keep file sizes under the 600-line limit.
5
+ */
6
+ import { useEffect, useRef } from "react";
7
+ import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
8
+ import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
9
+ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
10
+ import type { SidebarTab } from "../components/sidebar/LeftSidebar";
11
+ import type { PatchTarget } from "../utils/sourcePatcher";
12
+
13
+ interface UseDomEditPreviewSyncParams {
14
+ previewIframe: HTMLIFrameElement | null;
15
+ activeCompPath: string | null;
16
+ captionEditMode: boolean;
17
+ domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
18
+ domEditSelection: DomEditSelection | null;
19
+ applyDomSelection: (
20
+ selection: DomEditSelection | null,
21
+ options?: { revealPanel?: boolean; preserveGroup?: boolean },
22
+ ) => void;
23
+ buildDomSelectionFromTarget: (element: HTMLElement) => Promise<DomEditSelection | null>;
24
+ refreshPreviewDocumentVersion: () => void;
25
+ syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
26
+ applyStudioManualEditsToPreviewRef: React.MutableRefObject<
27
+ (iframe: HTMLIFrameElement) => Promise<void>
28
+ >;
29
+ openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
30
+ getSidebarTab?: () => SidebarTab;
31
+ }
32
+
33
+ export function useDomEditPreviewSync({
34
+ previewIframe,
35
+ activeCompPath,
36
+ captionEditMode,
37
+ domEditSelectionRef,
38
+ domEditSelection,
39
+ applyDomSelection,
40
+ buildDomSelectionFromTarget,
41
+ refreshPreviewDocumentVersion,
42
+ syncPreviewHistoryHotkey,
43
+ applyStudioManualEditsToPreviewRef,
44
+ openSourceForSelection,
45
+ getSidebarTab,
46
+ }: UseDomEditPreviewSyncParams): void {
47
+ // Sync selection from preview document on load / refresh
48
+ // eslint-disable-next-line no-restricted-syntax
49
+ useEffect(() => {
50
+ if (!previewIframe) return;
51
+
52
+ // fallow-ignore-next-line complexity
53
+ const syncSelectionFromDocument = async () => {
54
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
55
+ const currentSelection = domEditSelectionRef.current;
56
+ if (!currentSelection) return;
57
+ let doc: Document | null = null;
58
+ try {
59
+ doc = previewIframe.contentDocument;
60
+ } catch {
61
+ return;
62
+ }
63
+ if (!doc) return;
64
+
65
+ reapplyPositionEditsAfterSeek(doc);
66
+
67
+ const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
68
+ if (!nextElement) {
69
+ applyDomSelection(null, { revealPanel: false });
70
+ return;
71
+ }
72
+
73
+ const nextSelection = await buildDomSelectionFromTarget(nextElement);
74
+ if (nextSelection) {
75
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
76
+ }
77
+ };
78
+
79
+ syncPreviewHistoryHotkey(previewIframe);
80
+ void applyStudioManualEditsToPreviewRef.current(previewIframe);
81
+ void syncSelectionFromDocument();
82
+ refreshPreviewDocumentVersion();
83
+
84
+ const handleLoad = () => {
85
+ syncPreviewHistoryHotkey(previewIframe);
86
+ void applyStudioManualEditsToPreviewRef.current(previewIframe);
87
+ void syncSelectionFromDocument();
88
+ refreshPreviewDocumentVersion();
89
+ };
90
+
91
+ previewIframe.addEventListener("load", handleLoad);
92
+ return () => {
93
+ previewIframe.removeEventListener("load", handleLoad);
94
+ };
95
+ }, [
96
+ activeCompPath,
97
+ applyDomSelection,
98
+ buildDomSelectionFromTarget,
99
+ captionEditMode,
100
+ domEditSelectionRef,
101
+ previewIframe,
102
+ refreshPreviewDocumentVersion,
103
+ syncPreviewHistoryHotkey,
104
+ applyStudioManualEditsToPreviewRef,
105
+ ]);
106
+
107
+ // Auto-reveal source when an element is selected while the Code tab is active.
108
+ // Use a ref for the callback so the effect only fires on selection changes,
109
+ // not when openSourceForSelection is recreated due to editingFile content updates.
110
+ const openSourceRef = useRef(openSourceForSelection);
111
+ openSourceRef.current = openSourceForSelection;
112
+ useEffect(
113
+ // fallow-ignore-next-line complexity
114
+ () => {
115
+ if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
116
+ if (!domEditSelection.sourceFile) return;
117
+ if (getSidebarTab() !== "code") return;
118
+ openSourceRef.current(domEditSelection.sourceFile, {
119
+ id: domEditSelection.id,
120
+ selector: domEditSelection.selector,
121
+ selectorIndex: domEditSelection.selectorIndex,
122
+ });
123
+ },
124
+ [domEditSelection, getSidebarTab],
125
+ );
126
+ }
@@ -1,12 +1,9 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { usePlayerStore } from "../player";
4
- import {
5
- STUDIO_INSPECTOR_PANELS_ENABLED,
6
- STUDIO_GSAP_PANEL_ENABLED,
7
- } from "../components/editor/manualEditingAvailability";
8
- import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
9
- import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
4
+ import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
5
+ import { type DomEditSelection } from "../components/editor/domEditing";
6
+ import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
10
7
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
11
8
  import type { EditHistoryKind } from "../utils/editHistory";
12
9
  import type { RightPanelTab } from "../utils/studioHelpers";
@@ -482,85 +479,20 @@ export function useDomEditSession({
482
479
  [domEditSelection, updateArcSegment],
483
480
  );
484
481
 
485
- // Sync selection from preview document on load / refresh
486
- // eslint-disable-next-line no-restricted-syntax
487
- useEffect(() => {
488
- if (!previewIframe) return;
489
-
490
- // fallow-ignore-next-line complexity
491
- const syncSelectionFromDocument = async () => {
492
- if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
493
- const currentSelection = domEditSelectionRef.current;
494
- if (!currentSelection) return;
495
- let doc: Document | null = null;
496
- try {
497
- doc = previewIframe.contentDocument;
498
- } catch {
499
- return;
500
- }
501
- if (!doc) return;
502
-
503
- reapplyPositionEditsAfterSeek(doc);
504
-
505
- const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
506
- if (!nextElement) {
507
- applyDomSelection(null, { revealPanel: false });
508
- return;
509
- }
510
-
511
- const nextSelection = await buildDomSelectionFromTarget(nextElement);
512
- if (nextSelection) {
513
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
514
- }
515
- };
516
-
517
- syncPreviewHistoryHotkey(previewIframe);
518
- void applyStudioManualEditsToPreviewRef.current(previewIframe);
519
- void syncSelectionFromDocument();
520
- refreshPreviewDocumentVersion();
521
-
522
- const handleLoad = () => {
523
- syncPreviewHistoryHotkey(previewIframe);
524
- void applyStudioManualEditsToPreviewRef.current(previewIframe);
525
- void syncSelectionFromDocument();
526
- refreshPreviewDocumentVersion();
527
- };
528
-
529
- previewIframe.addEventListener("load", handleLoad);
530
- return () => {
531
- previewIframe.removeEventListener("load", handleLoad);
532
- };
533
- }, [
482
+ useDomEditPreviewSync({
483
+ previewIframe,
534
484
  activeCompPath,
535
- applyDomSelection,
536
- buildDomSelectionFromTarget,
537
485
  captionEditMode,
538
486
  domEditSelectionRef,
539
- previewIframe,
487
+ domEditSelection,
488
+ applyDomSelection,
489
+ buildDomSelectionFromTarget,
540
490
  refreshPreviewDocumentVersion,
541
491
  syncPreviewHistoryHotkey,
542
492
  applyStudioManualEditsToPreviewRef,
543
- ]);
544
-
545
- // Auto-reveal source when an element is selected while the Code tab is active.
546
- // Use a ref for the callback so the effect only fires on selection changes,
547
- // not when openSourceForSelection is recreated due to editingFile content updates.
548
- const openSourceRef = useRef(openSourceForSelection);
549
- openSourceRef.current = openSourceForSelection;
550
- useEffect(
551
- // fallow-ignore-next-line complexity
552
- () => {
553
- if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
554
- if (!domEditSelection.sourceFile) return;
555
- if (getSidebarTab() !== "code") return;
556
- openSourceRef.current(domEditSelection.sourceFile, {
557
- id: domEditSelection.id,
558
- selector: domEditSelection.selector,
559
- selectorIndex: domEditSelection.selectorIndex,
560
- });
561
- },
562
- [domEditSelection, getSidebarTab],
563
- );
493
+ openSourceForSelection,
494
+ getSidebarTab,
495
+ });
564
496
 
565
497
  return {
566
498
  // State