@hyperframes/studio 0.6.72 → 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.
Files changed (60) hide show
  1. package/dist/assets/index-BcJO6Ej5.js +140 -0
  2. package/dist/assets/index-C2gBZ2km.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/PropertyPanel.tsx +293 -140
  16. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  17. package/src/components/editor/SnapToolbar.tsx +163 -0
  18. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  19. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  20. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  22. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  23. package/src/components/editor/manualEditingAvailability.ts +6 -0
  24. package/src/components/editor/manualEditsDom.ts +56 -2
  25. package/src/components/editor/manualOffsetDrag.ts +19 -3
  26. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  27. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  28. package/src/components/editor/snapEngine.test.ts +657 -0
  29. package/src/components/editor/snapEngine.ts +575 -0
  30. package/src/components/editor/snapTargetCollection.ts +147 -0
  31. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  32. package/src/components/nle/NLELayout.tsx +18 -0
  33. package/src/contexts/DomEditContext.tsx +24 -0
  34. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  37. package/src/hooks/useAppHotkeys.ts +63 -1
  38. package/src/hooks/useDomEditCommits.ts +39 -4
  39. package/src/hooks/useDomEditSession.ts +177 -63
  40. package/src/hooks/useGsapScriptCommits.ts +144 -7
  41. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  42. package/src/hooks/useGsapTweenCache.ts +174 -3
  43. package/src/hooks/useTimelineEditing.ts +93 -0
  44. package/src/icons/SystemIcons.tsx +2 -0
  45. package/src/player/components/ClipContextMenu.tsx +99 -0
  46. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  47. package/src/player/components/Timeline.test.ts +2 -1
  48. package/src/player/components/Timeline.tsx +108 -68
  49. package/src/player/components/TimelineCanvas.tsx +47 -1
  50. package/src/player/components/TimelineClip.tsx +8 -3
  51. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  52. package/src/player/components/timelineDragDrop.ts +103 -0
  53. package/src/player/components/timelineLayout.ts +1 -1
  54. package/src/player/store/playerStore.ts +42 -0
  55. package/src/utils/editHistory.ts +1 -1
  56. package/src/utils/optimisticUpdate.test.ts +53 -0
  57. package/src/utils/optimisticUpdate.ts +18 -0
  58. package/src/utils/studioUiPreferences.ts +17 -0
  59. package/dist/assets/index-CrxThtSJ.css +0 -1
  60. package/dist/assets/index-CveQve6o.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 pos = animation.position;
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.m41 -= offsetX;
237
- m.m42 -= offsetY;
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
- restoreStudioPathOffset(input.element, initialPathOffset);
240
- endStudioManualEditGesture(input.element, gestureToken);
241
- return { ok: false, reason: measured.reason, selection: input.selection };
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
+ }