@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
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
resolveDomEditSelection,
|
|
6
6
|
type DomEditLayerItem,
|
|
7
7
|
} from "./domEditing";
|
|
8
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
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 } =
|
|
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 `${
|
|
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 {
|
|
25
|
-
|
|
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
|
-
|
|
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 =
|
|
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] =
|
|
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
|
|
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);
|