@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.
- package/dist/assets/{hyperframes-player-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
- package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
- package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
- package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
- package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
- package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
- package/dist/chunk-NM7BXNZ7.js.map +1 -0
- package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.html +2 -2
- package/dist/index.js +911 -691
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -2
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/editor/AnimationCard.test.tsx +60 -0
- package/src/components/editor/AnimationCard.tsx +16 -6
- package/src/components/editor/KeyframeEaseList.tsx +4 -1
- package/src/components/editor/LayersPanel.tsx +39 -47
- package/src/components/editor/domEditInlineText.ts +1 -1
- package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
- package/src/components/editor/gsapAnimationHelpers.ts +34 -0
- package/src/components/editor/layerKind.test.ts +38 -0
- package/src/components/editor/layerKind.ts +19 -0
- package/src/components/editor/manualEditsDom.ts +5 -1
- package/src/components/editor/plainTranslate.test.ts +21 -2
- package/src/components/editor/plainTranslate.ts +7 -0
- package/src/components/editor/useDomEditNudge.test.tsx +48 -27
- package/src/components/ui/cn.ts +2 -1
- package/src/contexts/DomEditContext.tsx +3 -1
- package/src/hooks/draggedGsapPosition.test.ts +34 -0
- package/src/hooks/draggedGsapPosition.ts +40 -15
- package/src/hooks/gsapDragCommit.ts +17 -10
- package/src/hooks/gsapDragPositionCommit.ts +8 -9
- package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
- package/src/hooks/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- package/src/hooks/gsapRuntimePatch.ts +4 -0
- package/src/hooks/gsapShared.test.ts +11 -0
- package/src/hooks/gsapShared.ts +1 -1
- package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
- package/src/hooks/useDomGeometryCommits.ts +6 -1
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +3 -0
- package/src/hooks/useEnableKeyframes.ts +32 -37
- package/src/hooks/useGestureCommit.test.tsx +89 -0
- package/src/hooks/useGestureCommit.ts +26 -9
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/icons/SystemIcons.tsx +6 -0
- package/src/layersPanelExport.test.tsx +45 -4
- package/src/utils/elementGsap.ts +6 -5
- package/src/utils/gsapKeyframeEases.ts +2 -0
- package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
- package/src/utils/gsapLiveRetime.ts +39 -31
- package/src/utils/gsapSoftReload.test.ts +98 -14
- package/src/utils/gsapSoftReload.ts +14 -2
- package/src/utils/gsapUndoRestore.test.ts +43 -2
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/softReloadTargets.ts +56 -0
- package/src/utils/studioPendingEdits.test.ts +123 -0
- package/src/utils/studioPendingEdits.ts +39 -3
- package/dist/chunk-5VSXRGIX.js.map +0 -1
- /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
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
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.
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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={
|
|
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.
|
|
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 {
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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-
|
|
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
|
|
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 *
|
|
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
|
-
|
|
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
|
-
{
|
|
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 = () =>
|
|
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
|
|