@hyperframes/studio 0.8.133 → 0.8.135

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 (72) hide show
  1. package/dist/assets/{hyperframes-player-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
  2. package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
  3. package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
  4. package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
  5. package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
  6. package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
  7. package/dist/chunk-NM7BXNZ7.js.map +1 -0
  8. package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +911 -691
  12. package/dist/index.js.map +1 -1
  13. package/dist/player.js +2 -2
  14. package/dist/styles.css +1 -1
  15. package/dist/ui.d.ts +3 -2
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/AnimationCard.test.tsx +60 -0
  19. package/src/components/editor/AnimationCard.tsx +16 -6
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -1
  21. package/src/components/editor/LayersPanel.tsx +39 -47
  22. package/src/components/editor/domEditInlineText.ts +1 -1
  23. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +34 -0
  25. package/src/components/editor/layerKind.test.ts +38 -0
  26. package/src/components/editor/layerKind.ts +19 -0
  27. package/src/components/editor/manualEditsDom.ts +5 -1
  28. package/src/components/editor/plainTranslate.test.ts +21 -2
  29. package/src/components/editor/plainTranslate.ts +7 -0
  30. package/src/components/editor/useDomEditNudge.test.tsx +48 -27
  31. package/src/components/ui/cn.ts +2 -1
  32. package/src/contexts/DomEditContext.tsx +3 -1
  33. package/src/hooks/draggedGsapPosition.test.ts +34 -0
  34. package/src/hooks/draggedGsapPosition.ts +40 -15
  35. package/src/hooks/gsapDragCommit.ts +17 -10
  36. package/src/hooks/gsapDragPositionCommit.ts +8 -9
  37. package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
  38. package/src/hooks/gsapResizeIntercept.test.ts +72 -1
  39. package/src/hooks/gsapResizeIntercept.ts +84 -17
  40. package/src/hooks/gsapRuntimeBridge.ts +4 -1
  41. package/src/hooks/gsapRuntimePatch.ts +4 -0
  42. package/src/hooks/gsapShared.test.ts +11 -0
  43. package/src/hooks/gsapShared.ts +1 -1
  44. package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
  45. package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
  46. package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
  47. package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
  48. package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
  49. package/src/hooks/useDomGeometryCommits.ts +6 -1
  50. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  51. package/src/hooks/useEditHistoryActions.ts +3 -0
  52. package/src/hooks/useEnableKeyframes.ts +32 -37
  53. package/src/hooks/useGestureCommit.test.tsx +89 -0
  54. package/src/hooks/useGestureCommit.ts +26 -9
  55. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  56. package/src/hooks/useGsapAwareEditing.ts +10 -2
  57. package/src/icons/SystemIcons.tsx +6 -0
  58. package/src/layersPanelExport.test.tsx +45 -4
  59. package/src/utils/elementGsap.ts +6 -5
  60. package/src/utils/gsapKeyframeEases.ts +2 -0
  61. package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
  62. package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
  63. package/src/utils/gsapLiveRetime.ts +39 -31
  64. package/src/utils/gsapSoftReload.test.ts +98 -14
  65. package/src/utils/gsapSoftReload.ts +14 -2
  66. package/src/utils/gsapUndoRestore.test.ts +43 -2
  67. package/src/utils/gsapUndoRestore.ts +4 -2
  68. package/src/utils/softReloadTargets.ts +56 -0
  69. package/src/utils/studioPendingEdits.test.ts +123 -0
  70. package/src/utils/studioPendingEdits.ts +39 -3
  71. package/dist/chunk-5VSXRGIX.js.map +0 -1
  72. /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
package/dist/ui.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ButtonHTMLAttributes, ReactNode, ElementType, ComponentPropsWithoutRef, ReactElement } from 'react';
3
3
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
4
  import { Tabs as Tabs$1 } from '@base-ui/react/tabs';
5
- import * as node_modules_cn_dist_types from 'node_modules/cn/dist/types';
5
+ import { CnFunction } from 'cn';
6
6
  import { ContextMenu as ContextMenu$1 } from '@base-ui/react/context-menu';
7
7
  import { Menu as Menu$1 } from '@base-ui/react/menu';
8
8
  import { Popover as Popover$1 } from '@base-ui/react/popover';
@@ -101,7 +101,8 @@ declare function Tooltip({ label, children, delay, side }: TooltipProps): react_
101
101
  * Studio's tailwind-merge config: teaches it the two scales `theme.css`
102
102
  * invents (`text-step-*`, `ctl`/`ctl-sm`/`ctl-lg`) so overrides replace, not stack.
103
103
  */
104
- declare const cn: node_modules_cn_dist_types.CnFunction;
104
+
105
+ declare const cn: CnFunction;
105
106
 
106
107
  /**
107
108
  * Base UI lets `className` be a function of the part's state. Studio's parts
package/dist/ui.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  fieldBase,
31
31
  fieldText,
32
32
  popupSurface
33
- } from "./chunk-5VSXRGIX.js";
33
+ } from "./chunk-NM7BXNZ7.js";
34
34
  import "./chunk-PZ5AY32C.js";
35
35
  export {
36
36
  Button,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.133",
3
+ "version": "0.8.135",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.133",
64
- "@hyperframes/sdk": "0.8.133",
65
- "@hyperframes/parsers": "0.8.133",
66
- "@hyperframes/player": "0.8.133",
67
- "@hyperframes/shader-transitions": "0.8.133",
68
- "@hyperframes/studio-server": "0.8.133"
63
+ "@hyperframes/core": "0.8.135",
64
+ "@hyperframes/player": "0.8.135",
65
+ "@hyperframes/shader-transitions": "0.8.135",
66
+ "@hyperframes/parsers": "0.8.135",
67
+ "@hyperframes/studio-server": "0.8.135",
68
+ "@hyperframes/sdk": "0.8.135"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.133"
84
+ "@hyperframes/producer": "0.8.135"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -149,6 +149,66 @@ function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void
149
149
  }
150
150
 
151
151
  describe("AnimationCard", () => {
152
+ it("heads a keyframed card with the segment ease and names the run ease on its own line", () => {
153
+ const view = renderFocusCard(null, vi.fn(), true);
154
+ expect(view.host.querySelector("[data-card-ease]")?.textContent).toBe("power1.inOut");
155
+ expect(view.host.querySelector("[data-card-run-ease]")?.textContent).toBe(
156
+ "Run ease: power1.out",
157
+ );
158
+ act(() => view.root.unmount());
159
+ });
160
+
161
+ it("heads a keyframed card Mixed when its segments differ, with no run line for a linear run", () => {
162
+ const animation: GsapAnimation = {
163
+ ...ANIMATION,
164
+ ease: undefined,
165
+ keyframes: {
166
+ format: "percentage",
167
+ keyframes: [
168
+ { percentage: 0, properties: { x: 0 } },
169
+ { percentage: 50, properties: { x: 100 }, ease: "expo.in" },
170
+ { percentage: 100, properties: { x: 200 } },
171
+ ],
172
+ },
173
+ };
174
+ const view = renderFocusCard(null, vi.fn(), true, animation);
175
+ expect(view.host.querySelector("[data-card-ease]")?.textContent).toBe("Mixed");
176
+ expect(view.host.querySelector("[data-card-run-ease]")).toBeNull();
177
+ act(() => view.root.unmount());
178
+ });
179
+
180
+ it("labels segments with rounded percentages, never float noise or exponents", () => {
181
+ const animation: GsapAnimation = {
182
+ ...ANIMATION,
183
+ keyframes: {
184
+ format: "percentage",
185
+ keyframes: [
186
+ { percentage: 0, properties: { x: 0 } },
187
+ { percentage: 1.2860082304526747e-7, properties: { x: 1 } },
188
+ { percentage: 0.13484773662551455, properties: { x: 2 } },
189
+ { percentage: 100, properties: { x: 200 } },
190
+ ],
191
+ },
192
+ };
193
+ const view = renderFocusCard(null, vi.fn(), true, animation);
194
+ const labels = [
195
+ ...view.host.querySelectorAll("[data-ease-segment-pct] button span:first-child"),
196
+ ];
197
+ expect(labels.map((label) => label.textContent)).toEqual([
198
+ "0% → <0.001%",
199
+ "<0.001% → 0.135%",
200
+ "0.135% → 100%",
201
+ ]);
202
+ act(() => view.root.unmount());
203
+ });
204
+
205
+ it("labels an unset segment with the ease GSAP plays, not the tween's run ease", () => {
206
+ const view = renderFocusCard(null, vi.fn(), true);
207
+ const labels = [...view.host.querySelectorAll("[data-ease-segment-pct] button span.ml-auto")];
208
+ expect(labels.map((label) => label.textContent)).toEqual(["power1.inOut", "power1.inOut"]);
209
+ act(() => view.root.unmount());
210
+ });
211
+
152
212
  it("scrolls a focused segment into view but not a manually toggled segment", () => {
153
213
  const originalScrollIntoView = Object.getOwnPropertyDescriptor(
154
214
  HTMLElement.prototype,
@@ -6,7 +6,8 @@ import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
6
6
  import { MetricField, SelectField } from "./propertyPanelPrimitives";
7
7
  import { controlPointsForGsapEase } from "./studioMotion";
8
8
  import { EASE_LABELS, METHOD_LABELS, METHOD_TOOLTIPS, PROP_LABELS } from "./gsapAnimationConstants";
9
- import { buildTweenSummary } from "./gsapAnimationHelpers";
9
+ import { buildTweenSummary, uniformSegmentEase } from "./gsapAnimationHelpers";
10
+ import { keyframedTweenEases } from "../../utils/gsapKeyframeEases";
10
11
  import { EaseCurveSection } from "./EaseCurveSection";
11
12
  import { ArcPathControls } from "./ArcPathControls";
12
13
  import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
@@ -31,6 +32,9 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
31
32
  onFocusSegmentConsumed?: () => void;
32
33
  }
33
34
 
35
+ const easeDisplayName = (ease: string) =>
36
+ ease.startsWith("custom(") ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
37
+
34
38
  // fallow-ignore-next-line complexity
35
39
  export const AnimationCard = memo(function AnimationCard({
36
40
  animation,
@@ -152,9 +156,9 @@ export const AnimationCard = memo(function AnimationCard({
152
156
  const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
153
157
  const easeName =
154
158
  (animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
155
- const easeLabel = easeName.startsWith("custom(")
156
- ? "Custom curve"
157
- : (EASE_LABELS[easeName] ?? easeName);
159
+ const headerEase = animation.keyframes ? uniformSegmentEase(animation) : easeName;
160
+ const easeLabel = headerEase === null ? "Mixed" : easeDisplayName(headerEase);
161
+ const runEase = animation.keyframes ? keyframedTweenEases(animation).run : undefined;
158
162
  const endTime =
159
163
  typeof animation.position === "number"
160
164
  ? animation.position + (animation.duration ?? 0)
@@ -217,7 +221,8 @@ export const AnimationCard = memo(function AnimationCard({
217
221
  </span>
218
222
  <span
219
223
  className={`ml-auto text-[10px] ${flat ? "text-panel-text-3" : "text-neutral-500"}`}
220
- title={easeName}
224
+ title={headerEase ?? "Mixed"}
225
+ data-card-ease
221
226
  >
222
227
  {easeLabel}
223
228
  </span>
@@ -254,6 +259,11 @@ export const AnimationCard = memo(function AnimationCard({
254
259
  Keyframed — click a segment below to edit its curve
255
260
  </p>
256
261
  )}
262
+ {runEase && runEase !== "none" && (
263
+ <p className="mt-1 text-[9px] text-neutral-500" data-card-run-ease>
264
+ Run ease: {easeDisplayName(runEase)}
265
+ </p>
266
+ )}
257
267
  </div>
258
268
  <button
259
269
  type="button"
@@ -296,7 +306,7 @@ export const AnimationCard = memo(function AnimationCard({
296
306
  {animation.keyframes && onUpdateKeyframeEase ? (
297
307
  <KeyframeEaseList
298
308
  keyframes={animation.keyframes.keyframes}
299
- globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
309
+ globalEase={keyframedTweenEases(animation).segment({})}
300
310
  expandedPct={expandedKfPct}
301
311
  collidingAnimationTargets={focusedCollidingAnimationTargets}
302
312
  onToggle={(pct) => {
@@ -1,6 +1,7 @@
1
1
  import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
2
2
  import { EASE_LABELS } from "./gsapAnimationConstants";
3
3
  import { EaseCurveSection } from "./EaseCurveSection";
4
+ import { roundTo3 } from "../../utils/rounding";
4
5
  import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
5
6
 
6
7
  // The full GSAP easing vocabulary offered by the "Set all…" bulk control —
@@ -41,6 +42,8 @@ const APPLY_ALL_EASES = [
41
42
  "bounce.inOut",
42
43
  ] as const;
43
44
 
45
+ const percentLabel = (pct: number) => (pct > 0 && pct < 0.001 ? "<0.001" : String(roundTo3(pct)));
46
+
44
47
  export function KeyframeEaseList({
45
48
  keyframes,
46
49
  globalEase,
@@ -91,7 +94,7 @@ export function KeyframeEaseList({
91
94
  if (i === 0) return null;
92
95
  const segEase = kf.ease ?? globalEase;
93
96
  const isExpanded = expandedPct === kf.percentage;
94
- const label = `${keyframes[i - 1].percentage}% → ${kf.percentage}%`;
97
+ const label = `${percentLabel(keyframes[i - 1].percentage)}% → ${percentLabel(kf.percentage)}%`;
95
98
  const easeLabel = segEase.startsWith("custom(")
96
99
  ? "Custom"
97
100
  : (EASE_LABELS[segEase] ?? segEase);
@@ -20,7 +20,17 @@ import {
20
20
  findMatchingTimelineElementId,
21
21
  resolveTimelineSelectionSeekTime,
22
22
  } from "../../utils/studioHelpers";
23
- import { Layers } from "../../icons/SystemIcons";
23
+ import {
24
+ Film,
25
+ Folder,
26
+ ImageIcon,
27
+ Layers,
28
+ Music,
29
+ Square,
30
+ Type,
31
+ Vector,
32
+ } from "../../icons/SystemIcons";
33
+ import { isCompositionHost, layerKindOf } from "./layerKind";
24
34
  import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
25
35
  import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort";
26
36
  import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
@@ -34,38 +44,18 @@ import { useLayerRevealOverride } from "./useLayerRevealOverride";
34
44
  // Rows this panel renders before it stops. A display budget, not a document limit.
35
45
  const LAYERS_PANEL_MAX_ROWS = 80;
36
46
 
37
- const TAG_ICONS: Record<string, string> = {
38
- video: "Vi",
39
- audio: "Au",
40
- img: "Im",
41
- svg: "Sv",
42
- canvas: "Cn",
43
- div: "Di",
44
- section: "Se",
45
- span: "Sp",
46
- p: "P",
47
- h1: "H1",
48
- h2: "H2",
49
- h3: "H3",
50
- h4: "H4",
51
- h5: "H5",
52
- h6: "H6",
53
- a: "A",
54
- button: "Bt",
55
- ul: "Ul",
56
- ol: "Ol",
57
- li: "Li",
58
- style: "St",
59
- template: "Te",
60
- };
61
-
62
- function getTagBadge(tagName: string): string {
63
- return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase();
64
- }
47
+ const [CARET, ICON, GAP] = [16, 20, 6];
48
+ const CHILD_INDENT = CARET + GAP + ICON + GAP;
65
49
 
66
- function isCompositionHost(el: HTMLElement): boolean {
67
- return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
68
- }
50
+ const KIND_ICON = {
51
+ image: ImageIcon,
52
+ video: Film,
53
+ audio: Music,
54
+ vector: Vector,
55
+ group: Folder,
56
+ text: Type,
57
+ shape: Square,
58
+ } as const;
69
59
 
70
60
  /**
71
61
  * A trailing-rAF + cooldown throttle: `invoke` runs `run` at most once per
@@ -452,7 +442,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
452
442
  <button
453
443
  type="button"
454
444
  onClick={() => setActiveGroupElement(null)}
455
- className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
445
+ className="mx-1 flex w-[calc(100%-0.5rem)] items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
456
446
  >
457
447
  <span aria-hidden="true">←</span>
458
448
  <span className="truncate">
@@ -467,6 +457,14 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
467
457
  const isCollapsed = collapsed[layer.key] ?? false;
468
458
  const hasChildren = layer.childCount > 0;
469
459
  const isCompHost = isCompositionHost(layer.element);
460
+ const kind = layerKindOf(layer.element, hasChildren);
461
+ const KindIcon = KIND_ICON[kind];
462
+ const kindName = kind[0].toUpperCase() + kind.slice(1);
463
+ const hint = draggable
464
+ ? layer.element.hasAttribute("data-hf-group")
465
+ ? "Double-click to enter group"
466
+ : undefined
467
+ : "This layer can't be reordered";
470
468
 
471
469
  return (
472
470
  <div
@@ -484,21 +482,15 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
484
482
  handleSelectLayer(layer);
485
483
  }
486
484
  }}
487
- className={`group flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors ${
485
+ className={`group mx-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors ${
488
486
  isDragged
489
487
  ? "opacity-40"
490
488
  : selected
491
489
  ? "bg-panel-accent/14 text-accent-ink"
492
490
  : "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
493
491
  } ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
494
- style={{ paddingLeft: 8 + layer.depth * 16 }}
495
- title={
496
- draggable
497
- ? layer.element.hasAttribute("data-hf-group")
498
- ? "Double-click to enter group"
499
- : undefined
500
- : "This layer can't be reordered"
501
- }
492
+ style={{ paddingLeft: 8 + layer.depth * CHILD_INDENT }}
493
+ title={hint}
502
494
  >
503
495
  {hasChildren ? (
504
496
  <button
@@ -518,11 +510,11 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
518
510
  <path d="M2 1l4 3-4 3z" />
519
511
  </svg>
520
512
  </button>
521
- ) : (
522
- <span className="w-4 shrink-0" />
523
- )}
513
+ ) : null}
524
514
  <span
525
- className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
515
+ data-layer-kind={kind}
516
+ title={hint ? `${kindName} · ${hint}` : kindName}
517
+ className={`flex h-5 w-5 shrink-0 items-center justify-center rounded ${
526
518
  selected
527
519
  ? "bg-panel-accent/18 text-accent-ink"
528
520
  : isCompHost
@@ -530,7 +522,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
530
522
  : "bg-panel-hover text-panel-text-4"
531
523
  }`}
532
524
  >
533
- {getTagBadge(layer.tagName)}
525
+ <KindIcon size={12} />
534
526
  </span>
535
527
  <span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
536
528
  {hasChildren && (
@@ -53,7 +53,7 @@ export function canEditElementTextInline(element: HTMLElement | null): boolean {
53
53
  return (element.textContent ?? "").trim().length > 0;
54
54
  }
55
55
 
56
- function hasOnlyFormattingChildren(element: HTMLElement): boolean {
56
+ export function hasOnlyFormattingChildren(element: HTMLElement): boolean {
57
57
  for (const child of Array.from(element.children)) {
58
58
  if (!isRichTextFormattingTag(child.tagName)) return false;
59
59
  if (!isHtmlFromAnyWindow(child)) return false;
@@ -52,6 +52,35 @@ describe("buildTweenSummary", () => {
52
52
  expect(s).toContain("opacity");
53
53
  });
54
54
 
55
+ it("describes a keyframed tween by its keyframes and the segment ease it plays", () => {
56
+ const keyframes = {
57
+ easeEach: "power3.out",
58
+ keyframes: [
59
+ { percentage: 0, properties: { x: 0 } },
60
+ { percentage: 50, properties: { x: 300, opacity: 0.5 } },
61
+ { percentage: 100, properties: { x: 600 } },
62
+ ],
63
+ };
64
+ expect(buildTweenSummary(anim({ ease: undefined, keyframes } as Partial<GsapAnimation>))).toBe(
65
+ "Starting at 0s, over 1s, animate #box's move x, opacity through 3 keyframes, each segment eased power3.out.",
66
+ );
67
+ });
68
+
69
+ it("names a keyframed tween's run ease and mixed segment eases", () => {
70
+ const keyframes = {
71
+ keyframes: [
72
+ { percentage: 0, properties: { x: 0 } },
73
+ { percentage: 50, properties: { x: 300 }, ease: "expo.in" },
74
+ { percentage: 100, properties: { x: 600 } },
75
+ ],
76
+ };
77
+ expect(
78
+ buildTweenSummary(anim({ ease: "sine.inOut", keyframes } as Partial<GsapAnimation>)),
79
+ ).toBe(
80
+ "Starting at 0s, over 1s, animate #box's move x through 3 keyframes, with per-keyframe easing, across a sine.inOut run.",
81
+ );
82
+ });
83
+
55
84
  it("describes a set tween", () => {
56
85
  const s = buildTweenSummary(anim({ method: "set", properties: { opacity: 0 } }));
57
86
  expect(s).toMatch(/^At 0s, instantly set/);
@@ -1,5 +1,6 @@
1
1
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
2
2
  import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
3
+ import { keyframedTweenEases } from "../../utils/gsapKeyframeEases";
3
4
 
4
5
  function formatPropValue(prop: string, v: number | string): string {
5
6
  const unit = PROP_UNITS[prop] ?? "";
@@ -7,6 +8,38 @@ function formatPropValue(prop: string, v: number | string): string {
7
8
  return `${v}${unit}`;
8
9
  }
9
10
 
11
+ const easeLabel = (ease: string) =>
12
+ ease.startsWith("custom(") ? "custom" : (EASE_LABELS[ease] ?? ease);
13
+
14
+ export function uniformSegmentEase(animation: GsapAnimation): string | null {
15
+ const eases = keyframedTweenEases(animation);
16
+ const steps = animation.keyframes?.keyframes ?? [];
17
+ const segmentEases = new Set(steps.slice(1).map((kf) => eases.segment(kf)));
18
+ if (segmentEases.size === 0) return eases.segment({});
19
+ return segmentEases.size === 1 ? [...segmentEases][0]! : null;
20
+ }
21
+
22
+ function keyframedTweenSummary(
23
+ animation: GsapAnimation,
24
+ pos: number | string,
25
+ dur: number,
26
+ ): string {
27
+ const steps = animation.keyframes?.keyframes ?? [];
28
+ const props = [...new Set(steps.flatMap((kf) => Object.keys(kf.properties)))];
29
+ const propText = props.map((p) => (PROP_LABELS[p] ?? p).toLowerCase()).join(", ");
30
+ const segmentEase = uniformSegmentEase(animation);
31
+ const segmentText =
32
+ steps.length < 2
33
+ ? ""
34
+ : segmentEase
35
+ ? `, each segment eased ${easeLabel(segmentEase)}`
36
+ : ", with per-keyframe easing";
37
+ const run = keyframedTweenEases(animation).run;
38
+ const runText = run && run !== "none" ? `, across a ${easeLabel(run)} run` : "";
39
+ const count = `${steps.length} keyframe${steps.length === 1 ? "" : "s"}`;
40
+ return `Starting at ${pos}s, over ${dur}s, animate ${animation.targetSelector}'s ${propText || "no properties yet"} through ${count}${segmentText}${runText}.`;
41
+ }
42
+
10
43
  // fallow-ignore-next-line complexity
11
44
  export function buildTweenSummary(animation: GsapAnimation): string {
12
45
  const easeName = animation.ease ?? "none";
@@ -16,6 +49,7 @@ export function buildTweenSummary(animation: GsapAnimation): string {
16
49
  const dur = animation.duration ?? 0;
17
50
  const rawPos = animation.position;
18
51
  const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos;
52
+ if (animation.keyframes) return keyframedTweenSummary(animation, pos, dur);
19
53
  const propDescs = props.map(([p, v]) => {
20
54
  const label = (PROP_LABELS[p] ?? p).toLowerCase();
21
55
  return `${label} to ${formatPropValue(p, v)}`;
@@ -0,0 +1,38 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { layerKindOf } from "./layerKind";
4
+
5
+ const el = (html: string) => {
6
+ const host = document.createElement("div");
7
+ host.innerHTML = html;
8
+ return host.firstElementChild!;
9
+ };
10
+
11
+ describe("layerKindOf", () => {
12
+ it.each([
13
+ ['<img src="a.png">', "image"],
14
+ ['<picture><img src="a.png"></picture>', "image"],
15
+ ['<video src="a.mp4"></video>', "video"],
16
+ ['<audio src="a.mp3"></audio>', "audio"],
17
+ ['<svg viewBox="0 0 10 10"><path d="M0 0h10"/></svg>', "vector"],
18
+ ['<div data-hf-group="Intro"><div></div></div>', "group"],
19
+ ['<div data-composition-src="intro.html"></div>', "group"],
20
+ ['<div data-composition-file="intro.html"></div>', "group"],
21
+ ["<h1>Title</h1>", "text"],
22
+ ["<div>Plain words</div>", "text"],
23
+ ["<p>Rich <b>bold</b> and <em>soft</em> words</p>", "text"],
24
+ ['<div style="background:red"></div>', "shape"],
25
+ ["<div><img></div>", "shape"],
26
+ ["<div>Caption<img></div>", "shape"],
27
+ ["<p>H<sub>2</sub>O</p>", "shape"],
28
+ ["<div> </div>", "shape"],
29
+ ["<canvas></canvas>", "shape"],
30
+ ])("%s is %s", (html, kind) => {
31
+ expect(layerKindOf(el(html), false)).toBe(kind);
32
+ });
33
+ });
34
+
35
+ it("a row with layer children is a group unless it is text", () => {
36
+ expect(layerKindOf(el("<div><div></div></div>"), true)).toBe("group");
37
+ expect(layerKindOf(el('<p>Hi <span class="x">there</span></p>'), true)).toBe("text");
38
+ });
@@ -0,0 +1,19 @@
1
+ import { hasOnlyFormattingChildren } from "./domEditInlineText";
2
+
3
+ export type LayerKind = "image" | "video" | "audio" | "vector" | "group" | "text" | "shape";
4
+
5
+ export function isCompositionHost(el: Element): boolean {
6
+ return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
7
+ }
8
+
9
+ /** What a Layers row tells a person an element is: its media tag, a group, text, or else a shape. */
10
+ export function layerKindOf(el: Element, hasChildren: boolean): LayerKind {
11
+ const tag = el.tagName.toLowerCase();
12
+ if (tag === "img" || tag === "picture") return "image";
13
+ if (tag === "video") return "video";
14
+ if (tag === "audio") return "audio";
15
+ if (tag === "svg") return "vector";
16
+ if (el.hasAttribute("data-hf-group") || isCompositionHost(el)) return "group";
17
+ if (hasOnlyFormattingChildren(el as HTMLElement) && el.textContent?.trim()) return "text";
18
+ return hasChildren ? "group" : "shape";
19
+ }
@@ -91,10 +91,14 @@ export function studioManualEditSavesIn(doc: Document): number {
91
91
  return gestureSaves.get(doc) ?? 0;
92
92
  }
93
93
 
94
+ export function countStudioPreviewChange(doc: Document): void {
95
+ gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
96
+ }
97
+
94
98
  /** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
95
99
  export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
96
100
  const doc = element.ownerDocument;
97
- const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
101
+ const count = () => countStudioPreviewChange(doc);
98
102
  count();
99
103
  const result = save();
100
104
  void Promise.resolve(result).then(count, count);
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
- import { describe, expect, it } from "vitest";
3
- import { readTranslatePx } from "./plainTranslate";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { readTranslatePx, readTranslatePxLeavingPercent } from "./plainTranslate";
4
4
 
5
5
  function box(style: string): HTMLElement {
6
6
  const element = document.createElement("div");
@@ -76,3 +76,22 @@ describe("readTranslatePx", () => {
76
76
  },
77
77
  );
78
78
  });
79
+
80
+ describe("readTranslatePxLeavingPercent", () => {
81
+ it("seeds a first tween from the px a plain move left in CSS translate, leaving % to xPercent", () => {
82
+ const el = document.body.appendChild(document.createElement("div"));
83
+ // happy-dom computes no `translate`; Chromium resolves it to these strings.
84
+ const computed = (translate: string) =>
85
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({ translate } as CSSStyleDeclaration);
86
+ computed("299.893px 203.262px");
87
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 299.893, y: 203.262 });
88
+ computed("calc(-50% + 120px) -50%");
89
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 120, y: 0 });
90
+ computed("calc(-50% - 120px) calc(min(10px, 20px) + 5px)");
91
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: -120, y: 15 });
92
+ computed("none");
93
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 0, y: 0 });
94
+ vi.restoreAllMocks();
95
+ el.remove();
96
+ });
97
+ });
@@ -100,6 +100,13 @@ export function readTranslatePx(el: HTMLElement): Point {
100
100
  };
101
101
  }
102
102
 
103
+ export function readTranslatePxLeavingPercent(el: HTMLElement): Point {
104
+ const value = el.ownerDocument.defaultView?.getComputedStyle(el).translate || "none";
105
+ const [x = "0px", y = "0px"] = value === "none" ? [] : splitTopLevelWhitespace(value);
106
+ const px = (part: string) => roundTo3(evaluateLength(part, 0)) || 0;
107
+ return { x: px(x), y: px(y) };
108
+ }
109
+
103
110
  export const UNREADABLE_TRANSLATE =
104
111
  "Studio can't read this layer's translate. Move it in the Code tab.";
105
112