@hyperframes/studio 0.8.98 → 0.8.100
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-C6muFtfk.js → hyperframes-player-fWt87gyh.js} +1 -1
- package/dist/assets/{index-B6vU39NM.js → index-D34Oy5DN.js} +1 -1
- package/dist/assets/{index-itc1XziP.js → index-D6TjRAvz.js} +171 -171
- package/dist/assets/{index-BxnoLgxG.js → index-DofdzdxJ.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +718 -438
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
- package/src/components/editor/DomEditCropHandles.tsx +30 -62
- package/src/components/editor/DomEditOverlay.test.ts +2 -2
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/editor/cropResize.test.ts +129 -0
- package/src/components/editor/cropResize.ts +143 -0
- package/src/components/editor/domEditOverlayCrop.ts +65 -15
- package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
- package/src/components/editor/domEditOverlayGestures.ts +2 -5
- package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
- package/src/components/editor/elementPositionOffset.ts +5 -4
- package/src/components/editor/manualEdits.test.ts +22 -8
- package/src/components/editor/manualEditsDom.ts +27 -11
- package/src/components/editor/manualEditsParsing.ts +0 -4
- package/src/components/editor/manualEditsSnapshot.ts +3 -0
- package/src/components/editor/manualOffsetDrag.test.ts +84 -0
- package/src/components/editor/manualOffsetDrag.ts +50 -40
- package/src/components/editor/plainTranslate.test.ts +62 -0
- package/src/components/editor/plainTranslate.ts +85 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/elementOffsetStager.ts +33 -2
- package/src/hooks/gsapDragCommit.ts +6 -6
- package/src/hooks/gsapEditOutcome.ts +6 -0
- package/src/hooks/gsapResizeIntercept.ts +5 -5
- package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
- package/src/hooks/gsapRuntimeBridge.ts +3 -18
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +69 -21
- package/src/hooks/timelineTimingSync.ts +40 -4
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
- package/src/hooks/useCropOverlay.ts +8 -2
- package/src/hooks/useDomEditCommits.test.tsx +18 -4
- package/src/hooks/useDomEditCommits.ts +3 -1
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
- package/src/hooks/useDomEditTextCommits.ts +35 -35
- package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +116 -2
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +147 -23
- package/src/hooks/useGsapAwareEditing.ts +48 -34
- package/src/hooks/useTimelineEditing.test.tsx +190 -135
- package/src/hooks/useTimelineEditing.ts +13 -13
- package/src/hooks/useTimelineGroupEditing.ts +13 -9
- package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
- package/src/utils/rounding.ts +5 -0
- package/src/utils/sdkEditTransaction.ts +10 -3
|
@@ -31,7 +31,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
|
31
31
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
32
32
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
33
33
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
34
|
-
import { roundTo3 } from "../utils/rounding";
|
|
34
|
+
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
35
35
|
import { resolveGroupTween } from "./gsapRuntimeBridge";
|
|
36
36
|
import { logResize } from "../utils/resizeDebug";
|
|
37
37
|
import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
@@ -287,8 +287,8 @@ export async function tryGsapResizeIntercept(
|
|
|
287
287
|
}
|
|
288
288
|
} else {
|
|
289
289
|
resizeProps = {
|
|
290
|
-
width:
|
|
291
|
-
height:
|
|
290
|
+
width: roundToLayoutPx(size.width),
|
|
291
|
+
height: roundToLayoutPx(size.height),
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
294
|
// Finalize a scale-route commit: tear down the gesture's inline width/height
|
|
@@ -358,8 +358,8 @@ export async function tryGsapResizeIntercept(
|
|
|
358
358
|
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
|
359
359
|
// rounded value from this delta).
|
|
360
360
|
const corrected = {
|
|
361
|
-
x:
|
|
362
|
-
y:
|
|
361
|
+
x: roundTo3(base.x + residual.x),
|
|
362
|
+
y: roundTo3(base.y + residual.y),
|
|
363
363
|
};
|
|
364
364
|
logResize("scale-finalize", {
|
|
365
365
|
dropPoint: scaleDraftDropPoint,
|
|
@@ -319,6 +319,17 @@ describe("tryGsapRotationIntercept — instant holds", () => {
|
|
|
319
319
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
+
it("commits the exact angle the draft showed", async () => {
|
|
323
|
+
const commitMutation = vi.fn();
|
|
324
|
+
|
|
325
|
+
await tryGsapRotationIntercept(selection, 75.25, [], null, commitMutation);
|
|
326
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
327
|
+
selection,
|
|
328
|
+
expect.objectContaining({ type: "add", properties: { rotation: 75.25 } }),
|
|
329
|
+
expect.anything(),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
322
333
|
it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
|
|
323
334
|
const helperSkew = {
|
|
324
335
|
id: "#puck-b-to-rotation",
|
|
@@ -27,6 +27,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
|
27
27
|
import { isGestureTransactionCommit } from "./gestureTransaction";
|
|
28
28
|
import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
|
29
29
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
30
|
+
import { roundTo3 } from "../utils/rounding";
|
|
30
31
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
31
32
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
32
33
|
import {
|
|
@@ -34,7 +35,7 @@ import {
|
|
|
34
35
|
pickClosestToPlayhead,
|
|
35
36
|
readGsapPositionFromIframe,
|
|
36
37
|
} from "./gsapPositionDetection";
|
|
37
|
-
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
38
|
+
import { hasNonHoldTweenForElement, POSITION_CHANNELS } from "./gsapRuntimeKeyframes";
|
|
38
39
|
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
39
40
|
import {
|
|
40
41
|
animationWritesAnyProperty,
|
|
@@ -42,22 +43,6 @@ import {
|
|
|
42
43
|
type GsapEditOutcome,
|
|
43
44
|
} from "./gsapEditOutcome";
|
|
44
45
|
|
|
45
|
-
// Position channels — used to scope the "has a live position tween?" check so a
|
|
46
|
-
// sibling rotation/scale animation never forces a static position hold into the
|
|
47
|
-
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
|
|
48
|
-
export const POSITION_CHANNELS: string[] = [
|
|
49
|
-
"x",
|
|
50
|
-
"y",
|
|
51
|
-
"xPercent",
|
|
52
|
-
"yPercent",
|
|
53
|
-
"left",
|
|
54
|
-
"top",
|
|
55
|
-
// GSAP normalizes translateX/Y to x/y at play time, but readTween reads the
|
|
56
|
-
// AUTHORED shape — include them so a hand-authored translateX/Y position tween
|
|
57
|
-
// still counts as a live position tween.
|
|
58
|
-
"translateX",
|
|
59
|
-
"translateY",
|
|
60
|
-
];
|
|
61
46
|
const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
|
|
62
47
|
|
|
63
48
|
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
|
|
@@ -446,7 +431,7 @@ export async function tryGsapRotationIntercept(
|
|
|
446
431
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
447
432
|
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
|
448
433
|
// gesture's live preview already gsap.set this value (single source of truth).
|
|
449
|
-
const newRotation =
|
|
434
|
+
const newRotation = roundTo3(angle);
|
|
450
435
|
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
|
|
451
436
|
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
|
|
452
437
|
// mirroring the static position set. Idempotent: re-rotate updates an existing
|
|
@@ -167,6 +167,13 @@ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-
|
|
|
167
167
|
expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
it("true when the tween sits in a later timeline (a soft reload re-adds main last)", () => {
|
|
171
|
+
const iframe = fakeIframe(el, []);
|
|
172
|
+
const win = iframe.contentWindow as unknown as { __timelines: Record<string, unknown> };
|
|
173
|
+
win.__timelines.main = { getChildren: () => [liveTween] };
|
|
174
|
+
expect(hasNonHoldTweenForElement(iframe, "#puck-b")).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
170
177
|
it("false when only a zero-duration hold/set remains (post delete-all)", () => {
|
|
171
178
|
expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false);
|
|
172
179
|
});
|
|
@@ -200,7 +200,7 @@ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: s
|
|
|
200
200
|
/**
|
|
201
201
|
* Like `varsCarryChannel` but for a keyframe tween: the channels live inside the
|
|
202
202
|
* keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object
|
|
203
|
-
* form (`{ "0%": {...} }`)
|
|
203
|
+
* form (`{ "0%": {...} }`), the array form (`[{...}, ...]`) and the object-of-arrays form.
|
|
204
204
|
*/
|
|
205
205
|
function keyframeVarsCarryChannel(
|
|
206
206
|
vars: Record<string, unknown> | undefined,
|
|
@@ -208,6 +208,8 @@ function keyframeVarsCarryChannel(
|
|
|
208
208
|
): boolean {
|
|
209
209
|
const kf = vars?.keyframes;
|
|
210
210
|
if (!kf || typeof kf !== "object") return false;
|
|
211
|
+
if (!Array.isArray(kf) && channels.some((ch) => Object.prototype.hasOwnProperty.call(kf, ch)))
|
|
212
|
+
return true;
|
|
211
213
|
const steps = Array.isArray(kf) ? kf : Object.values(kf);
|
|
212
214
|
return steps.some(
|
|
213
215
|
(step) =>
|
|
@@ -375,8 +377,9 @@ export function readRuntimeKeyframes(
|
|
|
375
377
|
}
|
|
376
378
|
|
|
377
379
|
/**
|
|
378
|
-
* Whether
|
|
379
|
-
* not the studio position-hold `set`) targeting `selector`.
|
|
380
|
+
* Whether any live timeline has at least one NON-HOLD tween (non-zero duration,
|
|
381
|
+
* not the studio position-hold `set`) targeting `selector`. Every timeline is read:
|
|
382
|
+
* a soft reload re-adds the rebuilt composition's key last. Stricter than a
|
|
380
383
|
* truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing
|
|
381
384
|
* tween, so it can't distinguish a real animation from a leftover hold/marker.
|
|
382
385
|
* The drag's stale-parse guard needs this exact distinction — after a delete-all
|
|
@@ -387,37 +390,82 @@ export function readRuntimeKeyframes(
|
|
|
387
390
|
* rotation/scale tween doesn't make a static position hold enter the keyframe
|
|
388
391
|
* branch.
|
|
389
392
|
*/
|
|
390
|
-
// fallow-ignore-next-line complexity
|
|
391
393
|
export function hasNonHoldTweenForElement(
|
|
392
394
|
iframe: HTMLIFrameElement | null,
|
|
393
395
|
selector: string,
|
|
394
396
|
compositionId?: string,
|
|
395
397
|
channels?: string[],
|
|
396
398
|
): boolean {
|
|
397
|
-
const timelines = timelinesOf(iframe);
|
|
398
|
-
if (!timelines) return false;
|
|
399
|
-
const tlId =
|
|
400
|
-
compositionId ||
|
|
401
|
-
Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function");
|
|
402
|
-
if (!tlId) return false;
|
|
403
|
-
const timeline = timelines[tlId];
|
|
404
|
-
if (!timeline?.getChildren) return false;
|
|
405
|
-
|
|
406
399
|
let targetEl: Element | null = null;
|
|
407
400
|
try {
|
|
408
401
|
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
|
|
409
402
|
} catch {
|
|
410
403
|
return false;
|
|
411
404
|
}
|
|
412
|
-
|
|
405
|
+
return !!targetEl && hasNonHoldTween(timelinesOf(iframe), targetEl, channels, compositionId);
|
|
406
|
+
}
|
|
413
407
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
408
|
+
// A sibling rotation/scale tween must never push a static position hold into the keyframe branch.
|
|
409
|
+
export const POSITION_CHANNELS: string[] = [
|
|
410
|
+
"x",
|
|
411
|
+
"y",
|
|
412
|
+
"xPercent",
|
|
413
|
+
"yPercent",
|
|
414
|
+
"left",
|
|
415
|
+
"top",
|
|
416
|
+
// readTween reads the authored translateX/Y; GSAP normalizes them to x/y only at play time.
|
|
417
|
+
"translateX",
|
|
418
|
+
"translateY",
|
|
419
|
+
];
|
|
420
|
+
const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"];
|
|
421
|
+
|
|
422
|
+
/** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
|
|
423
|
+
function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
424
|
+
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
|
|
425
|
+
return Object.values(win?.__timelines ?? {}).some((tl) =>
|
|
426
|
+
(tl?.getChildren?.(true) ?? []).some(
|
|
427
|
+
(tween) =>
|
|
428
|
+
!!tween.vars &&
|
|
429
|
+
matchesElement(tween, el) &&
|
|
430
|
+
(channels.some((ch) => ch in tween.vars!) ||
|
|
431
|
+
keyframeVarsCarryChannel(tween.vars, channels)),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
|
|
437
|
+
* transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
|
|
438
|
+
export function gsapWritesPosition(el: Element): boolean {
|
|
439
|
+
const cache = (el as { _gsap?: { renderTransform?: unknown } })._gsap;
|
|
440
|
+
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
|
|
444
|
+
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
|
|
445
|
+
const win = el.ownerDocument.defaultView as {
|
|
446
|
+
__timelines?: Record<string, RuntimeTimeline>;
|
|
447
|
+
} | null;
|
|
448
|
+
return hasNonHoldTween(win?.__timelines ?? null, el, channels);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// fallow-ignore-next-line complexity
|
|
452
|
+
function hasNonHoldTween(
|
|
453
|
+
timelines: Record<string, RuntimeTimeline> | null,
|
|
454
|
+
targetEl: Element,
|
|
455
|
+
channels?: string[],
|
|
456
|
+
compositionId?: string,
|
|
457
|
+
): boolean {
|
|
458
|
+
if (!timelines) return false;
|
|
459
|
+
for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
|
|
460
|
+
// fallow-ignore-next-line code-duplication
|
|
461
|
+
for (const tween of timelines[tlId]?.getChildren?.(true) ?? []) {
|
|
462
|
+
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
463
|
+
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
464
|
+
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
465
|
+
if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
|
|
466
|
+
const read = readTween(tween.vars);
|
|
467
|
+
if (read && (!channels || readCarriesChannel(read, channels))) return true;
|
|
468
|
+
}
|
|
421
469
|
}
|
|
422
470
|
return false;
|
|
423
471
|
}
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// edit's undo history, and swapping the rewritten script into the live preview
|
|
4
4
|
// without a full iframe reload when possible.
|
|
5
5
|
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applySoftReload,
|
|
8
|
+
applySoftReloadFinalization,
|
|
9
|
+
extractGsapScriptText,
|
|
10
|
+
} from "../utils/gsapSoftReload";
|
|
7
11
|
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
|
8
12
|
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
9
13
|
import { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
@@ -15,6 +19,9 @@ import {
|
|
|
15
19
|
rollbackOwnedMutation,
|
|
16
20
|
type GsapMutationStatus,
|
|
17
21
|
} from "./gsapMutationClient";
|
|
22
|
+
import type { CutoverResult } from "../utils/sdkEditTransaction";
|
|
23
|
+
import { findTimelineScript } from "@hyperframes/core/gsap-parser-acorn";
|
|
24
|
+
import { walkCompositionDescendants } from "@hyperframes/parsers/hf-ids";
|
|
18
25
|
import {
|
|
19
26
|
serializeStudioFileMutations,
|
|
20
27
|
type StudioProjectFileWriter,
|
|
@@ -365,6 +372,21 @@ export async function scaleGsapPositions(
|
|
|
365
372
|
);
|
|
366
373
|
}
|
|
367
374
|
|
|
375
|
+
/** The GSAP sync a committed SDK timing write made, or null when its timeline script is unchanged. */
|
|
376
|
+
export function sdkTimingGsapSync(
|
|
377
|
+
result: Extract<CutoverResult, { status: "committed" }>,
|
|
378
|
+
): GsapMutationStatus | null {
|
|
379
|
+
const timelineText = (html: string) => {
|
|
380
|
+
const scripts: Element[] = [];
|
|
381
|
+
walkCompositionDescendants(new DOMParser().parseFromString(html, "text/html"), (el) => {
|
|
382
|
+
if (el.tagName.toLowerCase() === "script") scripts.push(el);
|
|
383
|
+
});
|
|
384
|
+
return findTimelineScript(scripts)?.textContent ?? null;
|
|
385
|
+
};
|
|
386
|
+
if (timelineText(result.before) === timelineText(result.after)) return null;
|
|
387
|
+
return { mutated: true, scriptText: extractGsapScriptText(result.after) };
|
|
388
|
+
}
|
|
389
|
+
|
|
368
390
|
/** Timing delta a single-clip edit applies to its GSAP tweens. */
|
|
369
391
|
export type SingleClipGsapEdit =
|
|
370
392
|
| { kind: "shift"; delta: number }
|
|
@@ -395,8 +417,9 @@ export function finishClipTimingFallback(input: {
|
|
|
395
417
|
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
396
418
|
writeProjectFile: StudioProjectFileWriter;
|
|
397
419
|
edit: SingleClipGsapEdit;
|
|
420
|
+
sdkGsap?: GsapMutationStatus | null;
|
|
398
421
|
}): Promise<void> {
|
|
399
|
-
const { projectId, targetPath, domId, edit } = input;
|
|
422
|
+
const { projectId, targetPath, domId, edit, sdkGsap } = input;
|
|
400
423
|
const timingChanged =
|
|
401
424
|
edit.kind === "shift"
|
|
402
425
|
? edit.delta !== 0
|
|
@@ -418,8 +441,9 @@ export function finishClipTimingFallback(input: {
|
|
|
418
441
|
return finishTimelineTimingFallback({
|
|
419
442
|
iframe: input.iframe,
|
|
420
443
|
reloadPreview: input.reloadPreview,
|
|
421
|
-
gsapMutation:
|
|
422
|
-
|
|
444
|
+
gsapMutation: sdkGsap
|
|
445
|
+
? () => Promise.resolve(sdkGsap)
|
|
446
|
+
: timingChanged && domId && projectId
|
|
423
447
|
? () =>
|
|
424
448
|
foldGsapMutationIntoHistory({
|
|
425
449
|
writeFile: input.writeProjectFile,
|
|
@@ -463,12 +487,24 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
463
487
|
resolveChangePath: (element: TimelineElement) => string;
|
|
464
488
|
/** Per-change GSAP mutation; return null to skip a change with no timing delta. */
|
|
465
489
|
mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
|
|
490
|
+
sdkGsap?: GsapMutationStatus | null;
|
|
466
491
|
}): Promise<void> {
|
|
467
492
|
const activePath = input.activeCompPath || "index.html";
|
|
468
493
|
const otherFileChanged = input.changes.some(
|
|
469
494
|
(change) => input.resolveChangePath(change.element) !== activePath,
|
|
470
495
|
);
|
|
471
496
|
const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
|
|
497
|
+
const { sdkGsap } = input;
|
|
498
|
+
if (sdkGsap) {
|
|
499
|
+
await finishTimelineTimingFallback({
|
|
500
|
+
iframe: input.iframe,
|
|
501
|
+
reloadPreview: input.reloadPreview,
|
|
502
|
+
gsapMutation: () => Promise.resolve(sdkGsap),
|
|
503
|
+
onGsapError,
|
|
504
|
+
rebindWhenUnmutated: !otherFileChanged,
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
472
508
|
await finishTimelineTimingFallback({
|
|
473
509
|
iframe: input.iframe,
|
|
474
510
|
reloadPreview: input.reloadPreview,
|
|
@@ -6,7 +6,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
|
9
|
-
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
9
|
+
import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness";
|
|
10
|
+
import { writeSizeWithCrop } from "../components/editor/cropResize";
|
|
11
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
10
12
|
|
|
11
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
14
|
|
|
@@ -362,3 +364,40 @@ describe("commitStaticSet group routing", () => {
|
|
|
362
364
|
act(() => root.unmount());
|
|
363
365
|
});
|
|
364
366
|
});
|
|
367
|
+
|
|
368
|
+
describe("useAnimatedPropertyCommit — a size write and its crop are one undo step", () => {
|
|
369
|
+
it.each([[{ width: 450 }], [{ x: 10, width: 450 }]])("%o on a static element", async (props) => {
|
|
370
|
+
const element = withInlineLayoutBox(document.createElement("div"));
|
|
371
|
+
element.id = "box";
|
|
372
|
+
element.style.cssText = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
|
|
373
|
+
document.body.append(element);
|
|
374
|
+
const sel = { ...selection, element } as DomEditSelection;
|
|
375
|
+
const keys: unknown[] = [];
|
|
376
|
+
const land = async (options: { coalesceKey?: string }) => {
|
|
377
|
+
keys.push(options.coalesceKey);
|
|
378
|
+
element.style.width = "450px";
|
|
379
|
+
};
|
|
380
|
+
const mutation: CommitMutation = (_s, _m, options) => land(options);
|
|
381
|
+
mutation.batch = (_calls, options) => land(options);
|
|
382
|
+
let raw!: ReturnType<typeof useAnimatedPropertyCommit>["commitAnimatedProperties"];
|
|
383
|
+
function Harness() {
|
|
384
|
+
raw = useAnimatedPropertyCommit({
|
|
385
|
+
selectedGsapAnimations: [],
|
|
386
|
+
gsapCommitMutation: mutation,
|
|
387
|
+
addGsapAnimation: vi.fn(),
|
|
388
|
+
convertToKeyframes: vi.fn(),
|
|
389
|
+
previewIframeRef: { current: null },
|
|
390
|
+
bumpGsapCache: vi.fn(),
|
|
391
|
+
}).commitAnimatedProperties;
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const root = mountReactHarness(<Harness />);
|
|
395
|
+
const patch = vi.fn().mockResolvedValue(undefined);
|
|
396
|
+
await writeSizeWithCrop(sel, props, mutation, patch, (keyed) => raw(sel, props, keyed));
|
|
397
|
+
|
|
398
|
+
const cropKey = patch.mock.calls[0]![2].coalesceKey;
|
|
399
|
+
expect(patch.mock.calls[0]![1][0]).toMatchObject({ value: "inset(0px 90px 0px 0px)" });
|
|
400
|
+
expect(keys).toEqual([cropKey]);
|
|
401
|
+
act(() => root.unmount());
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -431,8 +431,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
431
431
|
const commitAnimatedProperties = useCallback(
|
|
432
432
|
// This is the single routing boundary for set, keyframe, whole-tween, and first-group writes.
|
|
433
433
|
// fallow-ignore-next-line complexity
|
|
434
|
-
async (
|
|
435
|
-
|
|
434
|
+
async (
|
|
435
|
+
selection: DomEditSelection,
|
|
436
|
+
props: Record<string, number | string>,
|
|
437
|
+
commit: CommitMutation | null = gsapCommitMutation,
|
|
438
|
+
): Promise<void> => {
|
|
439
|
+
if (!commit) return;
|
|
436
440
|
const propEntries = Object.entries(props);
|
|
437
441
|
if (propEntries.length === 0) return;
|
|
438
442
|
const primaryProp = propEntries[0]![0];
|
|
@@ -490,7 +494,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
490
494
|
),
|
|
491
495
|
pct,
|
|
492
496
|
iframe,
|
|
493
|
-
{ commitMutation:
|
|
497
|
+
{ commitMutation: commit },
|
|
494
498
|
`Edit ${primaryProp} (whole animation)`,
|
|
495
499
|
);
|
|
496
500
|
return;
|
|
@@ -503,7 +507,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
503
507
|
primaryProp,
|
|
504
508
|
selector,
|
|
505
509
|
iframe,
|
|
506
|
-
|
|
510
|
+
commit,
|
|
507
511
|
);
|
|
508
512
|
return;
|
|
509
513
|
}
|
|
@@ -515,13 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
515
519
|
// must update the position set AND create a size set atomically rather
|
|
516
520
|
// than contaminating the first set with a foreign property group.
|
|
517
521
|
if (!elementHasKeyframes) {
|
|
518
|
-
await commitStaticSet(
|
|
519
|
-
selection,
|
|
520
|
-
propEntries,
|
|
521
|
-
selector,
|
|
522
|
-
selectedGsapAnimations,
|
|
523
|
-
gsapCommitMutation,
|
|
524
|
-
);
|
|
522
|
+
await commitStaticSet(selection, propEntries, selector, selectedGsapAnimations, commit);
|
|
525
523
|
return;
|
|
526
524
|
}
|
|
527
525
|
|
|
@@ -534,7 +532,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
534
532
|
propEntries,
|
|
535
533
|
selector,
|
|
536
534
|
selectedGsapAnimations,
|
|
537
|
-
|
|
535
|
+
commit,
|
|
538
536
|
);
|
|
539
537
|
return;
|
|
540
538
|
}
|
|
@@ -566,7 +564,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
566
564
|
{ percentage: 0, properties: { ...newProps, _auto: 1 } },
|
|
567
565
|
{ percentage: pct, properties: newProps },
|
|
568
566
|
];
|
|
569
|
-
await
|
|
567
|
+
await commit(
|
|
570
568
|
selection,
|
|
571
569
|
{
|
|
572
570
|
type: "add-with-keyframes",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isElementCropLifted,
|
|
5
|
+
readElementCropInsets,
|
|
6
|
+
} from "../components/editor/domEditOverlayCrop";
|
|
4
7
|
|
|
5
8
|
/** Selection-box crop hug: the outline that makes the selection box hug the
|
|
6
9
|
* element's committed inset crop. Crop is always-on (no mode) — the draggable
|
|
@@ -13,7 +16,10 @@ export function useCropOverlay(params: {
|
|
|
13
16
|
}) {
|
|
14
17
|
const { selection, overlayRect } = params;
|
|
15
18
|
|
|
16
|
-
const cropInsets =
|
|
19
|
+
const cropInsets =
|
|
20
|
+
selection && !isElementCropLifted(selection.element)
|
|
21
|
+
? readElementCropInsets(selection.element)
|
|
22
|
+
: null;
|
|
17
23
|
const hasCropInsets = Boolean(
|
|
18
24
|
cropInsets &&
|
|
19
25
|
(cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
|
|
@@ -228,7 +228,9 @@ function renderDomEditCommits(
|
|
|
228
228
|
applyDomSelection: vi.fn(),
|
|
229
229
|
clearDomSelection: vi.fn(),
|
|
230
230
|
refreshDomEditSelectionFromPreview: vi.fn(),
|
|
231
|
-
buildDomSelectionFromTarget: vi.fn(async () =>
|
|
231
|
+
buildDomSelectionFromTarget: vi.fn(async (target: HTMLElement) =>
|
|
232
|
+
target === selection.element ? selection : null,
|
|
233
|
+
),
|
|
232
234
|
onTrySdkPersist: options.onTrySdkPersist,
|
|
233
235
|
readOnlyPreview: false,
|
|
234
236
|
});
|
|
@@ -893,7 +895,7 @@ describe("useDomEditCommits rich-text persist handling", () => {
|
|
|
893
895
|
}
|
|
894
896
|
});
|
|
895
897
|
|
|
896
|
-
it("does not retarget a commit onto a replacement preview node", async () => {
|
|
898
|
+
it("does not retarget a commit onto a replacement preview node, and says the text was not saved", async () => {
|
|
897
899
|
const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
898
900
|
const html = '<span style="color: blue">After</span>';
|
|
899
901
|
const { iframe, element } = createPreviewElement(`<div data-hf-id="hf-card">${html}</div>`);
|
|
@@ -902,6 +904,7 @@ describe("useDomEditCommits rich-text persist handling", () => {
|
|
|
902
904
|
replacement.dataset.hfId = "hf-card";
|
|
903
905
|
replacement.innerHTML = "Reloaded elsewhere";
|
|
904
906
|
element.replaceWith(replacement);
|
|
907
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
905
908
|
|
|
906
909
|
try {
|
|
907
910
|
await act(async () => {
|
|
@@ -914,8 +917,14 @@ describe("useDomEditCommits rich-text persist handling", () => {
|
|
|
914
917
|
|
|
915
918
|
expect(replacement.innerHTML).toBe("Reloaded elsewhere");
|
|
916
919
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
917
|
-
expect(rendered.showToast).
|
|
920
|
+
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
921
|
+
expect.stringMatching(
|
|
922
|
+
/Couldn't save the text edit: the text's element is gone from the preview/,
|
|
923
|
+
),
|
|
924
|
+
"error",
|
|
925
|
+
);
|
|
918
926
|
} finally {
|
|
927
|
+
errorSpy.mockRestore();
|
|
919
928
|
rendered.cleanup();
|
|
920
929
|
}
|
|
921
930
|
});
|
|
@@ -1204,7 +1213,12 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1204
1213
|
const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
1205
1214
|
const { iframe, element } = createPreviewElement();
|
|
1206
1215
|
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
1207
|
-
onTrySdkPersist: async () => ({
|
|
1216
|
+
onTrySdkPersist: async () => ({
|
|
1217
|
+
status: "committed",
|
|
1218
|
+
version: "sdk-version-2",
|
|
1219
|
+
before: "",
|
|
1220
|
+
after: "",
|
|
1221
|
+
}),
|
|
1208
1222
|
});
|
|
1209
1223
|
|
|
1210
1224
|
try {
|
|
@@ -7,6 +7,7 @@ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSave
|
|
|
7
7
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
8
8
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
9
9
|
import type { CommitDomEditPatchBatches } from "./domEditCommitTypes";
|
|
10
|
+
import type { ResolveDomSelectionOptions } from "./useDomSelectionTypes";
|
|
10
11
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
11
12
|
import { DomEditPersistUnsafeValueError } from "./domEditPersistFailure";
|
|
12
13
|
import { useDomEditPersist, type RecordEditInput } from "./useDomEditPersist";
|
|
@@ -46,7 +47,7 @@ export interface UseDomEditCommitsParams {
|
|
|
46
47
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
|
47
48
|
buildDomSelectionFromTarget: (
|
|
48
49
|
target: HTMLElement,
|
|
49
|
-
options?:
|
|
50
|
+
options?: ResolveDomSelectionOptions,
|
|
50
51
|
) => Promise<DomEditSelection | null>;
|
|
51
52
|
/** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
|
|
52
53
|
* path, whose session is already current) so a later SDK edit doesn't
|
|
@@ -314,6 +315,7 @@ export function useDomEditCommits({
|
|
|
314
315
|
handleDomAddTextField,
|
|
315
316
|
handleDomRemoveTextField,
|
|
316
317
|
stageElementPositionOffset,
|
|
318
|
+
commitPositionPatchToHtml,
|
|
317
319
|
handleDomPathOffsetCommit,
|
|
318
320
|
handleDomBoxSizeCommit,
|
|
319
321
|
handleDomRotationCommit,
|
|
@@ -12,12 +12,12 @@ interface UseDomEditPositionPatchCommitParams {
|
|
|
12
12
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
type PositionPatchOptions = {
|
|
16
16
|
label: string;
|
|
17
17
|
coalesceKey: string;
|
|
18
18
|
coalesceMs?: number;
|
|
19
19
|
skipRefresh?: boolean;
|
|
20
|
-
}
|
|
20
|
+
};
|
|
21
21
|
|
|
22
22
|
export function useDomEditPositionPatchCommit({
|
|
23
23
|
activeCompPath,
|
|
@@ -230,6 +230,7 @@ export function useDomEditSession({
|
|
|
230
230
|
handleDomAddTextField,
|
|
231
231
|
handleDomRemoveTextField,
|
|
232
232
|
stageElementPositionOffset,
|
|
233
|
+
commitPositionPatchToHtml,
|
|
233
234
|
handleDomBoxSizeCommit,
|
|
234
235
|
handleDomManualEditsReset,
|
|
235
236
|
handleDomEditElementsDelete,
|
|
@@ -490,6 +491,7 @@ export function useDomEditSession({
|
|
|
490
491
|
trackGsapInteractionFailure,
|
|
491
492
|
stageElementPositionOffset,
|
|
492
493
|
handleDomBoxSizeCommit,
|
|
494
|
+
commitPositionPatchToHtml,
|
|
493
495
|
addGsapAnimation,
|
|
494
496
|
convertToKeyframes,
|
|
495
497
|
setArcPath,
|