@hyperframes/studio 0.6.87 → 0.6.89
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.
- package/dist/assets/index-2SbRRd33.js +146 -0
- package/dist/assets/index-D2NkPomd.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +33 -193
- package/src/components/StudioLeftSidebar.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +54 -31
- package/src/components/editor/AnimationCard.tsx +15 -3
- package/src/components/editor/DomEditOverlay.test.ts +34 -1
- package/src/components/editor/FileTree.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +17 -3
- package/src/components/editor/LayersPanel.tsx +19 -4
- package/src/components/editor/PropertyPanel.tsx +82 -170
- package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
- package/src/components/editor/gsapAnimatesProperty.ts +52 -0
- package/src/components/editor/manualEditsDom.ts +11 -57
- package/src/components/editor/manualOffsetDrag.test.ts +18 -1
- package/src/components/editor/manualOffsetDrag.ts +16 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
- package/src/components/editor/propertyPanelHelpers.ts +76 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useLayerDrag.ts +6 -3
- package/src/components/renders/RenderQueueItem.tsx +47 -46
- package/src/components/sidebar/CompositionsTab.tsx +15 -2
- package/src/components/sidebar/LeftSidebar.tsx +11 -0
- package/src/hooks/gsapDragCommit.ts +294 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
- package/src/hooks/gsapRuntimeBridge.ts +49 -402
- package/src/hooks/gsapRuntimeReaders.ts +201 -0
- package/src/hooks/timelineEditingHelpers.ts +148 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
- package/src/hooks/useBlockHandlers.ts +150 -0
- package/src/hooks/useClipboard.ts +1 -10
- package/src/hooks/useDomEditPreviewSync.ts +126 -0
- package/src/hooks/useDomEditSession.ts +11 -79
- package/src/hooks/useGestureCommit.ts +166 -0
- package/src/hooks/useGestureRecording.ts +271 -169
- package/src/hooks/useGsapScriptCommits.ts +7 -80
- package/src/hooks/useLintModal.ts +97 -25
- package/src/hooks/useTimelineEditing.ts +10 -132
- package/src/player/components/TimelineCanvas.tsx +24 -7
- package/src/player/components/useTimelinePlayhead.ts +2 -1
- package/src/player/store/playerStore.ts +12 -0
- package/src/utils/gsapSoftReload.ts +18 -1
- package/src/utils/studioUrlState.test.ts +9 -0
- package/dist/assets/index-BA19FAPN.js +0 -143
- 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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
487
|
+
domEditSelection,
|
|
488
|
+
applyDomSelection,
|
|
489
|
+
buildDomSelectionFromTarget,
|
|
540
490
|
refreshPreviewDocumentVersion,
|
|
541
491
|
syncPreviewHistoryHotkey,
|
|
542
492
|
applyStudioManualEditsToPreviewRef,
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|