@hyperframes/studio 0.6.97 → 0.6.99

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 (120) hide show
  1. package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
  2. package/dist/assets/index-B62bDCQv.css +1 -0
  3. package/dist/assets/{index-HveJ0MuV.js → index-C52IT_lp.js} +1 -1
  4. package/dist/assets/index-DOh7E1uj.js +1 -0
  5. package/dist/assets/index-DrwSRbsl.js +252 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +7 -5
  8. package/src/App.tsx +182 -177
  9. package/src/captions/store.ts +11 -11
  10. package/src/components/StudioHeader.tsx +4 -4
  11. package/src/components/StudioLeftSidebar.tsx +2 -2
  12. package/src/components/StudioPreviewArea.tsx +225 -183
  13. package/src/components/StudioRightPanel.tsx +3 -3
  14. package/src/components/TimelineToolbar.tsx +25 -0
  15. package/src/components/editor/DomEditOverlay.tsx +2 -5
  16. package/src/components/editor/EaseCurveSection.tsx +2 -3
  17. package/src/components/editor/GestureTrailOverlay.tsx +4 -3
  18. package/src/components/editor/LayersPanel.tsx +3 -9
  19. package/src/components/editor/PropertyPanel.tsx +20 -61
  20. package/src/components/editor/colorValue.ts +3 -1
  21. package/src/components/editor/domEditOverlayGestures.ts +54 -1
  22. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  23. package/src/components/editor/gradientValue.ts +3 -3
  24. package/src/components/editor/keyframeMove.test.ts +101 -0
  25. package/src/components/editor/keyframeMove.ts +151 -0
  26. package/src/components/editor/manualEditsDom.ts +0 -12
  27. package/src/components/editor/propertyPanelHelpers.ts +10 -38
  28. package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
  29. package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
  30. package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
  31. package/src/components/editor/studioMotionOps.test.ts +1 -1
  32. package/src/components/editor/studioMotionOps.ts +2 -1
  33. package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
  34. package/src/components/nle/NLELayout.tsx +1 -24
  35. package/src/components/sidebar/BlocksTab.tsx +2 -2
  36. package/src/contexts/DomEditContext.tsx +134 -31
  37. package/src/contexts/StudioContext.tsx +90 -40
  38. package/src/contexts/TimelineEditContext.tsx +47 -0
  39. package/src/hooks/domEditCommitTypes.ts +14 -0
  40. package/src/hooks/gsapDragCommit.ts +9 -24
  41. package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
  42. package/src/hooks/gsapKeyframeCommit.ts +5 -15
  43. package/src/hooks/gsapRuntimeBridge.ts +18 -52
  44. package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
  45. package/src/hooks/gsapRuntimeReaders.ts +19 -26
  46. package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
  47. package/src/hooks/gsapScriptCommitTypes.ts +58 -0
  48. package/src/hooks/gsapShared.ts +157 -0
  49. package/src/hooks/timelineEditingHelpers.ts +63 -2
  50. package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
  51. package/src/hooks/useAppHotkeys.ts +299 -377
  52. package/src/hooks/useConsoleErrorCapture.ts +33 -5
  53. package/src/hooks/useDomEditCommits.ts +35 -293
  54. package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
  55. package/src/hooks/useDomEditSession.ts +78 -249
  56. package/src/hooks/useDomEditTextCommits.ts +1 -1
  57. package/src/hooks/useDomEditWiring.ts +255 -0
  58. package/src/hooks/useDomGeometryCommits.ts +181 -0
  59. package/src/hooks/useDomSelection.ts +10 -27
  60. package/src/hooks/useEditorSave.ts +82 -0
  61. package/src/hooks/useElementLifecycleOps.ts +177 -0
  62. package/src/hooks/useEnableKeyframes.ts +10 -15
  63. package/src/hooks/useFileManager.ts +32 -114
  64. package/src/hooks/useFileTree.ts +80 -0
  65. package/src/hooks/useGestureCommit.ts +7 -5
  66. package/src/hooks/useGestureRecording.ts +1 -1
  67. package/src/hooks/useGsapAnimationOps.ts +122 -0
  68. package/src/hooks/useGsapArcPathOps.ts +61 -0
  69. package/src/hooks/useGsapAwareEditing.ts +242 -0
  70. package/src/hooks/useGsapKeyframeOps.ts +167 -0
  71. package/src/hooks/useGsapPropertyDebounce.ts +135 -0
  72. package/src/hooks/useGsapScriptCommits.ts +58 -570
  73. package/src/hooks/useGsapSelectionHandlers.ts +22 -9
  74. package/src/hooks/useGsapTweenCache.ts +35 -29
  75. package/src/hooks/useLintModal.ts +7 -0
  76. package/src/hooks/useMusicBeatAnalysis.ts +152 -0
  77. package/src/hooks/useRazorSplit.ts +1 -1
  78. package/src/hooks/useRenderClipContent.ts +46 -21
  79. package/src/hooks/useTimelineEditing.ts +48 -4
  80. package/src/player/components/AudioWaveform.tsx +29 -4
  81. package/src/player/components/BeatStrip.tsx +166 -0
  82. package/src/player/components/Timeline.tsx +39 -18
  83. package/src/player/components/TimelineCanvas.tsx +52 -12
  84. package/src/player/components/TimelineClipDiamonds.tsx +130 -20
  85. package/src/player/components/TimelinePropertyRows.tsx +8 -2
  86. package/src/player/components/TimelineRuler.tsx +36 -2
  87. package/src/player/components/timelineEditing.ts +30 -5
  88. package/src/player/components/useTimelineClipDrag.ts +155 -4
  89. package/src/player/components/useTimelinePlayhead.ts +30 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +47 -45
  91. package/src/player/lib/mediaProbe.ts +46 -3
  92. package/src/player/lib/playbackScrub.ts +16 -0
  93. package/src/player/lib/timelineDOM.ts +10 -2
  94. package/src/player/lib/timelineIframeHelpers.ts +89 -0
  95. package/src/player/store/playerStore.ts +92 -33
  96. package/src/utils/beatEditActions.ts +109 -0
  97. package/src/utils/beatEditing.ts +136 -0
  98. package/src/utils/clipboardPayload.ts +3 -2
  99. package/src/utils/compositionPatterns.ts +2 -0
  100. package/src/utils/keyframeSelection.test.ts +45 -0
  101. package/src/utils/keyframeSelection.ts +29 -0
  102. package/src/utils/rounding.ts +9 -0
  103. package/src/utils/studioHelpers.ts +5 -2
  104. package/src/utils/studioUrlState.ts +2 -1
  105. package/src/utils/timelineAssetDrop.ts +6 -5
  106. package/src/utils/timelineInspector.ts +15 -100
  107. package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-Cfye9xzo.js +0 -251
  110. package/src/components/editor/DopesheetStrip.tsx +0 -141
  111. package/src/components/editor/StaggerControls.tsx +0 -61
  112. package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
  113. package/src/components/editor/TimelineLayerPanel.tsx +0 -15
  114. package/src/components/nle/TimelineEditorNotice.tsx +0 -133
  115. package/src/hooks/gsapRuntimePreview.ts +0 -19
  116. package/src/player/components/timelineUtils.ts +0 -211
  117. package/src/utils/audioBeatDetection.ts +0 -58
  118. package/src/utils/keyframeSnapping.test.ts +0 -74
  119. package/src/utils/keyframeSnapping.ts +0 -63
  120. 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 caption mode change
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 when matching selection
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 hoverMatchesSelection = domEditSelectionsTargetSame(
409
- domEditHoverSelection,
410
- domEditSelection,
411
- );
412
- const hoverMatchesGroup = domEditSelectionInGroup(
413
- domEditGroupSelections,
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
- domEditGroupSelections,
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) : Math.round(val * 1000) / 1000;
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 = computePercentage(t, sel);
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 = computePercentage(t, sel);
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 = computePercentage(t, sel);
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.id ? `#${sel.id}` : sel.selector;
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: Math.round(elStart * 1000) / 1000,
163
- duration: Math.round(elDuration * 1000) / 1000,
157
+ position: roundTo3(elStart),
158
+ duration: roundTo3(elDuration),
164
159
  keyframes,
165
160
  },
166
161
  { label: "Enable keyframes", softReload: true },