@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.
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-Ce3pBm_I.js +252 -0
  4. package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
  5. package/dist/assets/index-D-bS9Dxx.js +1 -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
@@ -5,7 +5,7 @@ import {
5
5
  resolveDomEditSelection,
6
6
  type DomEditLayerItem,
7
7
  } from "./domEditing";
8
- import { useStudioContext } from "../../contexts/StudioContext";
8
+ import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
9
9
  import { useDomEditContext } from "../../contexts/DomEditContext";
10
10
  import { usePlayerStore } from "../../player";
11
11
  import {
@@ -54,14 +54,8 @@ interface CollapsedState {
54
54
 
55
55
  // fallow-ignore-next-line complexity
56
56
  export const LayersPanel = memo(function LayersPanel() {
57
- const {
58
- previewIframeRef,
59
- activeCompPath,
60
- refreshKey,
61
- compositionLoading,
62
- timelineElements,
63
- showToast,
64
- } = useStudioContext();
57
+ const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext();
58
+ const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext();
65
59
  const currentTime = usePlayerStore((s) => s.currentTime);
66
60
  const {
67
61
  domEditSelection,
@@ -1,6 +1,6 @@
1
1
  import { memo, useEffect, useRef, useState } from "react";
2
2
  import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
3
- import { useStudioContext } from "../../contexts/StudioContext";
3
+ import { useStudioShellContext } from "../../contexts/StudioContext";
4
4
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
5
5
  import {
6
6
  EMPTY_STYLES,
@@ -11,6 +11,7 @@ import {
11
11
  readGsapBorderRadiusForPanel,
12
12
  } from "./propertyPanelHelpers";
13
13
  import { MetricField, Section } from "./propertyPanelPrimitives";
14
+ import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
14
15
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
15
16
  import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
16
17
  import { TextSection, StyleSections } from "./propertyPanelSections";
@@ -83,7 +84,7 @@ export const PropertyPanel = memo(function PropertyPanel({
83
84
  onToggleRecording,
84
85
  }: PropertyPanelProps) {
85
86
  const styles = element?.computedStyles ?? EMPTY_STYLES;
86
- const { showToast } = useStudioContext();
87
+ const { showToast } = useStudioShellContext();
87
88
  const [clipboardCopied, setClipboardCopied] = useState(false);
88
89
  const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
89
90
  const storeTime = usePlayerStore((s) => s.currentTime);
@@ -144,6 +145,7 @@ export const PropertyPanel = memo(function PropertyPanel({
144
145
 
145
146
  const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
146
147
  const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
148
+ const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
147
149
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
148
150
  const showEditableSections = element.capabilities.canEditStyles;
149
151
  const manualOffset = readStudioPathOffset(element.element);
@@ -157,66 +159,7 @@ export const PropertyPanel = memo(function PropertyPanel({
157
159
  ? manualSize.height
158
160
  : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
159
161
 
160
- const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
161
- const parsed = parsePxMetricValue(nextValue);
162
- if (parsed == null) return;
163
- if (onCommitAnimatedProperty && hasGsapAnimation) {
164
- void onCommitAnimatedProperty(element, axis, parsed);
165
- return;
166
- }
167
- if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
168
- const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
169
- onAddKeyframe(gsapAnimId, pct, axis, parsed);
170
- return;
171
- }
172
- if (hasGsapAnimation) {
173
- showToast?.("Cannot edit position — animation callbacks not available");
174
- return;
175
- }
176
- const current = readStudioPathOffset(element.element);
177
- void Promise.resolve(
178
- onSetManualOffset(element, {
179
- x: axis === "x" ? parsed : current.x,
180
- y: axis === "y" ? parsed : current.y,
181
- }),
182
- ).catch(() => undefined);
183
- };
184
-
185
- // fallow-ignore-next-line complexity
186
- const commitManualSize = (axis: "width" | "height", nextValue: string) => {
187
- const parsed = parsePxMetricValue(nextValue);
188
- if (parsed == null || parsed <= 0) return;
189
- if (onCommitAnimatedProperty && hasGsapAnimation) {
190
- void onCommitAnimatedProperty(element, axis, parsed);
191
- return;
192
- }
193
- if (hasGsapAnimation) {
194
- showToast?.("Cannot edit size — animation callbacks not available");
195
- return;
196
- }
197
- const current = readStudioBoxSize(element.element);
198
- const width =
199
- current.width > 0
200
- ? current.width
201
- : (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
202
- const height =
203
- current.height > 0
204
- ? current.height
205
- : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
206
- void Promise.resolve(
207
- onSetManualSize(element, {
208
- width: axis === "width" ? parsed : width,
209
- height: axis === "height" ? parsed : height,
210
- }),
211
- ).catch(() => undefined);
212
- };
213
-
214
162
  const manualRotation = readStudioRotation(element.element);
215
- const commitManualRotation = (nextValue: string) => {
216
- const parsed = Number.parseFloat(nextValue);
217
- if (!Number.isFinite(parsed)) return;
218
- void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
219
- };
220
163
 
221
164
  const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
222
165
  const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
@@ -226,6 +169,21 @@ export const PropertyPanel = memo(function PropertyPanel({
226
169
  const gsapKeyframes = gsapKfAnim?.keyframes?.keyframes ?? null;
227
170
  const gsapAnimId = gsapKfAnim?.id ?? gsapAnimations?.[0]?.id ?? null;
228
171
  const hasGsapAnimation = !!(gsapAnimId || gsapAnimations.length > 0);
172
+ const { commitManualOffset, commitManualSize, commitManualRotation } =
173
+ createTransformCommitHandlers({
174
+ element,
175
+ styles,
176
+ hasGsapAnimation,
177
+ gsapAnimId,
178
+ gsapKeyframes,
179
+ currentPct,
180
+ onCommitAnimatedProperty,
181
+ onAddKeyframe,
182
+ onSetManualOffset,
183
+ onSetManualSize,
184
+ onSetManualRotation,
185
+ showToast,
186
+ });
229
187
  const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
230
188
  const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
231
189
 
@@ -495,6 +453,7 @@ export const PropertyPanel = memo(function PropertyPanel({
495
453
  <MetricField
496
454
  label="R"
497
455
  value={`${displayR}°`}
456
+ disabled={manualRotationEditingDisabled}
498
457
  onCommit={(next) => commitManualRotation(next.replace("°", ""))}
499
458
  />
500
459
  </div>
@@ -1,3 +1,5 @@
1
+ import { roundToCenti } from "../../utils/rounding";
2
+
1
3
  export interface ParsedColor {
2
4
  red: number;
3
5
  green: number;
@@ -24,7 +26,7 @@ function toHex(value: number): string {
24
26
  }
25
27
 
26
28
  function formatAlpha(value: number): string {
27
- return `${Math.round(clampAlpha(value) * 100) / 100}`;
29
+ return `${roundToCenti(clampAlpha(value))}`;
28
30
  }
29
31
 
30
32
  export function parseCssColor(value: string): ParsedColor | null {
@@ -1,3 +1,4 @@
1
+ import type { RefObject } from "react";
1
2
  import type { DomEditSelection } from "./domEditing";
2
3
  import type {
3
4
  StudioBoxSizeSnapshot,
@@ -5,8 +6,9 @@ import type {
5
6
  StudioRotationSnapshot,
6
7
  } from "./manualEdits";
7
8
  import type { ManualOffsetDragMember } from "./manualOffsetDrag";
8
- import type { GroupOverlayItem } from "./domEditOverlayGeometry";
9
+ import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
9
10
  import type { SnapContext } from "./snapTargetCollection";
11
+ import type { SnapGuidesState } from "./SnapGuideOverlay";
10
12
 
11
13
  export type GestureKind = "drag" | "resize" | "rotate";
12
14
 
@@ -143,3 +145,54 @@ export function resolveDomEditRotationGesture(input: {
143
145
  export function hasDomEditRotationChanged(initialAngle: number, nextAngle: number): boolean {
144
146
  return Math.abs(nextAngle - initialAngle) >= ROTATION_COMMIT_EPSILON_DEGREES;
145
147
  }
148
+
149
+ // ── Shared types for DomEditOverlay gesture wiring ──
150
+ // These live here (rather than in DomEditOverlay.tsx or useDomEditOverlayGestures.ts)
151
+ // to break circular imports between those files.
152
+
153
+ export interface DomEditGroupPathOffsetCommit {
154
+ selection: DomEditSelection;
155
+ next: { x: number; y: number };
156
+ }
157
+
158
+ // Refs are stable across renders; values are read via .current.
159
+ export type UseDomEditOverlayGesturesOptions = {
160
+ overlayRef: RefObject<HTMLDivElement | null>;
161
+ iframeRef: RefObject<HTMLIFrameElement | null>;
162
+ boxRef: RefObject<HTMLDivElement | null>;
163
+ selectionRef: RefObject<DomEditSelection | null>;
164
+ overlayRectRef: RefObject<OverlayRect | null>;
165
+ groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
166
+ gestureRef: RefObject<GestureState | null>;
167
+ groupGestureRef: RefObject<GroupGestureState | null>;
168
+ blockedMoveRef: RefObject<BlockedMoveState | null>;
169
+ rafPausedRef: RefObject<boolean>;
170
+ suppressNextBoxClickRef: RefObject<boolean>;
171
+ setOverlayRect: (next: OverlayRect | null) => void;
172
+ setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
173
+ onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
174
+ onManualDragStartRef: RefObject<(() => void) | undefined>;
175
+ onPathOffsetCommitRef: RefObject<
176
+ (s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
177
+ >;
178
+ onGroupPathOffsetCommitRef: RefObject<
179
+ (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
180
+ >;
181
+ onBoxSizeCommitRef: RefObject<
182
+ (s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
183
+ >;
184
+ onRotationCommitRef: RefObject<
185
+ (s: DomEditSelection, n: { angle: number }) => Promise<void> | void
186
+ >;
187
+ onCanvasPointerMoveRef: RefObject<
188
+ (
189
+ e: React.PointerEvent<HTMLDivElement>,
190
+ o?: { preferClipAncestor?: boolean },
191
+ ) => Promise<DomEditSelection | null>
192
+ >;
193
+ onCanvasMouseDown: (
194
+ e: React.MouseEvent<HTMLDivElement>,
195
+ o?: { preferClipAncestor?: boolean },
196
+ ) => void;
197
+ snapGuidesRef: RefObject<SnapGuidesState | null>;
198
+ };
@@ -21,8 +21,11 @@ import {
21
21
  filterNestedDomEditGroupItems,
22
22
  selectionCacheKey,
23
23
  } from "./domEditOverlayGeometry";
24
- import { type GestureKind, type GestureState } from "./domEditOverlayGestures";
25
- import type { UseDomEditOverlayGesturesOptions } from "./useDomEditOverlayGestures";
24
+ import {
25
+ type GestureKind,
26
+ type GestureState,
27
+ type UseDomEditOverlayGesturesOptions,
28
+ } from "./domEditOverlayGestures";
26
29
  import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
27
30
 
28
31
  export function startGroupDrag(
@@ -1,3 +1,5 @@
1
+ import { roundToCenti } from "../../utils/rounding";
2
+
1
3
  export type GradientKind = "linear" | "radial" | "conic";
2
4
 
3
5
  export type RadialSizeKeyword =
@@ -124,9 +126,7 @@ function clamp(value: number, min: number, max: number): number {
124
126
  return Math.min(max, Math.max(min, value));
125
127
  }
126
128
 
127
- function round(value: number): number {
128
- return Math.round(value * 100) / 100;
129
- }
129
+ const round = roundToCenti;
130
130
 
131
131
  function parsePercent(value: string | undefined, fallback: number): number {
132
132
  const parsed = parseCssNumber(value);
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { pickKeyframeTween, computeKeyframeMovePlan } from "./keyframeMove";
3
+
4
+ const flat = (id: string, target: string, position: number, duration: number, group?: string) => ({
5
+ id,
6
+ targetSelector: target,
7
+ position,
8
+ duration,
9
+ resolvedStart: position,
10
+ propertyGroup: group,
11
+ });
12
+
13
+ const el = { start: 0, duration: 10, domId: "box", selector: "#box" };
14
+
15
+ describe("pickKeyframeTween", () => {
16
+ it("matches by the element's selector", () => {
17
+ const anims = [flat("a", "#other", 0, 5), flat("b", "#box", 2, 3)];
18
+ expect(pickKeyframeTween(anims, el, 3, undefined)?.id).toBe("b");
19
+ });
20
+
21
+ it("prefers the dragged keyframe's property group", () => {
22
+ const anims = [flat("pos", "#box", 0, 8, "position"), flat("vis", "#box", 0, 8, "visual")];
23
+ expect(pickKeyframeTween(anims, el, 1, "visual")?.id).toBe("vis");
24
+ });
25
+
26
+ it("among same-group tweens picks the one whose window contains the original time", () => {
27
+ const fadeIn = flat("in", "#box", 1, 1, "visual");
28
+ const fadeOut = flat("out", "#box", 8, 1, "visual");
29
+ expect(pickKeyframeTween([fadeIn, fadeOut], el, 8.5, "visual")?.id).toBe("out");
30
+ expect(pickKeyframeTween([fadeIn, fadeOut], el, 1.2, "visual")?.id).toBe("in");
31
+ });
32
+
33
+ it("returns undefined when there are no tweens", () => {
34
+ expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined();
35
+ });
36
+
37
+ it("returns undefined rather than editing another element on a selector mismatch", () => {
38
+ const anims = [flat("a", "#other", 0, 5), flat("b", ".unrelated", 2, 3)];
39
+ expect(pickKeyframeTween(anims, el, 3, undefined)).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ describe("computeKeyframeMovePlan — flat tween", () => {
44
+ const anim = flat("t", "#box", 2, 4); // window [2, 6]
45
+
46
+ it("start point trims the front, keeping the end fixed", () => {
47
+ // newPct 30% → abs 3 → start moves to 3, duration shrinks to 3.
48
+ const plan = computeKeyframeMovePlan(anim, 0, el, 30);
49
+ expect(plan.meta).toEqual({ position: 3, duration: 3 });
50
+ expect(plan.removes).toEqual([]);
51
+ });
52
+
53
+ it("end point resizes, keeping the start", () => {
54
+ // tweenOldPct 100 (end) → newPct 80% → abs 8 → duration 6, start unchanged.
55
+ const plan = computeKeyframeMovePlan(anim, 100, el, 80);
56
+ expect(plan.meta).toEqual({ position: 2, duration: 6 });
57
+ });
58
+ });
59
+
60
+ describe("computeKeyframeMovePlan — keyframe-array tween", () => {
61
+ const anim = {
62
+ id: "k",
63
+ targetSelector: "#box",
64
+ position: 0,
65
+ duration: 10,
66
+ resolvedStart: 0,
67
+ keyframes: {
68
+ keyframes: [
69
+ { percentage: 0, properties: { x: 0 } },
70
+ { percentage: 50, properties: { x: 50 } },
71
+ { percentage: 100, properties: { x: 100 } },
72
+ ],
73
+ },
74
+ };
75
+
76
+ it("moves an intermediate keyframe without touching the tween or others", () => {
77
+ // mid keyframe (tweenPct 50) → newPct 70% → abs 7 → 70% of the tween.
78
+ const plan = computeKeyframeMovePlan(anim, 50, el, 70);
79
+ expect(plan.meta).toBeUndefined();
80
+ expect(plan.removes).toEqual([50]);
81
+ expect(plan.adds).toEqual([{ pct: 70, properties: { x: 50 } }]);
82
+ });
83
+
84
+ it("start move remaps intermediates to preserve their absolute times", () => {
85
+ // start (tweenPct 0) → newPct 20% → abs 2 → window [2,10]. The 50% keyframe
86
+ // was at abs 5 → now (5-2)/8 = 37.5%.
87
+ const plan = computeKeyframeMovePlan(anim, 0, el, 20);
88
+ expect(plan.meta).toEqual({ position: 2, duration: 8 });
89
+ expect(plan.removes).toContain(50);
90
+ const mid = plan.adds.find((a) => a.properties.x === 50);
91
+ expect(mid?.pct).toBeCloseTo(37.5, 1);
92
+ });
93
+
94
+ it("is a no-op when the dragged keyframe can't be located (stale cache)", () => {
95
+ // tweenOldPct 33 matches no keyframe (0/50/100) → must NOT resize the tween.
96
+ const plan = computeKeyframeMovePlan(anim, 33, el, 70);
97
+ expect(plan.meta).toBeUndefined();
98
+ expect(plan.removes).toEqual([]);
99
+ expect(plan.adds).toEqual([]);
100
+ });
101
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Pure helpers for committing a keyframe-diamond drag: pick the tween the
3
+ * dragged keyframe belongs to, and compute the GSAP mutations (tween
4
+ * position/duration and/or keyframe add/remove) for the move. Kept free of
5
+ * React/store so the timeline drag handler stays a thin orchestrator.
6
+ */
7
+
8
+ interface TweenLike {
9
+ id: string;
10
+ targetSelector: string;
11
+ position: number | string;
12
+ duration?: number;
13
+ resolvedStart?: number;
14
+ propertyGroup?: string;
15
+ keyframes?: { keyframes: { percentage: number; properties: Record<string, number | string> }[] };
16
+ }
17
+
18
+ interface ElementWindow {
19
+ start: number;
20
+ duration: number;
21
+ domId?: string;
22
+ selector?: string;
23
+ }
24
+
25
+ export interface KeyframeMovePlan {
26
+ /** Tween timing change (start/end point drags). */
27
+ meta?: { position: number; duration: number };
28
+ /** Keyframe percentages to remove, then re-add (intermediate move / remap). */
29
+ removes: number[];
30
+ adds: { pct: number; properties: Record<string, number | string> }[];
31
+ }
32
+
33
+ const round3 = (n: number) => Math.round(n * 1000) / 1000;
34
+ const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100));
35
+ const MIN_DUR = 0.05;
36
+
37
+ function tweenWindow(a: TweenLike): { start: number; dur: number } {
38
+ return {
39
+ start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0),
40
+ dur: a.duration ?? 0,
41
+ };
42
+ }
43
+
44
+ type Kf = { percentage: number; properties: Record<string, number | string> };
45
+
46
+ /**
47
+ * Remap every keyframe except `keepIdx` from the old tween window to the new one
48
+ * so their absolute times stay fixed after a start/end resize. Returns the
49
+ * remove/add ops (empty for flat tweens, which have no intermediates).
50
+ */
51
+ function remapKeyframes(
52
+ kfs: Kf[],
53
+ keepIdx: number,
54
+ oldStart: number,
55
+ oldDur: number,
56
+ newStart: number,
57
+ newDur: number,
58
+ ): Pick<KeyframeMovePlan, "removes" | "adds"> {
59
+ const removes: number[] = [];
60
+ const adds: KeyframeMovePlan["adds"] = [];
61
+ if (newDur <= 0) return { removes, adds };
62
+ for (let i = 0; i < kfs.length; i++) {
63
+ if (i === keepIdx) continue;
64
+ const k = kfs[i]!;
65
+ const absT = oldStart + (k.percentage / 100) * oldDur;
66
+ const remapped = clampPct(((absT - newStart) / newDur) * 100);
67
+ if (Math.abs(remapped - k.percentage) < 0.05) continue;
68
+ removes.push(k.percentage);
69
+ adds.push({ pct: remapped, properties: k.properties });
70
+ }
71
+ return { removes, adds };
72
+ }
73
+
74
+ /**
75
+ * Pick the tween the dragged keyframe belongs to: restrict to the element's
76
+ * selector and (if known) the keyframe's property group, then choose the one
77
+ * whose time window contains — or is nearest — the keyframe's original time.
78
+ * An element can have several tweens in one group (e.g. fade-in + fade-out).
79
+ */
80
+ export function pickKeyframeTween<T extends TweenLike>(
81
+ anims: T[],
82
+ el: ElementWindow,
83
+ origAbsTime: number,
84
+ group: string | undefined,
85
+ ): T | undefined {
86
+ const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean);
87
+ const forEl = anims.filter((a) => selectors.includes(a.targetSelector));
88
+ // Only ever pick among THIS element's tweens. Don't fall back to all
89
+ // animations — a selector mismatch (e.g. a class/compound-selector tween)
90
+ // would otherwise edit a different element's keyframes. No match → no-op.
91
+ if (forEl.length === 0) return undefined;
92
+ const groupPool = group ? forEl.filter((a) => a.propertyGroup === group) : [];
93
+ const candidates = groupPool.length > 0 ? groupPool : forEl;
94
+ const dist = (a: T): number => {
95
+ const { start, dur } = tweenWindow(a);
96
+ if (origAbsTime >= start && origAbsTime <= start + dur) return 0;
97
+ return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur)));
98
+ };
99
+ return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!);
100
+ }
101
+
102
+ /**
103
+ * Compute the mutations for moving a keyframe to `newPct` (clip-relative):
104
+ * - start point → trim front (position moves, end fixed),
105
+ * - end point → resize (duration changes, start fixed),
106
+ * - intermediate → move only that keyframe; start/end moves remap the other
107
+ * keyframes so their absolute times stay put.
108
+ */
109
+ // fallow-ignore-next-line complexity
110
+ export function computeKeyframeMovePlan(
111
+ anim: TweenLike,
112
+ tweenOldPct: number,
113
+ el: ElementWindow,
114
+ newPct: number,
115
+ ): KeyframeMovePlan {
116
+ const newAbsTime = el.start + (newPct / 100) * el.duration;
117
+ const tweenStart = tweenWindow(anim).start;
118
+ const tweenDur = anim.duration ?? el.duration;
119
+ const kfs = anim.keyframes
120
+ ? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage)
121
+ : null;
122
+ const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1;
123
+
124
+ // Keyframe-array tween but the dragged keyframe couldn't be located (stale
125
+ // cache / precision drift): no-op rather than falling through to an end-point
126
+ // resize that would silently rescale the whole tween and re-time every key.
127
+ if (kfs && idx === -1) return { removes: [], adds: [] };
128
+
129
+ if (kfs && idx > 0 && idx < kfs.length - 1) {
130
+ const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0;
131
+ return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] };
132
+ }
133
+
134
+ const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50;
135
+ let newStart = tweenStart;
136
+ let newDur = tweenDur;
137
+ if (isStartPoint) {
138
+ const end = tweenStart + tweenDur;
139
+ newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR));
140
+ newDur = end - newStart;
141
+ } else {
142
+ newDur = Math.max(MIN_DUR, newAbsTime - tweenStart);
143
+ }
144
+
145
+ const windowChanged = newStart !== tweenStart || newDur !== tweenDur;
146
+ const remap =
147
+ kfs && windowChanged
148
+ ? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur)
149
+ : { removes: [], adds: [] };
150
+ return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap };
151
+ }
@@ -506,18 +506,6 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
506
506
  );
507
507
  }
508
508
 
509
- /* ── HTML patch builders (re-exported from manualEditsDomPatches) ── */
510
- export {
511
- buildPathOffsetPatches,
512
- buildClearPathOffsetPatches,
513
- buildBoxSizePatches,
514
- buildClearBoxSizePatches,
515
- buildRotationPatches,
516
- buildClearRotationPatches,
517
- buildMotionPatches,
518
- buildClearMotionPatches,
519
- } from "./manualEditsDomPatches";
520
-
521
509
  /* ── Seek reapply (position + motion) ────────────────────────────── */
522
510
 
523
511
  function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
@@ -3,6 +3,7 @@ import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import { roundToCenti } from "../../utils/rounding";
6
7
 
7
8
  export interface PropertyPanelProps {
8
9
  projectId: string;
@@ -239,8 +240,13 @@ export function parseNumericValue(value: string | undefined): number | null {
239
240
  return Number.isFinite(parsed) ? parsed : null;
240
241
  }
241
242
 
243
+ export function formatTimingValue(seconds: number): string {
244
+ if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
245
+ return `${seconds.toFixed(2)}s`;
246
+ }
247
+
242
248
  export function formatNumericValue(value: number): string {
243
- const rounded = Math.round(value * 100) / 100;
249
+ const rounded = roundToCenti(value);
244
250
  return Number.isInteger(rounded)
245
251
  ? `${rounded}`
246
252
  : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
@@ -473,40 +479,6 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
473
479
  return value.slice(index, endParen).trim();
474
480
  }
475
481
 
476
- // ── Fit to children ──────────────────────────────────────────────────
477
-
478
- export function computeFitToChildrenSize(
479
- element: DomEditSelection,
480
- ): { width: number; height: number } | null {
481
- const el = element.element;
482
- const win = el.ownerDocument?.defaultView;
483
- const children = Array.from(el.children).filter((c): c is HTMLElement => c.nodeType === 1);
484
- if (children.length === 0) return null;
485
- let minX = Infinity,
486
- minY = Infinity,
487
- maxX = -Infinity,
488
- maxY = -Infinity;
489
- for (const child of children) {
490
- if (win) {
491
- const cs = win.getComputedStyle(child);
492
- if (cs.visibility === "hidden" || cs.display === "none") continue;
493
- }
494
- const r = child.getBoundingClientRect();
495
- if (r.width === 0 && r.height === 0) continue;
496
- minX = Math.min(minX, r.left);
497
- minY = Math.min(minY, r.top);
498
- maxX = Math.max(maxX, r.right);
499
- maxY = Math.max(maxY, r.bottom);
500
- }
501
- if (!isFinite(minX)) return null;
502
- const parentRect = el.getBoundingClientRect();
503
- const scaleX = parentRect.width > 0 ? element.boundingBox.width / parentRect.width : 1;
504
- const scaleY = parentRect.height > 0 ? element.boundingBox.height / parentRect.height : 1;
505
- const width = Math.round((maxX - minX) * scaleX);
506
- const height = Math.round((maxY - minY) * scaleY);
507
- return width > 0 && height > 0 ? { width, height } : null;
508
- }
509
-
510
482
  // ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
511
483
 
512
484
  export function readGsapRuntimeValuesForPanel(
@@ -541,7 +513,7 @@ export function readGsapRuntimeValuesForPanel(
541
513
  const result: Record<string, number> = {};
542
514
  for (const prop of propKeys) {
543
515
  const v = Number(gsap.getProperty(el, prop));
544
- if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
516
+ if (Number.isFinite(v)) result[prop] = roundToCenti(v);
545
517
  }
546
518
  return Object.keys(result).length > 0 ? result : null;
547
519
  } catch {
@@ -568,8 +540,8 @@ export function readGsapBorderRadiusForPanel(
568
540
  if (!iframe?.contentDocument || !selector) return null;
569
541
  try {
570
542
  const el = iframe.contentDocument.querySelector(selector);
571
- if (!el) return null;
572
- const cs = iframe.contentWindow!.getComputedStyle(el);
543
+ if (!el || !iframe.contentWindow) return null;
544
+ const cs = iframe.contentWindow.getComputedStyle(el);
573
545
  const parse = (v: string) => Number.parseFloat(v) || 0;
574
546
  return {
575
547
  tl: parse(cs.borderTopLeftRadius),
@@ -3,6 +3,7 @@ import { Check, ClipboardList, Film, Music } from "../../icons/SystemIcons";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import {
5
5
  formatNumericValue,
6
+ formatTimingValue,
6
7
  LABEL,
7
8
  parseNumericValue,
8
9
  RESPONSIVE_GRID,
@@ -15,11 +16,6 @@ export function isMediaElement(element: DomEditSelection): boolean {
15
16
  return MEDIA_TAGS.has(element.tagName);
16
17
  }
17
18
 
18
- function formatTimingValue(seconds: number): string {
19
- if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
20
- return `${seconds.toFixed(2)}s`;
21
- }
22
-
23
19
  export function MediaSection({
24
20
  projectDir,
25
21
  element,
@@ -1,13 +1,8 @@
1
1
  import { Clock } from "../../icons/SystemIcons";
2
2
  import type { DomEditSelection } from "./domEditing";
3
- import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
3
+ import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
4
4
  import { MetricField, Section } from "./propertyPanelPrimitives";
5
5
 
6
- function formatTimingValue(seconds: number): string {
7
- if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
8
- return `${seconds.toFixed(2)}s`;
9
- }
10
-
11
6
  function parseTimingValue(input: string): number | null {
12
7
  const cleaned = input.replace(/s$/i, "").trim();
13
8
  const parsed = Number.parseFloat(cleaned);