@hyperframes/studio 0.8.134 → 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-Bro1gj0Z.js → hyperframes-player-DFv9heE6.js} +1 -1
- package/dist/assets/{index-DnVGIbEC.js → index-Buy06mZy.js} +1 -1
- package/dist/assets/{index-BwNDTmAD.js → index-CTp4loy9.js} +1 -1
- package/dist/assets/{index-wnXyQjW-.js → index-N_4Ij4X6.js} +185 -184
- 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 +227 -88
- 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/useDomEditNudge.test.tsx +48 -27
- package/src/components/ui/cn.ts +2 -1
- package/src/contexts/DomEditContext.tsx +3 -1
- package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
- 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/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/useGestureCommit.test.tsx +89 -0
- package/src/hooks/useGestureCommit.ts +26 -9
- package/src/icons/SystemIcons.tsx +6 -0
- package/src/layersPanelExport.test.tsx +45 -4
- 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 +21 -2
- package/src/utils/softReloadTargets.ts +56 -0
- package/src/utils/studioPendingEdits.test.ts +123 -0
- package/src/utils/studioPendingEdits.ts +27 -1
- 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/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/parsers": "0.8.
|
|
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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
2
3
|
// @vitest-environment happy-dom
|
|
3
4
|
import React, { act } from "react";
|
|
@@ -7,7 +8,11 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
|
|
|
7
8
|
import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
|
|
8
9
|
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
|
|
9
10
|
import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
flushStudioPendingEdits,
|
|
13
|
+
hasStudioPendingEdits,
|
|
14
|
+
trackedStudioEdit,
|
|
15
|
+
} from "../../utils/studioPendingEdits";
|
|
11
16
|
import type { DomEditSelection } from "./domEditing";
|
|
12
17
|
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
13
18
|
|
|
@@ -392,23 +397,32 @@ describe("useDomEditNudge pauses playback before it snapshots the timelines", ()
|
|
|
392
397
|
});
|
|
393
398
|
});
|
|
394
399
|
|
|
400
|
+
/** A burst-ready Harness on a fresh element, under fake timers. */
|
|
401
|
+
function mountBurstHarness(
|
|
402
|
+
id: string,
|
|
403
|
+
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"],
|
|
404
|
+
) {
|
|
405
|
+
__resetForTests();
|
|
406
|
+
vi.useFakeTimers();
|
|
407
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
408
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
409
|
+
element.id = id;
|
|
410
|
+
act(() => {
|
|
411
|
+
root.render(
|
|
412
|
+
React.createElement(Harness, {
|
|
413
|
+
selection: makeSelection("Dot", element),
|
|
414
|
+
onPathOffsetCommit,
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
return root;
|
|
419
|
+
}
|
|
420
|
+
|
|
395
421
|
describe("useDomEditNudge — undo right after a burst", () => {
|
|
396
422
|
it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
|
|
397
|
-
__resetForTests();
|
|
398
|
-
vi.useFakeTimers();
|
|
399
|
-
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
400
|
-
const element = document.body.appendChild(document.createElement("div"));
|
|
401
|
-
element.id = "dot-undo";
|
|
402
423
|
let saved!: () => void;
|
|
403
424
|
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
|
|
404
|
-
|
|
405
|
-
root.render(
|
|
406
|
-
React.createElement(Harness, {
|
|
407
|
-
selection: makeSelection("Dot", element),
|
|
408
|
-
onPathOffsetCommit: commit,
|
|
409
|
-
}),
|
|
410
|
-
);
|
|
411
|
-
});
|
|
425
|
+
const root = mountBurstHarness("dot-undo", commit);
|
|
412
426
|
act(() => dispatchArrowRight());
|
|
413
427
|
|
|
414
428
|
let drained = false;
|
|
@@ -424,21 +438,9 @@ describe("useDomEditNudge — undo right after a burst", () => {
|
|
|
424
438
|
});
|
|
425
439
|
|
|
426
440
|
it("counts the burst as a pending edit from its first key until its save lands", async () => {
|
|
427
|
-
__resetForTests();
|
|
428
|
-
vi.useFakeTimers();
|
|
429
|
-
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
430
|
-
const element = document.body.appendChild(document.createElement("div"));
|
|
431
|
-
element.id = "dot-pending";
|
|
432
441
|
let saved!: () => void;
|
|
433
442
|
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
|
|
434
|
-
|
|
435
|
-
root.render(
|
|
436
|
-
React.createElement(Harness, {
|
|
437
|
-
selection: makeSelection("Dot", element),
|
|
438
|
-
onPathOffsetCommit: commit,
|
|
439
|
-
}),
|
|
440
|
-
);
|
|
441
|
-
});
|
|
443
|
+
const root = mountBurstHarness("dot-pending", commit);
|
|
442
444
|
expect(hasStudioPendingEdits()).toBe(false);
|
|
443
445
|
act(() => dispatchArrowRight());
|
|
444
446
|
expect(hasStudioPendingEdits()).toBe(true);
|
|
@@ -452,6 +454,25 @@ describe("useDomEditNudge — undo right after a burst", () => {
|
|
|
452
454
|
});
|
|
453
455
|
});
|
|
454
456
|
|
|
457
|
+
describe("useDomEditNudge — a Design-panel edit during a burst", () => {
|
|
458
|
+
it("commits the burst before the panel edit, so one undo takes back the panel edit", async () => {
|
|
459
|
+
const order: string[] = [];
|
|
460
|
+
const root = mountBurstHarness("dot-panel", async () => void order.push("nudge"));
|
|
461
|
+
try {
|
|
462
|
+
act(() => dispatchArrowRight());
|
|
463
|
+
const panelEdit = trackedStudioEdit(async () => void order.push("width"), {
|
|
464
|
+
afterOlderSaves: true,
|
|
465
|
+
})();
|
|
466
|
+
vi.useRealTimers();
|
|
467
|
+
await panelEdit;
|
|
468
|
+
expect(order).toEqual(["nudge", "width"]);
|
|
469
|
+
} finally {
|
|
470
|
+
vi.useRealTimers();
|
|
471
|
+
act(() => root.unmount());
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
455
476
|
describe("useDomEditNudge — a commit that throws", () => {
|
|
456
477
|
it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
|
|
457
478
|
__resetForTests();
|
package/src/components/ui/cn.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* invents (`text-step-*`, `ctl`/`ctl-sm`/`ctl-lg`) so overrides replace, not stack.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { CnFunction } from "cn";
|
|
6
7
|
import { createCn } from "cn/config";
|
|
7
8
|
|
|
8
9
|
/** `text-step-11`, `text-step-9`, … — the type scale from `theme.css`. */
|
|
@@ -11,7 +12,7 @@ const isTypeStep = (value: string) => /^step-\d+$/.test(value);
|
|
|
11
12
|
/** `ctl`, `ctl-sm`, `ctl-lg` — the control heights from `theme.css`. */
|
|
12
13
|
const isControlSize = (value: string) => /^ctl(-sm|-lg)?$/.test(value);
|
|
13
14
|
|
|
14
|
-
export const cn = createCn({
|
|
15
|
+
export const cn: CnFunction = createCn({
|
|
15
16
|
extend: {
|
|
16
17
|
classGroups: {
|
|
17
18
|
"font-size": [{ text: [isTypeStep] }],
|
|
@@ -107,7 +107,9 @@ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
|
|
|
107
107
|
const tracked: Record<string, unknown> = { ...actions };
|
|
108
108
|
for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
|
|
109
109
|
if (!SAVES_AN_EDIT[key]) continue;
|
|
110
|
-
tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown
|
|
110
|
+
tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown, {
|
|
111
|
+
afterOlderSaves: true,
|
|
112
|
+
});
|
|
111
113
|
}
|
|
112
114
|
return tracked as unknown as DomEditActionsValue;
|
|
113
115
|
}
|