@hyperframes/studio 0.6.73 → 0.6.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- package/dist/assets/index-Dc2HfqON.js +0 -140
|
@@ -27,6 +27,16 @@ export const PROP_LABELS: Record<string, string> = {
|
|
|
27
27
|
autoAlpha: "Visibility",
|
|
28
28
|
visibility: "Visible",
|
|
29
29
|
scaleX_alias: "Stretch X",
|
|
30
|
+
filter: "Filter",
|
|
31
|
+
clipPath: "Clip Path",
|
|
32
|
+
color: "Color",
|
|
33
|
+
backgroundColor: "Background",
|
|
34
|
+
borderColor: "Border Color",
|
|
35
|
+
borderRadius: "Radius",
|
|
36
|
+
fontSize: "Font Size",
|
|
37
|
+
letterSpacing: "Tracking",
|
|
38
|
+
skewX: "Skew X",
|
|
39
|
+
skewY: "Skew Y",
|
|
30
40
|
};
|
|
31
41
|
|
|
32
42
|
export const PROP_UNITS: Record<string, string> = {
|
|
@@ -83,6 +93,11 @@ export const EASE_LABELS: Record<string, string> = {
|
|
|
83
93
|
"expo.out": "Very snappy stop",
|
|
84
94
|
"expo.in": "Very slow start",
|
|
85
95
|
"expo.inOut": "Dramatic ease",
|
|
96
|
+
"spring-gentle": "Gentle spring",
|
|
97
|
+
"spring-bouncy": "Bouncy spring",
|
|
98
|
+
"spring-stiff": "Stiff spring",
|
|
99
|
+
"spring-wobbly": "Wobbly spring",
|
|
100
|
+
"spring-heavy": "Heavy spring",
|
|
86
101
|
};
|
|
87
102
|
|
|
88
103
|
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
|
|
@@ -123,6 +138,33 @@ export function parseCustomEaseFromString(ease: string): {
|
|
|
123
138
|
|
|
124
139
|
export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
|
|
125
140
|
|
|
141
|
+
export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step?: number }> = {
|
|
142
|
+
opacity: { min: 0, max: 1, step: 0.01 },
|
|
143
|
+
autoAlpha: { min: 0, max: 1, step: 0.01 },
|
|
144
|
+
scale: { min: -10, max: 10, step: 0.01 },
|
|
145
|
+
scaleX: { min: -10, max: 10, step: 0.01 },
|
|
146
|
+
scaleY: { min: -10, max: 10, step: 0.01 },
|
|
147
|
+
rotation: { step: 1 },
|
|
148
|
+
skewX: { min: -90, max: 90, step: 1 },
|
|
149
|
+
skewY: { min: -90, max: 90, step: 1 },
|
|
150
|
+
width: { min: 0, step: 1 },
|
|
151
|
+
height: { min: 0, step: 1 },
|
|
152
|
+
borderRadius: { min: 0, step: 1 },
|
|
153
|
+
x: { step: 1 },
|
|
154
|
+
y: { step: 1 },
|
|
155
|
+
fontSize: { min: 1, step: 1 },
|
|
156
|
+
letterSpacing: { step: 0.1 },
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export function clampPropertyValue(prop: string, value: number): number {
|
|
160
|
+
const constraint = PROP_CONSTRAINTS[prop];
|
|
161
|
+
if (!constraint) return value;
|
|
162
|
+
let clamped = value;
|
|
163
|
+
if (constraint.min !== undefined) clamped = Math.max(constraint.min, clamped);
|
|
164
|
+
if (constraint.max !== undefined) clamped = Math.min(constraint.max, clamped);
|
|
165
|
+
return clamped;
|
|
166
|
+
}
|
|
167
|
+
|
|
126
168
|
export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const;
|
|
127
169
|
|
|
128
170
|
export const ADD_METHOD_LABELS: Record<string, string> = {
|
|
@@ -14,7 +14,8 @@ export function buildTweenSummary(animation: GsapAnimation): string {
|
|
|
14
14
|
const props = Object.entries(animation.properties);
|
|
15
15
|
const target = animation.targetSelector;
|
|
16
16
|
const dur = animation.duration ?? 0;
|
|
17
|
-
const
|
|
17
|
+
const rawPos = animation.position;
|
|
18
|
+
const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos;
|
|
18
19
|
const propDescs = props.map(([p, v]) => {
|
|
19
20
|
const label = (PROP_LABELS[p] ?? p).toLowerCase();
|
|
20
21
|
return `${label} to ${formatPropValue(p, v)}`;
|
|
@@ -68,6 +68,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
68
68
|
export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
69
69
|
env,
|
|
70
70
|
["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"],
|
|
71
|
+
true,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
|
75
|
+
env,
|
|
76
|
+
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
|
|
71
77
|
false,
|
|
72
78
|
);
|
|
73
79
|
|
|
@@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
226
|
+
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
|
|
226
227
|
const transform = element.style.getPropertyValue("transform");
|
|
227
228
|
if (!transform || transform === "none") return;
|
|
228
229
|
const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
|
|
@@ -233,8 +234,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
|
233
234
|
if (m.m41 === 0 && m.m42 === 0) return;
|
|
234
235
|
const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP);
|
|
235
236
|
const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP);
|
|
236
|
-
m.
|
|
237
|
-
|
|
237
|
+
const angle = Math.atan2(m.b, m.a);
|
|
238
|
+
const cos = Math.cos(angle);
|
|
239
|
+
const sin = Math.sin(angle);
|
|
240
|
+
m.m41 -= offsetX * cos - offsetY * sin;
|
|
241
|
+
m.m42 -= offsetX * sin + offsetY * cos;
|
|
238
242
|
if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) {
|
|
239
243
|
element.style.removeProperty("transform");
|
|
240
244
|
} else {
|
|
@@ -512,8 +516,58 @@ function reapplyPathOffsets(doc: Document): void {
|
|
|
512
516
|
}
|
|
513
517
|
}
|
|
514
518
|
|
|
519
|
+
function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
|
|
520
|
+
const win = el.ownerDocument.defaultView as
|
|
521
|
+
| (Window & {
|
|
522
|
+
__timelines?: Record<
|
|
523
|
+
string,
|
|
524
|
+
{
|
|
525
|
+
getChildren?: (
|
|
526
|
+
deep: boolean,
|
|
527
|
+
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
|
|
528
|
+
}
|
|
529
|
+
>;
|
|
530
|
+
})
|
|
531
|
+
| null;
|
|
532
|
+
if (!win?.__timelines) return false;
|
|
533
|
+
const propSet = new Set(props);
|
|
534
|
+
for (const tl of Object.values(win.__timelines)) {
|
|
535
|
+
if (!tl?.getChildren) continue;
|
|
536
|
+
try {
|
|
537
|
+
for (const child of tl.getChildren(true)) {
|
|
538
|
+
if (!child.targets || !child.vars) continue;
|
|
539
|
+
let targetsEl = false;
|
|
540
|
+
for (const t of child.targets()) {
|
|
541
|
+
if (t === el || (el.id && t.id === el.id)) {
|
|
542
|
+
targetsEl = true;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (!targetsEl) continue;
|
|
547
|
+
const vars = child.vars;
|
|
548
|
+
for (const p of propSet) {
|
|
549
|
+
if (p in vars) return true;
|
|
550
|
+
}
|
|
551
|
+
if (vars.keyframes && typeof vars.keyframes === "object") {
|
|
552
|
+
for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
|
|
553
|
+
if (kfVal && typeof kfVal === "object") {
|
|
554
|
+
for (const p of propSet) {
|
|
555
|
+
if (p in (kfVal as Record<string, unknown>)) return true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} catch {
|
|
562
|
+
/* */
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
|
|
515
568
|
function reapplyBoxSizes(doc: Document): void {
|
|
516
569
|
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
|
|
570
|
+
if (gsapAnimatesProperty(el, "width", "height")) continue;
|
|
517
571
|
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
|
|
518
572
|
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
|
|
519
573
|
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
@@ -236,9 +236,25 @@ export function createManualOffsetDragMember(input: {
|
|
|
236
236
|
const gestureToken = beginStudioManualEditGesture(input.element);
|
|
237
237
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
|
|
238
238
|
if (!measured.ok) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
239
|
+
// Fallback: when GSAP transforms interfere with probe measurement, use
|
|
240
|
+
// the preview scale as an approximation. The commit path reads the actual
|
|
241
|
+
// GSAP position from the iframe runtime, so visual imprecision during
|
|
242
|
+
// drag is acceptable — the final committed position is always exact.
|
|
243
|
+
const scaleX = input.rect.editScaleX || 1;
|
|
244
|
+
const scaleY = input.rect.editScaleY || 1;
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
member: {
|
|
248
|
+
key: input.key,
|
|
249
|
+
selection: input.selection,
|
|
250
|
+
element: input.element,
|
|
251
|
+
initialOffset,
|
|
252
|
+
initialPathOffset,
|
|
253
|
+
gestureToken,
|
|
254
|
+
screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY },
|
|
255
|
+
originRect: input.rect,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
242
258
|
}
|
|
243
259
|
|
|
244
260
|
return {
|
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
import { parseCssColor, type ParsedColor } from "./colorValue";
|
|
2
2
|
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
|
|
3
|
+
import type { DomEditSelection } from "./domEditing";
|
|
4
|
+
import type { ImportedFontAsset } from "./fontAssets";
|
|
5
|
+
|
|
6
|
+
export interface PropertyPanelProps {
|
|
7
|
+
projectId: string;
|
|
8
|
+
projectDir: string | null;
|
|
9
|
+
assets: string[];
|
|
10
|
+
element: DomEditSelection | null;
|
|
11
|
+
multiSelectCount?: number;
|
|
12
|
+
copiedAgentPrompt: boolean;
|
|
13
|
+
onClearSelection: () => void;
|
|
14
|
+
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
15
|
+
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
16
|
+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
17
|
+
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
|
|
18
|
+
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
|
|
19
|
+
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
|
|
20
|
+
onSetText: (value: string, fieldKey?: string) => void;
|
|
21
|
+
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
22
|
+
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
23
|
+
onRemoveTextField: (fieldKey: string) => void;
|
|
24
|
+
onAskAgent: () => void;
|
|
25
|
+
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
26
|
+
fontAssets?: ImportedFontAsset[];
|
|
27
|
+
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
28
|
+
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
|
29
|
+
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
|
|
30
|
+
gsapMultipleTimelines?: boolean;
|
|
31
|
+
gsapUnsupportedTimelinePattern?: boolean;
|
|
32
|
+
onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
|
|
33
|
+
onUpdateGsapMeta?: (
|
|
34
|
+
animId: string,
|
|
35
|
+
updates: { duration?: number; ease?: string; position?: number },
|
|
36
|
+
) => void;
|
|
37
|
+
onDeleteGsapAnimation?: (animId: string) => void;
|
|
38
|
+
onAddGsapProperty?: (animId: string, prop: string) => void;
|
|
39
|
+
onRemoveGsapProperty?: (animId: string, prop: string) => void;
|
|
40
|
+
onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void;
|
|
41
|
+
onAddGsapFromProperty?: (animId: string, prop: string) => void;
|
|
42
|
+
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
|
|
43
|
+
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
|
|
44
|
+
onAddKeyframe?: (
|
|
45
|
+
animationId: string,
|
|
46
|
+
percentage: number,
|
|
47
|
+
property: string,
|
|
48
|
+
value: number | string,
|
|
49
|
+
) => void;
|
|
50
|
+
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
|
51
|
+
onConvertToKeyframes?: (animationId: string) => void;
|
|
52
|
+
onCommitAnimatedProperty?: (
|
|
53
|
+
selection: DomEditSelection,
|
|
54
|
+
property: string,
|
|
55
|
+
value: number | string,
|
|
56
|
+
) => Promise<void>;
|
|
57
|
+
onSeekToTime?: (time: number) => void;
|
|
58
|
+
}
|
|
3
59
|
|
|
4
60
|
/* ------------------------------------------------------------------ */
|
|
5
61
|
/* Font types & constants (shared by font and section modules) */
|
|
@@ -399,3 +455,37 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
|
|
|
399
455
|
if (endParen < index) return "";
|
|
400
456
|
return value.slice(index, endParen).trim();
|
|
401
457
|
}
|
|
458
|
+
|
|
459
|
+
// ── Fit to children ──────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
export function computeFitToChildrenSize(
|
|
462
|
+
element: DomEditSelection,
|
|
463
|
+
): { width: number; height: number } | null {
|
|
464
|
+
const el = element.element;
|
|
465
|
+
const win = el.ownerDocument?.defaultView;
|
|
466
|
+
const children = Array.from(el.children).filter((c): c is HTMLElement => c.nodeType === 1);
|
|
467
|
+
if (children.length === 0) return null;
|
|
468
|
+
let minX = Infinity,
|
|
469
|
+
minY = Infinity,
|
|
470
|
+
maxX = -Infinity,
|
|
471
|
+
maxY = -Infinity;
|
|
472
|
+
for (const child of children) {
|
|
473
|
+
if (win) {
|
|
474
|
+
const cs = win.getComputedStyle(child);
|
|
475
|
+
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
|
476
|
+
}
|
|
477
|
+
const r = child.getBoundingClientRect();
|
|
478
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
479
|
+
minX = Math.min(minX, r.left);
|
|
480
|
+
minY = Math.min(minY, r.top);
|
|
481
|
+
maxX = Math.max(maxX, r.right);
|
|
482
|
+
maxY = Math.max(maxY, r.bottom);
|
|
483
|
+
}
|
|
484
|
+
if (!isFinite(minX)) return null;
|
|
485
|
+
const parentRect = el.getBoundingClientRect();
|
|
486
|
+
const scaleX = parentRect.width > 0 ? element.boundingBox.width / parentRect.width : 1;
|
|
487
|
+
const scaleY = parentRect.height > 0 ? element.boundingBox.height / parentRect.height : 1;
|
|
488
|
+
const width = Math.round((maxX - minX) * scaleX);
|
|
489
|
+
const height = Math.round((maxY - minY) * scaleY);
|
|
490
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
491
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Clock } from "../../icons/SystemIcons";
|
|
2
|
+
import type { DomEditSelection } from "./domEditing";
|
|
3
|
+
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
4
|
+
import { MetricField, Section } from "./propertyPanelPrimitives";
|
|
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
|
+
function parseTimingValue(input: string): number | null {
|
|
12
|
+
const cleaned = input.replace(/s$/i, "").trim();
|
|
13
|
+
const parsed = Number.parseFloat(cleaned);
|
|
14
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function TimingSection({
|
|
18
|
+
element,
|
|
19
|
+
onSetAttribute,
|
|
20
|
+
}: {
|
|
21
|
+
element: DomEditSelection;
|
|
22
|
+
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
23
|
+
}) {
|
|
24
|
+
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
|
25
|
+
const duration =
|
|
26
|
+
Number.parseFloat(
|
|
27
|
+
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
|
28
|
+
) || 0;
|
|
29
|
+
const end = start + duration;
|
|
30
|
+
|
|
31
|
+
const commitStart = (nextValue: string) => {
|
|
32
|
+
const parsed = parseTimingValue(nextValue);
|
|
33
|
+
if (parsed == null) return;
|
|
34
|
+
void onSetAttribute("start", parsed.toFixed(2));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const commitDuration = (nextValue: string) => {
|
|
38
|
+
const parsed = parseTimingValue(nextValue);
|
|
39
|
+
if (parsed == null || parsed <= 0) return;
|
|
40
|
+
void onSetAttribute("duration", parsed.toFixed(2));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const commitEnd = (nextValue: string) => {
|
|
44
|
+
const parsed = parseTimingValue(nextValue);
|
|
45
|
+
if (parsed == null || parsed <= start) return;
|
|
46
|
+
void onSetAttribute("duration", (parsed - start).toFixed(2));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Section title="Timing" icon={<Clock size={15} />}>
|
|
51
|
+
<div className={RESPONSIVE_GRID}>
|
|
52
|
+
<MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
|
|
53
|
+
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
|
|
54
|
+
</div>
|
|
55
|
+
<div className="mt-3">
|
|
56
|
+
<MetricField
|
|
57
|
+
label="Duration"
|
|
58
|
+
value={formatTimingValue(duration)}
|
|
59
|
+
onCommit={commitDuration}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</Section>
|
|
63
|
+
);
|
|
64
|
+
}
|