@hyperframes/studio 0.6.97 → 0.6.98
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/hyperframes-player-DgsMQSvV.js +418 -0
- package/dist/assets/index-B62bDCQv.css +1 -0
- package/dist/assets/index-Ce3pBm_I.js +252 -0
- package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
- package/dist/assets/index-D-bS9Dxx.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +7 -5
- package/src/App.tsx +182 -177
- package/src/captions/store.ts +11 -11
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/StudioPreviewArea.tsx +225 -183
- package/src/components/StudioRightPanel.tsx +3 -3
- package/src/components/TimelineToolbar.tsx +25 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -5
- package/src/components/editor/EaseCurveSection.tsx +2 -3
- package/src/components/editor/GestureTrailOverlay.tsx +4 -3
- package/src/components/editor/LayersPanel.tsx +3 -9
- package/src/components/editor/PropertyPanel.tsx +20 -61
- package/src/components/editor/colorValue.ts +3 -1
- package/src/components/editor/domEditOverlayGestures.ts +54 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/gradientValue.ts +3 -3
- package/src/components/editor/keyframeMove.test.ts +101 -0
- package/src/components/editor/keyframeMove.ts +151 -0
- package/src/components/editor/manualEditsDom.ts +0 -12
- package/src/components/editor/propertyPanelHelpers.ts +10 -38
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
- package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
- package/src/components/editor/studioMotionOps.test.ts +1 -1
- package/src/components/editor/studioMotionOps.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
- package/src/components/nle/NLELayout.tsx +1 -24
- package/src/components/sidebar/BlocksTab.tsx +2 -2
- package/src/contexts/DomEditContext.tsx +134 -31
- package/src/contexts/StudioContext.tsx +90 -40
- package/src/contexts/TimelineEditContext.tsx +47 -0
- package/src/hooks/domEditCommitTypes.ts +14 -0
- package/src/hooks/gsapDragCommit.ts +9 -24
- package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
- package/src/hooks/gsapKeyframeCommit.ts +5 -15
- package/src/hooks/gsapRuntimeBridge.ts +18 -52
- package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
- package/src/hooks/gsapRuntimeReaders.ts +19 -26
- package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
- package/src/hooks/gsapScriptCommitTypes.ts +58 -0
- package/src/hooks/gsapShared.ts +157 -0
- package/src/hooks/timelineEditingHelpers.ts +63 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
- package/src/hooks/useAppHotkeys.ts +299 -377
- package/src/hooks/useConsoleErrorCapture.ts +33 -5
- package/src/hooks/useDomEditCommits.ts +35 -293
- package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
- package/src/hooks/useDomEditSession.ts +78 -249
- package/src/hooks/useDomEditTextCommits.ts +1 -1
- package/src/hooks/useDomEditWiring.ts +255 -0
- package/src/hooks/useDomGeometryCommits.ts +181 -0
- package/src/hooks/useDomSelection.ts +10 -27
- package/src/hooks/useEditorSave.ts +82 -0
- package/src/hooks/useElementLifecycleOps.ts +177 -0
- package/src/hooks/useEnableKeyframes.ts +10 -15
- package/src/hooks/useFileManager.ts +32 -114
- package/src/hooks/useFileTree.ts +80 -0
- package/src/hooks/useGestureCommit.ts +7 -5
- package/src/hooks/useGestureRecording.ts +1 -1
- package/src/hooks/useGsapAnimationOps.ts +122 -0
- package/src/hooks/useGsapArcPathOps.ts +61 -0
- package/src/hooks/useGsapAwareEditing.ts +242 -0
- package/src/hooks/useGsapKeyframeOps.ts +167 -0
- package/src/hooks/useGsapPropertyDebounce.ts +135 -0
- package/src/hooks/useGsapScriptCommits.ts +58 -570
- package/src/hooks/useGsapSelectionHandlers.ts +22 -9
- package/src/hooks/useGsapTweenCache.ts +35 -29
- package/src/hooks/useLintModal.ts +7 -0
- package/src/hooks/useMusicBeatAnalysis.ts +152 -0
- package/src/hooks/useRazorSplit.ts +1 -1
- package/src/hooks/useRenderClipContent.ts +46 -21
- package/src/hooks/useTimelineEditing.ts +48 -4
- package/src/player/components/AudioWaveform.tsx +29 -4
- package/src/player/components/BeatStrip.tsx +166 -0
- package/src/player/components/Timeline.tsx +39 -18
- package/src/player/components/TimelineCanvas.tsx +52 -12
- package/src/player/components/TimelineClipDiamonds.tsx +130 -20
- package/src/player/components/TimelinePropertyRows.tsx +8 -2
- package/src/player/components/TimelineRuler.tsx +36 -2
- package/src/player/components/timelineEditing.ts +30 -5
- package/src/player/components/useTimelineClipDrag.ts +155 -4
- package/src/player/components/useTimelinePlayhead.ts +30 -1
- package/src/player/hooks/useTimelinePlayer.ts +47 -45
- package/src/player/lib/mediaProbe.ts +46 -3
- package/src/player/lib/playbackScrub.ts +16 -0
- package/src/player/lib/timelineDOM.ts +10 -2
- package/src/player/lib/timelineIframeHelpers.ts +89 -0
- package/src/player/store/playerStore.ts +92 -33
- package/src/utils/beatEditActions.ts +109 -0
- package/src/utils/beatEditing.ts +136 -0
- package/src/utils/clipboardPayload.ts +3 -2
- package/src/utils/compositionPatterns.ts +2 -0
- package/src/utils/keyframeSelection.test.ts +45 -0
- package/src/utils/keyframeSelection.ts +29 -0
- package/src/utils/rounding.ts +9 -0
- package/src/utils/studioHelpers.ts +5 -2
- package/src/utils/studioUrlState.ts +2 -1
- package/src/utils/timelineAssetDrop.ts +6 -5
- package/src/utils/timelineInspector.ts +15 -100
- package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
- package/dist/assets/index-B0twsRu0.css +0 -1
- package/dist/assets/index-Cfye9xzo.js +0 -251
- package/src/components/editor/DopesheetStrip.tsx +0 -141
- package/src/components/editor/StaggerControls.tsx +0 -61
- package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
- package/src/components/editor/TimelineLayerPanel.tsx +0 -15
- package/src/components/nle/TimelineEditorNotice.tsx +0 -133
- package/src/hooks/gsapRuntimePreview.ts +0 -19
- package/src/player/components/timelineUtils.ts +0 -211
- package/src/utils/audioBeatDetection.ts +0 -58
- package/src/utils/keyframeSnapping.test.ts +0 -74
- package/src/utils/keyframeSnapping.ts +0 -63
- package/src/utils/timelineInspector.test.ts +0 -79
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
3
|
+
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
import {
|
|
5
|
+
applyStudioPathOffset,
|
|
6
|
+
applyStudioBoxSize,
|
|
7
|
+
applyStudioRotation,
|
|
8
|
+
clearStudioPathOffset,
|
|
9
|
+
clearStudioBoxSize,
|
|
10
|
+
clearStudioRotation,
|
|
11
|
+
} from "../components/editor/manualEdits";
|
|
12
|
+
import {
|
|
13
|
+
buildPathOffsetPatches,
|
|
14
|
+
buildBoxSizePatches,
|
|
15
|
+
buildRotationPatches,
|
|
16
|
+
buildClearPathOffsetPatches,
|
|
17
|
+
buildClearBoxSizePatches,
|
|
18
|
+
buildClearRotationPatches,
|
|
19
|
+
} from "../components/editor/manualEditsDomPatches";
|
|
20
|
+
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
21
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
22
|
+
|
|
23
|
+
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
|
24
|
+
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
|
|
25
|
+
|
|
26
|
+
// ── Helpers ──
|
|
27
|
+
|
|
28
|
+
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
29
|
+
|
|
30
|
+
// fallow-ignore-next-line complexity
|
|
31
|
+
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
|
|
32
|
+
// When the GSAP drag intercept is disabled for debugging, treat every
|
|
33
|
+
// element as un-targeted so commits take the plain CSS persist path.
|
|
34
|
+
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
|
|
35
|
+
if (!iframe?.contentWindow) return false;
|
|
36
|
+
let timelines: Record<string, TimelineLike> | undefined;
|
|
37
|
+
try {
|
|
38
|
+
timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
|
|
39
|
+
.__timelines;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (!timelines) return false;
|
|
44
|
+
const id = element.id;
|
|
45
|
+
for (const tl of Object.values(timelines)) {
|
|
46
|
+
if (!tl?.getChildren) continue;
|
|
47
|
+
try {
|
|
48
|
+
for (const child of tl.getChildren(true)) {
|
|
49
|
+
if (!child.targets) continue;
|
|
50
|
+
for (const t of child.targets()) {
|
|
51
|
+
if (t === element || (id && t.id === id)) return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Hook ──
|
|
62
|
+
|
|
63
|
+
interface UseDomGeometryCommitsParams {
|
|
64
|
+
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
65
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
66
|
+
commitPositionPatchToHtml: (
|
|
67
|
+
selection: DomEditSelection,
|
|
68
|
+
patches: PatchOperation[],
|
|
69
|
+
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
|
70
|
+
) => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useDomGeometryCommits({
|
|
74
|
+
previewIframeRef,
|
|
75
|
+
showToast,
|
|
76
|
+
commitPositionPatchToHtml,
|
|
77
|
+
}: UseDomGeometryCommitsParams) {
|
|
78
|
+
const handleDomPathOffsetCommit = useCallback(
|
|
79
|
+
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
80
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
81
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
82
|
+
showToast(error.message, "error");
|
|
83
|
+
return Promise.reject(error);
|
|
84
|
+
}
|
|
85
|
+
applyStudioPathOffset(selection.element, next);
|
|
86
|
+
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
87
|
+
label: "Move layer",
|
|
88
|
+
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const handleDomGroupPathOffsetCommit = useCallback(
|
|
95
|
+
(updates: DomEditGroupPathOffsetCommit[]) => {
|
|
96
|
+
if (updates.length === 0) return Promise.resolve();
|
|
97
|
+
const blockedUpdate = updates.find(({ selection }) =>
|
|
98
|
+
isElementGsapTargeted(previewIframeRef.current, selection.element),
|
|
99
|
+
);
|
|
100
|
+
if (blockedUpdate) {
|
|
101
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
102
|
+
showToast(error.message, "error");
|
|
103
|
+
return Promise.reject(error);
|
|
104
|
+
}
|
|
105
|
+
const coalesceKey = updates
|
|
106
|
+
.map((u) => getDomEditTargetKey(u.selection))
|
|
107
|
+
.sort()
|
|
108
|
+
.join(":");
|
|
109
|
+
const saves = updates.map(({ selection, next }) => {
|
|
110
|
+
applyStudioPathOffset(selection.element, next);
|
|
111
|
+
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
112
|
+
label: `Move ${updates.length} layers`,
|
|
113
|
+
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
return Promise.all(saves).then(() => undefined);
|
|
117
|
+
},
|
|
118
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const handleDomBoxSizeCommit = useCallback(
|
|
122
|
+
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
123
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
124
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
125
|
+
showToast(error.message, "error");
|
|
126
|
+
return Promise.reject(error);
|
|
127
|
+
}
|
|
128
|
+
applyStudioBoxSize(selection.element, next);
|
|
129
|
+
return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
|
|
130
|
+
label: "Resize layer box",
|
|
131
|
+
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const handleDomRotationCommit = useCallback(
|
|
138
|
+
(selection: DomEditSelection, next: { angle: number }) => {
|
|
139
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
140
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
141
|
+
showToast(error.message, "error");
|
|
142
|
+
return Promise.reject(error);
|
|
143
|
+
}
|
|
144
|
+
applyStudioRotation(selection.element, next);
|
|
145
|
+
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
|
146
|
+
label: "Rotate layer",
|
|
147
|
+
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const handleDomManualEditsReset = useCallback(
|
|
154
|
+
(selection: DomEditSelection) => {
|
|
155
|
+
const element = selection.element;
|
|
156
|
+
const clearPatches = [
|
|
157
|
+
...buildClearPathOffsetPatches(element),
|
|
158
|
+
...buildClearBoxSizePatches(element),
|
|
159
|
+
...buildClearRotationPatches(element),
|
|
160
|
+
];
|
|
161
|
+
clearStudioPathOffset(element);
|
|
162
|
+
clearStudioBoxSize(element);
|
|
163
|
+
clearStudioRotation(element);
|
|
164
|
+
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
|
|
165
|
+
void commitPositionPatchToHtml(selection, clearPatches, {
|
|
166
|
+
label: "Reset layer edits",
|
|
167
|
+
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
|
168
|
+
skipRefresh: false,
|
|
169
|
+
}).catch(() => undefined);
|
|
170
|
+
},
|
|
171
|
+
[commitPositionPatchToHtml],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
handleDomPathOffsetCommit,
|
|
176
|
+
handleDomGroupPathOffsetCommit,
|
|
177
|
+
handleDomBoxSizeCommit,
|
|
178
|
+
handleDomRotationCommit,
|
|
179
|
+
handleDomManualEditsReset,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -389,47 +389,30 @@ export function useDomSelection({
|
|
|
389
389
|
|
|
390
390
|
// ── Effects ──
|
|
391
391
|
|
|
392
|
-
// Clear hover on
|
|
393
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
394
|
-
useEffect(() => {
|
|
395
|
-
if (captionEditMode) updateDomEditHoverSelection(null);
|
|
396
|
-
}, [captionEditMode, updateDomEditHoverSelection]);
|
|
397
|
-
|
|
398
|
-
// Clear hover on composition/project/preview change
|
|
392
|
+
// Clear hover unconditionally on composition/project/preview change
|
|
399
393
|
// eslint-disable-next-line no-restricted-syntax
|
|
400
394
|
useEffect(() => {
|
|
401
395
|
updateDomEditHoverSelection(null);
|
|
402
396
|
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
|
|
403
397
|
|
|
404
|
-
// Clear hover
|
|
398
|
+
// Clear hover conditionally (caption mode, matches selection, disconnected element)
|
|
405
399
|
// eslint-disable-next-line no-restricted-syntax
|
|
406
400
|
useEffect(() => {
|
|
407
401
|
if (!domEditHoverSelection) return;
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
domEditSelection
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
domEditHoverSelection,
|
|
415
|
-
);
|
|
416
|
-
if (!hoverMatchesSelection && !hoverMatchesGroup) return;
|
|
417
|
-
updateDomEditHoverSelection(null);
|
|
402
|
+
const shouldClear =
|
|
403
|
+
captionEditMode ||
|
|
404
|
+
domEditSelectionsTargetSame(domEditHoverSelection, domEditSelection) ||
|
|
405
|
+
domEditSelectionInGroup(domEditGroupSelections, domEditHoverSelection) ||
|
|
406
|
+
!domEditHoverSelection.element.isConnected;
|
|
407
|
+
if (shouldClear) updateDomEditHoverSelection(null);
|
|
418
408
|
}, [
|
|
419
|
-
|
|
409
|
+
captionEditMode,
|
|
420
410
|
domEditHoverSelection,
|
|
421
411
|
domEditSelection,
|
|
412
|
+
domEditGroupSelections,
|
|
422
413
|
updateDomEditHoverSelection,
|
|
423
414
|
]);
|
|
424
415
|
|
|
425
|
-
// Clear hover when element disconnected
|
|
426
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
if (!domEditHoverSelection) return;
|
|
429
|
-
if (domEditHoverSelection.element.isConnected) return;
|
|
430
|
-
updateDomEditHoverSelection(null);
|
|
431
|
-
}, [domEditHoverSelection, updateDomEditHoverSelection]);
|
|
432
|
-
|
|
433
416
|
// Clear selection on caption mode change
|
|
434
417
|
// eslint-disable-next-line no-restricted-syntax
|
|
435
418
|
useEffect(() => {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
4
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
|
+
|
|
6
|
+
interface RecordEditInput {
|
|
7
|
+
label: string;
|
|
8
|
+
kind: EditHistoryKind;
|
|
9
|
+
coalesceKey?: string;
|
|
10
|
+
files: Record<string, { before: string; after: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface UseEditorSaveOptions {
|
|
14
|
+
editingPathRef: React.RefObject<string | undefined>;
|
|
15
|
+
projectIdRef: React.RefObject<string | null>;
|
|
16
|
+
readProjectFile: (path: string) => Promise<string>;
|
|
17
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
18
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
19
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
20
|
+
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useEditorSave({
|
|
24
|
+
editingPathRef,
|
|
25
|
+
projectIdRef,
|
|
26
|
+
readProjectFile,
|
|
27
|
+
writeProjectFile,
|
|
28
|
+
recordEdit,
|
|
29
|
+
domEditSaveTimestampRef,
|
|
30
|
+
setRefreshKey,
|
|
31
|
+
}: UseEditorSaveOptions) {
|
|
32
|
+
const saveRafRef = useRef<number | null>(null);
|
|
33
|
+
const refreshRafRef = useRef<number | null>(null);
|
|
34
|
+
|
|
35
|
+
const handleContentChange = useCallback(
|
|
36
|
+
(content: string) => {
|
|
37
|
+
const pid = projectIdRef.current;
|
|
38
|
+
if (!pid) return;
|
|
39
|
+
const path = editingPathRef.current;
|
|
40
|
+
if (!path) return;
|
|
41
|
+
|
|
42
|
+
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
|
43
|
+
saveRafRef.current = requestAnimationFrame(() => {
|
|
44
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
45
|
+
saveProjectFilesWithHistory({
|
|
46
|
+
projectId: pid,
|
|
47
|
+
label: "Edit source",
|
|
48
|
+
kind: "source",
|
|
49
|
+
coalesceKey: `source:${path}`,
|
|
50
|
+
files: { [path]: content },
|
|
51
|
+
readFile: readProjectFile,
|
|
52
|
+
writeFile: writeProjectFile,
|
|
53
|
+
recordEdit,
|
|
54
|
+
})
|
|
55
|
+
.then(() => {
|
|
56
|
+
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
57
|
+
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
58
|
+
})
|
|
59
|
+
.catch((error) => {
|
|
60
|
+
trackStudioEvent("save_failure", {
|
|
61
|
+
source: "code_editor",
|
|
62
|
+
error_message: error instanceof Error ? error.message : "unknown",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
[
|
|
68
|
+
domEditSaveTimestampRef,
|
|
69
|
+
editingPathRef,
|
|
70
|
+
projectIdRef,
|
|
71
|
+
readProjectFile,
|
|
72
|
+
recordEdit,
|
|
73
|
+
setRefreshKey,
|
|
74
|
+
writeProjectFile,
|
|
75
|
+
],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
saveRafRef,
|
|
80
|
+
handleContentChange,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { usePlayerStore } from "../player";
|
|
3
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
4
|
+
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
5
|
+
import {
|
|
6
|
+
buildDomEditPatchTarget,
|
|
7
|
+
readHfId,
|
|
8
|
+
type DomEditSelection,
|
|
9
|
+
} from "../components/editor/domEditing";
|
|
10
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
11
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
12
|
+
|
|
13
|
+
interface RecordEditInput {
|
|
14
|
+
label: string;
|
|
15
|
+
kind: EditHistoryKind;
|
|
16
|
+
coalesceKey?: string;
|
|
17
|
+
files: Record<string, { before: string; after: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface UseElementLifecycleOpsParams {
|
|
21
|
+
activeCompPath: string | null;
|
|
22
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
23
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
24
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
25
|
+
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
26
|
+
projectIdRef: React.MutableRefObject<string | null>;
|
|
27
|
+
reloadPreview: () => void;
|
|
28
|
+
clearDomSelection: () => void;
|
|
29
|
+
commitPositionPatchToHtml: (
|
|
30
|
+
selection: DomEditSelection,
|
|
31
|
+
patches: PatchOperation[],
|
|
32
|
+
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
|
33
|
+
) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useElementLifecycleOps({
|
|
37
|
+
activeCompPath,
|
|
38
|
+
showToast,
|
|
39
|
+
writeProjectFile,
|
|
40
|
+
domEditSaveTimestampRef,
|
|
41
|
+
editHistory,
|
|
42
|
+
projectIdRef,
|
|
43
|
+
reloadPreview,
|
|
44
|
+
clearDomSelection,
|
|
45
|
+
commitPositionPatchToHtml,
|
|
46
|
+
}: UseElementLifecycleOpsParams) {
|
|
47
|
+
// fallow-ignore-next-line complexity
|
|
48
|
+
const handleDomEditElementDelete = useCallback(
|
|
49
|
+
// fallow-ignore-next-line complexity
|
|
50
|
+
async (selection: DomEditSelection) => {
|
|
51
|
+
const pid = projectIdRef.current;
|
|
52
|
+
if (!pid) return;
|
|
53
|
+
const label = selection.label || selection.id || selection.selector || selection.tagName;
|
|
54
|
+
|
|
55
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(
|
|
58
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
59
|
+
);
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const data = (await response.json()) as { content?: string };
|
|
65
|
+
const originalContent = data.content;
|
|
66
|
+
if (typeof originalContent !== "string")
|
|
67
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
68
|
+
|
|
69
|
+
const patchTarget = buildDomEditPatchTarget(selection);
|
|
70
|
+
if (!patchTarget.id && !patchTarget.selector && !patchTarget.hfId) {
|
|
71
|
+
throw new Error("Selected element has no patchable target");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
75
|
+
const removeResponse = await fetch(
|
|
76
|
+
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
77
|
+
{
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify({ target: patchTarget }),
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
if (!removeResponse.ok) {
|
|
84
|
+
throw await createStudioSaveHttpError(
|
|
85
|
+
removeResponse,
|
|
86
|
+
`Failed to delete element from ${targetPath}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
|
|
91
|
+
const patchedContent =
|
|
92
|
+
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
93
|
+
await saveProjectFilesWithHistory({
|
|
94
|
+
projectId: pid,
|
|
95
|
+
label: "Delete element",
|
|
96
|
+
kind: "timeline",
|
|
97
|
+
files: { [targetPath]: patchedContent },
|
|
98
|
+
readFile: async () => originalContent,
|
|
99
|
+
writeFile: writeProjectFile,
|
|
100
|
+
recordEdit: editHistory.recordEdit,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
clearDomSelection();
|
|
104
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
105
|
+
reloadPreview();
|
|
106
|
+
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
109
|
+
showToast(message);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[
|
|
113
|
+
activeCompPath,
|
|
114
|
+
clearDomSelection,
|
|
115
|
+
domEditSaveTimestampRef,
|
|
116
|
+
editHistory.recordEdit,
|
|
117
|
+
projectIdRef,
|
|
118
|
+
reloadPreview,
|
|
119
|
+
showToast,
|
|
120
|
+
writeProjectFile,
|
|
121
|
+
],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const handleDomZIndexReorderCommit = useCallback(
|
|
125
|
+
(
|
|
126
|
+
entries: Array<{
|
|
127
|
+
element: HTMLElement;
|
|
128
|
+
zIndex: number;
|
|
129
|
+
id?: string;
|
|
130
|
+
selector?: string;
|
|
131
|
+
selectorIndex?: number;
|
|
132
|
+
sourceFile: string;
|
|
133
|
+
}>,
|
|
134
|
+
) => {
|
|
135
|
+
if (entries.length === 0) return;
|
|
136
|
+
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
|
137
|
+
for (let i = 0; i < entries.length; i++) {
|
|
138
|
+
const entry = entries[i];
|
|
139
|
+
entry.element.style.zIndex = String(entry.zIndex);
|
|
140
|
+
const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
|
|
141
|
+
{ type: "inline-style", property: "z-index", value: String(entry.zIndex) },
|
|
142
|
+
];
|
|
143
|
+
try {
|
|
144
|
+
const win = entry.element.ownerDocument?.defaultView;
|
|
145
|
+
if (win && win.getComputedStyle(entry.element).position === "static") {
|
|
146
|
+
entry.element.style.position = "relative";
|
|
147
|
+
patches.push({ type: "inline-style", property: "position", value: "relative" });
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
/* cross-origin or detached — skip */
|
|
151
|
+
}
|
|
152
|
+
void commitPositionPatchToHtml(
|
|
153
|
+
{
|
|
154
|
+
element: entry.element,
|
|
155
|
+
id: entry.id ?? null,
|
|
156
|
+
hfId: readHfId(entry.element),
|
|
157
|
+
selector: entry.selector,
|
|
158
|
+
selectorIndex: entry.selectorIndex,
|
|
159
|
+
sourceFile: entry.sourceFile,
|
|
160
|
+
} as unknown as DomEditSelection,
|
|
161
|
+
patches,
|
|
162
|
+
{
|
|
163
|
+
label: "Reorder layers",
|
|
164
|
+
coalesceKey,
|
|
165
|
+
skipRefresh: i < entries.length - 1,
|
|
166
|
+
},
|
|
167
|
+
).catch(() => undefined);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
[commitPositionPatchToHtml],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
handleDomEditElementDelete,
|
|
175
|
+
handleDomZIndexReorderCommit,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -12,6 +12,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
12
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
15
|
+
import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
|
|
16
|
+
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
|
17
|
+
import { roundTo3 } from "../utils/rounding";
|
|
15
18
|
|
|
16
19
|
export interface EnableKeyframesSession {
|
|
17
20
|
domEditSelection: DomEditSelection | null;
|
|
@@ -52,12 +55,11 @@ function readElementPosition(
|
|
|
52
55
|
const element = sel.element;
|
|
53
56
|
if (!element?.isConnected || !gsap?.getProperty) return result;
|
|
54
57
|
|
|
55
|
-
const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
|
|
56
58
|
const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
|
|
57
59
|
for (const prop of props) {
|
|
58
60
|
const val = Number(gsap.getProperty(element, prop));
|
|
59
61
|
if (!Number.isFinite(val)) continue;
|
|
60
|
-
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) :
|
|
62
|
+
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
return result;
|
|
@@ -75,13 +77,6 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAni
|
|
|
75
77
|
});
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
function computePercentage(t: number, sel: DomEditSelection): number {
|
|
79
|
-
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
80
|
-
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
81
|
-
if (elDuration <= 0) return 0;
|
|
82
|
-
return Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
80
|
// fallow-ignore-next-line complexity
|
|
86
81
|
export function useEnableKeyframes(
|
|
87
82
|
sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
|
|
@@ -104,7 +99,7 @@ export function useEnableKeyframes(
|
|
|
104
99
|
const flatAnim = anims.find((a) => !a.keyframes);
|
|
105
100
|
|
|
106
101
|
if (kfAnim?.keyframes) {
|
|
107
|
-
const pct =
|
|
102
|
+
const pct = computeElementPercentage(t, sel);
|
|
108
103
|
const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
|
109
104
|
if (existing) {
|
|
110
105
|
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
@@ -120,17 +115,17 @@ export function useEnableKeyframes(
|
|
|
120
115
|
|
|
121
116
|
await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined);
|
|
122
117
|
|
|
123
|
-
const pct =
|
|
118
|
+
const pct = computeElementPercentage(t, sel);
|
|
124
119
|
if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) {
|
|
125
120
|
await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position);
|
|
126
121
|
await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position);
|
|
127
122
|
}
|
|
128
123
|
} else {
|
|
129
124
|
const position = readElementPosition(iframe, sel, null);
|
|
130
|
-
const pct =
|
|
125
|
+
const pct = computeElementPercentage(t, sel);
|
|
131
126
|
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
132
127
|
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
133
|
-
const selector = sel
|
|
128
|
+
const selector = selectorFromSelection(sel);
|
|
134
129
|
|
|
135
130
|
if (!selector) {
|
|
136
131
|
session.handleGsapAddAnimation("to");
|
|
@@ -159,8 +154,8 @@ export function useEnableKeyframes(
|
|
|
159
154
|
{
|
|
160
155
|
type: "add-with-keyframes",
|
|
161
156
|
targetSelector: selector,
|
|
162
|
-
position:
|
|
163
|
-
duration:
|
|
157
|
+
position: roundTo3(elStart),
|
|
158
|
+
duration: roundTo3(elDuration),
|
|
164
159
|
keyframes,
|
|
165
160
|
},
|
|
166
161
|
{ label: "Enable keyframes", softReload: true },
|