@hyperframes/studio 0.8.125 → 0.8.127
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-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
- package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
- package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
- package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +5368 -4986
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
- package/src/components/editor/MotionPathNode.tsx +56 -0
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +167 -33
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +49 -86
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- package/src/components/editor/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +2 -3
- package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +61 -1
- package/src/components/editor/motionPathGeometry.ts +50 -10
- package/src/components/editor/motionPathHome.ts +75 -0
- package/src/components/editor/motionPathLayerNode.test.ts +43 -35
- package/src/components/editor/motionPathLayerNode.ts +20 -16
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
- package/src/components/editor/studioMotion.ts +7 -19
- package/src/components/editor/useMotionPathData.test.tsx +236 -0
- package/src/components/editor/useMotionPathData.ts +23 -61
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
- package/src/hooks/gsapDragCommit.ts +33 -1
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +39 -2
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
- package/src/hooks/gsapShared.test.ts +1 -0
- package/src/hooks/gsapShared.ts +8 -3
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
- package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
- package/src/hooks/gsapValueAtPlayhead.ts +46 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
- package/src/hooks/useThumbnailStripSize.ts +206 -17
- package/src/noWindowBoundDomChecks.test.ts +31 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -6
- package/src/player/components/ImageThumbnail.tsx +11 -5
- package/src/player/components/ThumbnailTiles.test.tsx +30 -0
- package/src/player/components/ThumbnailTiles.tsx +41 -0
- package/src/player/components/VideoThumbnail.test.tsx +116 -9
- package/src/player/components/VideoThumbnail.tsx +11 -5
- package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
- package/src/player/lib/timelineGroupInfo.ts +2 -1
- package/src/webmcp/webmcpTestUtils.ts +2 -2
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
|
|
2
|
-
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
3
1
|
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
4
2
|
import { memo, useEffect, useRef, useState, type RefObject } from "react";
|
|
5
3
|
import type { DomEditSelection } from "./domEditing";
|
|
6
4
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
5
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
6
|
+
import { useGsapInteractionFailureTelemetry } from "../../hooks/useGsapInteractionFailureTelemetry";
|
|
7
7
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
8
|
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
9
9
|
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
|
|
10
|
-
import {
|
|
11
|
-
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
|
|
10
|
+
import { nearestPointOnPath, nodeCentre, type MotionNodeRef } from "./motionPathGeometry";
|
|
12
11
|
import { editableAnimationId, selectorFor } from "./motionPathSelection";
|
|
13
|
-
import {
|
|
12
|
+
import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
|
|
14
13
|
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
15
|
-
import {
|
|
14
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
15
|
+
import { ACCENT, MotionPathLine, MotionPathNode } from "./MotionPathNode";
|
|
16
16
|
import {
|
|
17
17
|
KeyframeDiamondContextMenu,
|
|
18
18
|
type KeyframeDiamondContextMenuState,
|
|
@@ -22,16 +22,12 @@ import {
|
|
|
22
22
|
commitAddKeyframe,
|
|
23
23
|
commitAddWaypoint,
|
|
24
24
|
commitCreatePath,
|
|
25
|
-
|
|
25
|
+
commitNodeDrop,
|
|
26
26
|
commitRemoveWaypoint,
|
|
27
|
+
nodeDropLabel,
|
|
27
28
|
} from "./motionPathCommit";
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
hasMotionPathPlugin,
|
|
31
|
-
isPreviewHtmlElement,
|
|
32
|
-
transformWDivisor,
|
|
33
|
-
useMotionPathData,
|
|
34
|
-
} from "./useMotionPathData";
|
|
29
|
+
import { elementHome } from "./motionPathHome";
|
|
30
|
+
import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData";
|
|
35
31
|
|
|
36
32
|
interface MotionPathOverlayProps {
|
|
37
33
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
@@ -70,6 +66,7 @@ const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
|
|
|
70
66
|
// (select the keyframe); at or above it the gesture commits a move. Screen-space
|
|
71
67
|
// (not composition px) so it behaves identically at any zoom.
|
|
72
68
|
const DRAG_THRESHOLD_PX = 3;
|
|
69
|
+
const noToast = () => {};
|
|
73
70
|
|
|
74
71
|
/**
|
|
75
72
|
* Draws the selected element's GSAP motion path over the canvas — a dashed
|
|
@@ -95,12 +92,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
95
92
|
handleGsapRemoveAllKeyframes,
|
|
96
93
|
handleGsapMoveKeyframeToPlayhead,
|
|
97
94
|
} = useDomEditContext();
|
|
95
|
+
const shell = useStudioShellContextOptional();
|
|
96
|
+
const reportFailure = useGsapInteractionFailureTelemetry(
|
|
97
|
+
shell?.activeCompPath ?? null,
|
|
98
|
+
shell?.showToast ?? noToast,
|
|
99
|
+
);
|
|
98
100
|
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
|
|
99
101
|
iframeRef,
|
|
100
102
|
selectorFor(selection),
|
|
101
103
|
);
|
|
102
104
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
103
105
|
const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
|
|
106
|
+
const [lineCursor, setLineCursor] = useState("copy");
|
|
104
107
|
const [hoverNode, setHoverNode] = useState<number | null>(null);
|
|
105
108
|
// Right-click context menu on a path node — same delete actions as the
|
|
106
109
|
// timeline keyframe diamond. The node it was opened on rides along, because
|
|
@@ -194,7 +197,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
194
197
|
// Resolve the element LIVE from the current iframe document — the selected
|
|
195
198
|
// node may be detached after a soft-reload, which would skew home.
|
|
196
199
|
const live = frame.contentDocument?.querySelector(createSelector);
|
|
197
|
-
if (!
|
|
200
|
+
if (!isHtmlElement(live)) return;
|
|
198
201
|
e.stopPropagation();
|
|
199
202
|
e.preventDefault();
|
|
200
203
|
const sc = r.width / compW;
|
|
@@ -284,12 +287,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
284
287
|
// pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D
|
|
285
288
|
// elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a
|
|
286
289
|
// pointer position is mapped back to a stored offset (create + node drag).
|
|
287
|
-
const abs = nodes.map((n) =>
|
|
288
|
-
|
|
289
|
-
ax:
|
|
290
|
-
|
|
291
|
-
}));
|
|
290
|
+
const abs = nodes.map((n) => {
|
|
291
|
+
const c = nodeCentre(n, home, pScale);
|
|
292
|
+
return { ...n, ax: c.x, ay: c.y };
|
|
293
|
+
});
|
|
292
294
|
const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
|
|
295
|
+
const startAt = geometry.start && nodeCentre(geometry.start, home, pScale);
|
|
293
296
|
// Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
|
|
294
297
|
// rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
|
|
295
298
|
// absolute-positioned SVG), so subtracting them from a viewport clientX/Y would
|
|
@@ -313,9 +316,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
313
316
|
if (e.button !== 0) return;
|
|
314
317
|
e.stopPropagation();
|
|
315
318
|
const live = readGsapPositionFromIframe(iframeRef.current, selectorFor(selection) ?? "");
|
|
316
|
-
|
|
317
|
-
if (pressBelongsToLayer(e, clientToComp(e), abs[index]!, dotR, live) && pressSelectedLayer(e))
|
|
318
|
-
return;
|
|
319
|
+
if (pressControl(e, controlForNode(e, abs[index]!, live))) return;
|
|
319
320
|
(e.target as Element).setPointerCapture(e.pointerId);
|
|
320
321
|
dragRef.current = {
|
|
321
322
|
index,
|
|
@@ -378,36 +379,19 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
378
379
|
// high zoom) would commit an identical value — a no-op undo entry. Skip the
|
|
379
380
|
// commit, but don't treat it as a click either (the user did drag).
|
|
380
381
|
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
|
|
381
|
-
// With auto-keyframe off (#1808), dragging a keyframe's node on the motion
|
|
382
|
-
// path (the common way to nudge a KEYFRAMED element's position on canvas,
|
|
383
|
-
// since the element renders exactly at its current keyframe) shifts the
|
|
384
|
-
// whole path instead of moving just that one keyframe.
|
|
385
382
|
const anim =
|
|
386
383
|
d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
anim,
|
|
399
|
-
{ x, y },
|
|
400
|
-
d.ref.pct,
|
|
401
|
-
iframeRef.current,
|
|
402
|
-
{ commitMutation: writes.commit! },
|
|
403
|
-
"Move animation path",
|
|
404
|
-
).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
|
|
405
|
-
} else {
|
|
406
|
-
void commitNode(d.ref, x, y, animId, commitMutation);
|
|
407
|
-
}
|
|
408
|
-
// Park the playhead on the edited keyframe's time so the element previews AT
|
|
409
|
-
// that keyframe. Without it, a playhead sitting before the tween renders the
|
|
410
|
-
// element's base pose — the edit (correct on the path) looks like it vanished.
|
|
384
|
+
const label = nodeDropLabel(d.ref);
|
|
385
|
+
commitNodeDrop({
|
|
386
|
+
ref: d.ref,
|
|
387
|
+
at: { x, y },
|
|
388
|
+
animId,
|
|
389
|
+
anim,
|
|
390
|
+
selection,
|
|
391
|
+
iframe: iframeRef.current,
|
|
392
|
+
commitMutation,
|
|
393
|
+
}).catch((error: unknown) => reportFailure(error, selection, "drag", label));
|
|
394
|
+
// Park on the edited keyframe, or a playhead before the tween hides the edit.
|
|
411
395
|
if (d.ref.type === "keyframe" && anim) {
|
|
412
396
|
parkPlayheadOnKeyframe(anim, d.ref.pct);
|
|
413
397
|
}
|
|
@@ -415,15 +399,21 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
415
399
|
|
|
416
400
|
// Ghost "add" affordance: project the cursor onto the path; click inserts.
|
|
417
401
|
const onPathHover = (e: React.PointerEvent) => {
|
|
402
|
+
const control = controlUnder(e);
|
|
403
|
+
setLineCursor(control ? getComputedStyle(control).cursor : "copy");
|
|
418
404
|
const c = clientToComp(e);
|
|
419
405
|
const np = nearestPointOnPath(
|
|
420
406
|
c.x,
|
|
421
407
|
c.y,
|
|
422
408
|
abs.map((p) => ({ x: p.ax, y: p.ay })),
|
|
423
409
|
);
|
|
424
|
-
setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
|
|
410
|
+
setGhost(np && !control ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
|
|
425
411
|
};
|
|
426
412
|
const onPathDown = (e: React.PointerEvent) => {
|
|
413
|
+
if (pressControl(e, controlUnder(e))) {
|
|
414
|
+
e.stopPropagation();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
427
417
|
if (!animId) return;
|
|
428
418
|
// Compute the insertion point from the event directly so a click works
|
|
429
419
|
// without (or faster than) a preceding hover.
|
|
@@ -434,8 +424,9 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
434
424
|
abs.map((p) => ({ x: p.ax, y: p.ay })),
|
|
435
425
|
);
|
|
436
426
|
if (!np) return;
|
|
437
|
-
const
|
|
438
|
-
const
|
|
427
|
+
const [a, b] = [abs[np.segIndex]!, abs[np.segIndex + 1]!];
|
|
428
|
+
const x = Math.round(a.x + np.t * (b.x - a.x));
|
|
429
|
+
const y = Math.round(a.y + np.t * (b.y - a.y));
|
|
439
430
|
if (geometry.kind === "arc") {
|
|
440
431
|
e.stopPropagation();
|
|
441
432
|
void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
|
|
@@ -458,10 +449,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
458
449
|
};
|
|
459
450
|
|
|
460
451
|
const elementId = selection?.id ?? null;
|
|
461
|
-
// Right-click any path node → the timeline's keyframe
|
|
462
|
-
//
|
|
463
|
-
// too: returning early for them let the browser's own context menu open over
|
|
464
|
-
// the editor overlay, which is never what a right-click on a node should do.
|
|
452
|
+
// Right-click any path node → the timeline's keyframe menu. Waypoints too, or the
|
|
453
|
+
// browser's own context menu opens over the editor.
|
|
465
454
|
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
|
|
466
455
|
if (!animId || !elementId || !timelineElement) return;
|
|
467
456
|
e.preventDefault();
|
|
@@ -525,39 +514,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
525
514
|
stroke="transparent"
|
|
526
515
|
strokeWidth={14 / scale}
|
|
527
516
|
className="pointer-events-auto"
|
|
528
|
-
style={{ cursor:
|
|
517
|
+
style={{ cursor: lineCursor }}
|
|
529
518
|
onPointerMove={onPathHover}
|
|
530
519
|
onPointerLeave={() => setGhost(null)}
|
|
531
520
|
onPointerDown={onPathDown}
|
|
532
521
|
/>
|
|
533
522
|
)}
|
|
534
|
-
<
|
|
535
|
-
points={points}
|
|
536
|
-
fill="none"
|
|
537
|
-
style={{ stroke: ACCENT }}
|
|
538
|
-
strokeWidth={1.5}
|
|
539
|
-
strokeDasharray="5 5"
|
|
540
|
-
strokeLinejoin="round"
|
|
541
|
-
strokeLinecap="round"
|
|
542
|
-
vectorEffect="non-scaling-stroke"
|
|
543
|
-
opacity={0.85}
|
|
544
|
-
/>
|
|
545
|
-
{ghost && (
|
|
546
|
-
<rect
|
|
547
|
-
x={ghost.x - nodeR * 0.707}
|
|
548
|
-
y={ghost.y - nodeR * 0.707}
|
|
549
|
-
width={nodeR * 1.414}
|
|
550
|
-
height={nodeR * 1.414}
|
|
551
|
-
rx={nodeR * 0.24}
|
|
552
|
-
transform={`rotate(45 ${ghost.x} ${ghost.y})`}
|
|
553
|
-
fill="none"
|
|
554
|
-
strokeWidth={1.5}
|
|
555
|
-
strokeDasharray="2 2"
|
|
556
|
-
vectorEffect="non-scaling-stroke"
|
|
557
|
-
className="pointer-events-none"
|
|
558
|
-
style={{ stroke: ACCENT }}
|
|
559
|
-
/>
|
|
560
|
-
)}
|
|
523
|
+
<MotionPathLine points={points} start={startAt} ghost={ghost} r={nodeR} />
|
|
561
524
|
{abs.map((p, i) => (
|
|
562
525
|
<MotionPathNode
|
|
563
526
|
key={i}
|
|
@@ -124,17 +124,8 @@ export function readEffectiveZIndex(el: HTMLElement): number {
|
|
|
124
124
|
return 0;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/**
|
|
128
|
-
* Realm-safe HTMLElement check. The target lives in the preview IFRAME's
|
|
129
|
-
* document, but this module runs in the top window, so `child instanceof
|
|
130
|
-
* HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
|
|
131
|
-
* which silently emptied the sibling list and left every z-order action
|
|
132
|
-
* permanently disabled. Compare against the element's own realm instead, with
|
|
133
|
-
* a nodeType fallback for detached / cross-realm edge cases.
|
|
134
|
-
*/
|
|
127
|
+
/** Any element, asked structurally: the preview's nodes are not this window's. */
|
|
135
128
|
function isElementNode(node: Node): node is HTMLElement {
|
|
136
|
-
const view = node.ownerDocument?.defaultView;
|
|
137
|
-
if (view && node instanceof view.HTMLElement) return true;
|
|
138
129
|
return node.nodeType === 1;
|
|
139
130
|
}
|
|
140
131
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Puts Studio's committed edits back after a timeline seek has rendered over them.
|
|
2
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
2
3
|
import {
|
|
3
4
|
STUDIO_BOX_SIZE_ATTR,
|
|
4
5
|
STUDIO_HEIGHT_PROP,
|
|
@@ -15,15 +16,11 @@ import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTyp
|
|
|
15
16
|
import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
|
|
16
17
|
|
|
17
18
|
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
18
|
-
const
|
|
19
|
-
if (!ctor) return [];
|
|
20
|
-
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
|
|
21
|
-
(el): el is HTMLElement => el instanceof ctor,
|
|
22
|
-
);
|
|
19
|
+
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(isHtmlElement);
|
|
23
20
|
// Handle legacy HTML files where attributes were persisted with a double data- prefix
|
|
24
21
|
const legacyAttr = `data-${attr}`;
|
|
25
22
|
for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
|
|
26
|
-
if (el
|
|
23
|
+
if (isHtmlElement(el) && !el.hasAttribute(attr)) {
|
|
27
24
|
el.setAttribute(attr, "true");
|
|
28
25
|
el.removeAttribute(legacyAttr);
|
|
29
26
|
elements.push(el);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
1
2
|
import type { DomEditSelection } from "./domEditing";
|
|
2
3
|
import {
|
|
3
4
|
applyStudioPathOffset,
|
|
@@ -131,9 +132,7 @@ function getFrameElement(win: Window): HTMLElement | null {
|
|
|
131
132
|
try {
|
|
132
133
|
const frameElement = win.frameElement;
|
|
133
134
|
if (!frameElement) return null;
|
|
134
|
-
|
|
135
|
-
const htmlElement = ownerWin?.HTMLElement;
|
|
136
|
-
return htmlElement && frameElement instanceof htmlElement ? frameElement : null;
|
|
135
|
+
return isHtmlElement(frameElement) ? frameElement : null;
|
|
137
136
|
} catch {
|
|
138
137
|
return null;
|
|
139
138
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, expect, it, onTestFinished, vi } from "vitest";
|
|
3
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
import type { DomEditSelection } from "./domEditing";
|
|
5
|
+
import { boxSteps, previewWith, tween } from "../../hooks/gsapParsedTween.test-helpers";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
7
|
+
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
|
|
8
|
+
import { commitNodeDrop, nodeDropLabel, type CommitFn } from "./motionPathCommit";
|
|
9
|
+
|
|
10
|
+
it("names a node drop for its undo entry by what it moved", () => {
|
|
11
|
+
const { autoKeyframeEnabled } = usePlayerStore.getState();
|
|
12
|
+
onTestFinished(() => usePlayerStore.setState({ autoKeyframeEnabled }));
|
|
13
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
|
14
|
+
expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move keyframe");
|
|
15
|
+
usePlayerStore.setState({ autoKeyframeEnabled: false });
|
|
16
|
+
expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move animation path");
|
|
17
|
+
expect(nodeDropLabel({ type: "waypoint", index: 0 })).toBe("Move waypoint");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null, requestedSeekTime: null });
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function box(id = "box") {
|
|
26
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
27
|
+
element.id = id;
|
|
28
|
+
return { element, selection: { id, selector: `#${id}`, element } as DomEditSelection };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Drop = Parameters<typeof commitNodeDrop>[0];
|
|
32
|
+
|
|
33
|
+
function drop(fields: Omit<Drop, "animId" | "commitMutation"> & { anim: GsapAnimation }) {
|
|
34
|
+
const commitMutation = vi.fn<CommitFn>(async () => {});
|
|
35
|
+
const done = commitNodeDrop({ ...fields, animId: fields.anim.id, commitMutation });
|
|
36
|
+
const written = () =>
|
|
37
|
+
(commitMutation.mock.calls[0]![0] as { keyframes: { properties: object }[] }).keyframes.map(
|
|
38
|
+
(kf) => kf.properties,
|
|
39
|
+
);
|
|
40
|
+
return { done, commitMutation, written };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it("a keyframe node's drop changes that keyframe and keeps a newly animated channel on the others", async () => {
|
|
44
|
+
const { element, selection } = box();
|
|
45
|
+
// Over a CSS translate GSAP reads as y 30.
|
|
46
|
+
const { keys, live } = boxSteps([
|
|
47
|
+
[2, { x: 60 }],
|
|
48
|
+
[1, { x: 120 }],
|
|
49
|
+
]);
|
|
50
|
+
usePlayerStore.setState({ currentTime: 2, autoKeyframeEnabled: true });
|
|
51
|
+
|
|
52
|
+
const { done, commitMutation, written } = drop({
|
|
53
|
+
ref: { type: "keyframe", pct: 100 },
|
|
54
|
+
at: { x: 150, y: 90 },
|
|
55
|
+
anim: keys,
|
|
56
|
+
selection,
|
|
57
|
+
iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
|
|
58
|
+
});
|
|
59
|
+
await done;
|
|
60
|
+
|
|
61
|
+
expect(commitMutation.mock.calls[0]![0].type).toBe("replace-with-keyframes");
|
|
62
|
+
expect(written()).toEqual([
|
|
63
|
+
{ x: 60, y: 30 },
|
|
64
|
+
{ x: 150, y: 90 },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a drop on an array step's node edits that step, though the path spaces nodes evenly", async () => {
|
|
69
|
+
const { element, selection } = box();
|
|
70
|
+
// Step ends at 2 s and 3 s; the path draws the first node at 0%, the parser and lane at 66.7%.
|
|
71
|
+
const { keys, live } = boxSteps([
|
|
72
|
+
[2, { x: 60, y: 30 }],
|
|
73
|
+
[1, { x: 120, y: 30 }],
|
|
74
|
+
]);
|
|
75
|
+
usePlayerStore.setState({ currentTime: 1, autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
76
|
+
|
|
77
|
+
const { done, written } = drop({
|
|
78
|
+
ref: { type: "keyframe", pct: 0, step: 0 },
|
|
79
|
+
at: { x: 40, y: 90 },
|
|
80
|
+
anim: keys,
|
|
81
|
+
selection,
|
|
82
|
+
iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
|
|
83
|
+
});
|
|
84
|
+
await done;
|
|
85
|
+
|
|
86
|
+
expect(written()).toEqual([
|
|
87
|
+
{ x: 40, y: 90 },
|
|
88
|
+
{ x: 120, y: 30 },
|
|
89
|
+
]);
|
|
90
|
+
expect(usePlayerStore.getState().requestedSeekTime).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("a drop the writer refuses rejects with the reason, writes nothing and selects nothing", async () => {
|
|
94
|
+
const [a, b] = [box("a"), box("b")];
|
|
95
|
+
for (const { element } of [a, b]) element.className = "card";
|
|
96
|
+
const shared = tween({
|
|
97
|
+
targetSelector: ".card",
|
|
98
|
+
method: "to",
|
|
99
|
+
properties: {},
|
|
100
|
+
resolvedStart: 0,
|
|
101
|
+
duration: 1,
|
|
102
|
+
keyframes: { format: "percentage", keyframes: [{ percentage: 100, properties: { x: 20 } }] },
|
|
103
|
+
});
|
|
104
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
105
|
+
|
|
106
|
+
const { done, commitMutation } = drop({
|
|
107
|
+
ref: { type: "keyframe", pct: 100 },
|
|
108
|
+
at: { x: 5, y: 0 },
|
|
109
|
+
anim: shared,
|
|
110
|
+
selection: a.selection,
|
|
111
|
+
iframe: previewWith(b.element, [], { x: 0, y: 0 }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(done).rejects.toBeInstanceOf(GsapEditBlockedError);
|
|
115
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
116
|
+
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each([true, false])(
|
|
120
|
+
"a drop on a step list with a step delay says the delay blocks it and writes nothing (auto-keyframe %s)",
|
|
121
|
+
async (autoKeyframeEnabled) => {
|
|
122
|
+
const { element, selection } = box();
|
|
123
|
+
const { keys } = boxSteps([
|
|
124
|
+
[1.5, { x: 100, delay: 0.5 }],
|
|
125
|
+
[1.5, { x: 200, delay: 0.5 }],
|
|
126
|
+
]);
|
|
127
|
+
usePlayerStore.setState({ autoKeyframeEnabled, activeKeyframePct: null });
|
|
128
|
+
|
|
129
|
+
const { done, commitMutation } = drop({
|
|
130
|
+
ref: { type: "keyframe", pct: 100 },
|
|
131
|
+
at: { x: 150, y: 0 },
|
|
132
|
+
anim: keys,
|
|
133
|
+
selection,
|
|
134
|
+
iframe: previewWith(element, [], { x: 0, y: 0 }),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await expect(done).rejects.toThrow(/its own delay.*Code tab/);
|
|
138
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("with auto-keyframe off, a step's node shifts the path by its own keyframe's move", async () => {
|
|
143
|
+
const { selection } = box();
|
|
144
|
+
// Steps of 4 s, 1 s and 1 s: drawn at 0/50/100%, parsed at their step ends.
|
|
145
|
+
const { keys } = boxSteps([
|
|
146
|
+
[4, { x: 60, y: 0 }],
|
|
147
|
+
[1, { x: 120, y: 0 }],
|
|
148
|
+
[1, { x: 180, y: 0 }],
|
|
149
|
+
]);
|
|
150
|
+
usePlayerStore.setState({ autoKeyframeEnabled: false, activeKeyframePct: null });
|
|
151
|
+
|
|
152
|
+
const { done, written } = drop({
|
|
153
|
+
ref: { type: "keyframe", pct: 50, step: 1 },
|
|
154
|
+
at: { x: 157, y: 0 },
|
|
155
|
+
anim: keys,
|
|
156
|
+
selection,
|
|
157
|
+
iframe: null,
|
|
158
|
+
});
|
|
159
|
+
await done;
|
|
160
|
+
|
|
161
|
+
expect(written().map((props) => (props as { x: number }).x)).toEqual([97, 157, 217]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("a drop on an inner keyframe of a tween eased as a whole changes that keyframe", async () => {
|
|
165
|
+
const { element, selection } = box();
|
|
166
|
+
const keys = tween({
|
|
167
|
+
id: "#box-to-0-position",
|
|
168
|
+
method: "to",
|
|
169
|
+
properties: {},
|
|
170
|
+
resolvedStart: 0,
|
|
171
|
+
duration: 2,
|
|
172
|
+
ease: "power1.inOut",
|
|
173
|
+
keyframes: {
|
|
174
|
+
format: "percentage",
|
|
175
|
+
keyframes: [0, 100, 200].map((x, i) => ({ percentage: i * 50, properties: { x, y: 0 } })),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
179
|
+
|
|
180
|
+
const { done, written } = drop({
|
|
181
|
+
ref: { type: "keyframe", pct: 50 },
|
|
182
|
+
at: { x: 150, y: 0 },
|
|
183
|
+
anim: keys,
|
|
184
|
+
selection,
|
|
185
|
+
iframe: previewWith(element, [], { x: 0, y: 0 }),
|
|
186
|
+
});
|
|
187
|
+
await done;
|
|
188
|
+
|
|
189
|
+
expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 200]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("a drop on a short step under 1% from the one before edits the dragged step", async () => {
|
|
193
|
+
const { element, selection } = box();
|
|
194
|
+
// Parsed at 50, 50.5 and 100%: a 0.05 s pop step in a 10 s list.
|
|
195
|
+
const { keys, live } = boxSteps([
|
|
196
|
+
[5, { x: 0, y: 0 }],
|
|
197
|
+
[0.05, { x: 100, y: 0 }],
|
|
198
|
+
[4.95, { x: 100, y: 0 }],
|
|
199
|
+
]);
|
|
200
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
|
|
201
|
+
|
|
202
|
+
const { done, written } = drop({
|
|
203
|
+
ref: { type: "keyframe", pct: 50, step: 1 },
|
|
204
|
+
at: { x: 150, y: 0 },
|
|
205
|
+
anim: keys,
|
|
206
|
+
selection,
|
|
207
|
+
iframe: previewWith(element, [live(element)], { x: 0, y: 0 }),
|
|
208
|
+
});
|
|
209
|
+
await done;
|
|
210
|
+
|
|
211
|
+
expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 100]);
|
|
212
|
+
});
|
|
@@ -5,7 +5,17 @@ import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/preview
|
|
|
5
5
|
* GSAP source mutation routed through the (selection-bound) commit facade, which
|
|
6
6
|
* handles the soft reload, undo snapshot, and save-failure feedback.
|
|
7
7
|
*/
|
|
8
|
+
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
9
|
+
import { assertGsapEditPersisted } from "../../hooks/gsapEditOutcome";
|
|
10
|
+
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
|
|
11
|
+
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
12
|
+
import { commitValueAtPlayhead } from "../../hooks/gsapValueAtPlayhead";
|
|
13
|
+
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
|
14
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
15
|
+
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
16
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
17
|
import type { MotionNodeRef } from "./motionPathGeometry";
|
|
18
|
+
import { selectorFor } from "./motionPathSelection";
|
|
9
19
|
|
|
10
20
|
export type CommitFn = (
|
|
11
21
|
mutation: Record<string, unknown>,
|
|
@@ -41,6 +51,54 @@ export function commitNode(
|
|
|
41
51
|
);
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
type NodeDrop = {
|
|
55
|
+
ref: MotionNodeRef;
|
|
56
|
+
at: { x: number; y: number };
|
|
57
|
+
animId: string;
|
|
58
|
+
anim: GsapAnimation | undefined;
|
|
59
|
+
selection: DomEditSelection | null;
|
|
60
|
+
iframe: HTMLIFrameElement | null;
|
|
61
|
+
commitMutation: CommitFn;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** What a node drop does, for its undo entry and its failure report. */
|
|
65
|
+
export function nodeDropLabel(ref: MotionNodeRef): string {
|
|
66
|
+
if (ref.type !== "keyframe") return "Move waypoint";
|
|
67
|
+
return usePlayerStore.getState().autoKeyframeEnabled ? "Move keyframe" : "Move animation path";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A dropped keyframe goes through the layer drag's writer, GSAP's live values backfilling others
|
|
71
|
+
* (auto-keyframe off, #1808: the whole path shifts); a waypoint moves in place. */
|
|
72
|
+
export function commitNodeDrop(drop: NodeDrop): Promise<void> {
|
|
73
|
+
const { ref, at, anim, selection, iframe, commitMutation } = drop;
|
|
74
|
+
if (ref.type !== "keyframe" || !anim || !selection)
|
|
75
|
+
return commitNode(ref, at.x, at.y, drop.animId, commitMutation);
|
|
76
|
+
const writes = observeGsapGesture((_sel, mutation, options) => commitMutation(mutation, options));
|
|
77
|
+
const callbacks = { commitMutation: writes.commit! };
|
|
78
|
+
let done: Promise<unknown>;
|
|
79
|
+
const store = usePlayerStore.getState();
|
|
80
|
+
const step = ref.step == null ? undefined : anim.keyframes?.keyframes[ref.step];
|
|
81
|
+
const pct = step?.percentage ?? ref.pct;
|
|
82
|
+
if (store.autoKeyframeEnabled) {
|
|
83
|
+
const selected = store.activeKeyframePct;
|
|
84
|
+
store.setActiveKeyframePct(pct);
|
|
85
|
+
const live = readGsapPositionFromIframe(iframe, selectorFor(selection) ?? "");
|
|
86
|
+
done = commitValueAtPlayhead(selection, anim, at, iframe, callbacks, {
|
|
87
|
+
label: nodeDropLabel(ref),
|
|
88
|
+
backfill: live ?? undefined,
|
|
89
|
+
})
|
|
90
|
+
.then(assertGsapEditPersisted)
|
|
91
|
+
.catch((error: unknown) => {
|
|
92
|
+
usePlayerStore.getState().setActiveKeyframePct(selected);
|
|
93
|
+
throw error;
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
const label = nodeDropLabel(ref);
|
|
97
|
+
done = commitWholePropertyOffset(selection, anim, at, pct, iframe, callbacks, label);
|
|
98
|
+
}
|
|
99
|
+
return done.then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
|
|
100
|
+
}
|
|
101
|
+
|
|
44
102
|
export function commitAddWaypoint(
|
|
45
103
|
animationId: string,
|
|
46
104
|
index: number,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry";
|
|
2
|
+
import { buildMotionPathGeometry, nearestPointOnPath, nodeCentre } from "./motionPathGeometry";
|
|
3
3
|
import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
4
4
|
|
|
5
5
|
const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } });
|
|
@@ -135,3 +135,63 @@ describe("nearestPointOnPath", () => {
|
|
|
135
135
|
expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull();
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
describe("nodeCentre", () => {
|
|
140
|
+
// The edit bench's `keys` layer at 1 s: left 560, 260 wide now, GSAP x 50; it reaches x 60 and
|
|
141
|
+
// width 280 at 2 s, x 120 and width 320 at 3 s, so its centre is then at 760 and 840.
|
|
142
|
+
const home = { x: 690, y: 380, w: 260, h: 160, ax: 0.5, ay: 0.5 };
|
|
143
|
+
const read: ReadTween = {
|
|
144
|
+
keyframes: [
|
|
145
|
+
{ percentage: 66.667, properties: { x: 60, width: 280 } },
|
|
146
|
+
{ percentage: 100, properties: { x: 120, width: 320 } },
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
it("draws a keyframe that also sets the size where the layer's centre is at that keyframe", () => {
|
|
151
|
+
const geo = buildMotionPathGeometry(read, { x: 50, y: 30 })!;
|
|
152
|
+
expect(geo.nodes.map((n) => nodeCentre(n, home, 1))).toEqual([
|
|
153
|
+
{ x: 760, y: 410 },
|
|
154
|
+
{ x: 840, y: 410 },
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps a layer centred by xPercent/yPercent -50 on its centre as a keyframe resizes it", () => {
|
|
159
|
+
const geo = buildMotionPathGeometry(read, { x: 50, y: 30 })!;
|
|
160
|
+
const centred = { ...home, ax: 0, ay: 0 };
|
|
161
|
+
expect(geo.nodes.map((n) => nodeCentre(n, centred, 1).x)).toEqual([750, 810]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps a keyframe that sets no size at home plus its offset", () => {
|
|
165
|
+
const geo = buildMotionPathGeometry({ keyframes: [kf(0, 10, 20), kf(100, 200, 80)] })!;
|
|
166
|
+
expect(nodeCentre(geo.nodes[1]!, home, 1)).toEqual({ x: 890, y: 460 });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("start", () => {
|
|
171
|
+
it("keeps where GSAP started a tween whose first keyframe comes later, with the size it had", () => {
|
|
172
|
+
const read: ReadTween = {
|
|
173
|
+
keyframes: [kf(66.667, 60, 30), kf(100, 120, 30)],
|
|
174
|
+
start: { x: 40, y: 30, width: 260 },
|
|
175
|
+
};
|
|
176
|
+
expect(buildMotionPathGeometry(read)?.start).toEqual({ x: 40, y: 30, w: 260 });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("draws no start when GSAP has not read one", () => {
|
|
180
|
+
expect(buildMotionPathGeometry({ keyframes: [kf(50, 60, 30), kf(100, 120, 30)] })?.start).toBe(
|
|
181
|
+
undefined,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("an array step's node carries its step, so a drop finds that keyframe whatever its time", () => {
|
|
187
|
+
const read = {
|
|
188
|
+
keyframes: [
|
|
189
|
+
{ percentage: 0, properties: { x: 60 }, step: 0 },
|
|
190
|
+
{ percentage: 100, properties: { x: 120 }, step: 1 },
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
expect(buildMotionPathGeometry(read)!.nodes.map((n) => n.ref)).toEqual([
|
|
194
|
+
{ type: "keyframe", pct: 0, step: 0 },
|
|
195
|
+
{ type: "keyframe", pct: 100, step: 1 },
|
|
196
|
+
]);
|
|
197
|
+
});
|