@hyperframes/studio 0.8.97 → 0.8.99
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-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
- package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
- package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
- package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.html +1 -1
- package/dist/index.js +758 -607
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/src/components/EditorShell.tsx +7 -0
- 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/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- 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/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 +14 -0
- package/src/components/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -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 +2 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +34 -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/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useCropOverlay.ts +8 -2
- package/src/hooks/useDomEditCommits.test.tsx +6 -1
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
- package/src/hooks/useGsapAwareEditing.ts +37 -23
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/hooks/useTimelineEditing.test.tsx +190 -135
- package/src/hooks/useTimelineEditing.ts +13 -13
- package/src/hooks/useTimelineGroupEditing.ts +13 -9
- package/src/index.ts +4 -1
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- package/src/player/components/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +10 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- package/src/player/components/useTimelineProviderState.tsx +2 -0
- package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
- package/src/utils/rounding.ts +5 -0
- package/src/utils/sdkEditTransaction.ts +10 -3
|
@@ -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",
|
|
@@ -5,10 +5,8 @@ import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBr
|
|
|
5
5
|
import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
|
|
6
6
|
import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
7
7
|
import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
selectElementAnimationsOrRetry,
|
|
11
|
-
} from "./useGsapAnimationFetchFallback";
|
|
8
|
+
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
9
|
+
import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback";
|
|
12
10
|
|
|
13
11
|
interface CommitPreflight {
|
|
14
12
|
offset: GsapEditOutcome;
|
|
@@ -23,13 +21,7 @@ function runCommitPreflights(
|
|
|
23
21
|
group: boolean,
|
|
24
22
|
): CommitPreflight {
|
|
25
23
|
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
|
|
26
|
-
const
|
|
27
|
-
{ animations: fileAnimations },
|
|
28
|
-
target,
|
|
29
|
-
selection.element,
|
|
30
|
-
);
|
|
31
|
-
// A file the server parsed with no tweens at all is a definitive answer here.
|
|
32
|
-
const animations = matched.kind === "resolved" ? matched.animations : [];
|
|
24
|
+
const animations = getAnimationsForElement(fileAnimations, target, selection.element);
|
|
33
25
|
return {
|
|
34
26
|
offset: dragEditOutcome(selection, animations, iframe, [], group),
|
|
35
27
|
size: preflightGsapResizeIntercept(selection, animations, iframe),
|
|
@@ -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),
|
|
@@ -1204,7 +1204,12 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1204
1204
|
const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
1205
1205
|
const { iframe, element } = createPreviewElement();
|
|
1206
1206
|
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
1207
|
-
onTrySdkPersist: async () => ({
|
|
1207
|
+
onTrySdkPersist: async () => ({
|
|
1208
|
+
status: "committed",
|
|
1209
|
+
version: "sdk-version-2",
|
|
1210
|
+
before: "",
|
|
1211
|
+
after: "",
|
|
1212
|
+
}),
|
|
1208
1213
|
});
|
|
1209
1214
|
|
|
1210
1215
|
try {
|
|
@@ -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,
|
|
@@ -136,6 +136,7 @@ export function useDomGeometryCommit({
|
|
|
136
136
|
trackGsapInteractionFailure,
|
|
137
137
|
stageElementPositionOffset,
|
|
138
138
|
handleDomBoxSizeCommit: noDomBoxSizeRoute,
|
|
139
|
+
commitPositionPatchToHtml: commitWithFreshQueue,
|
|
139
140
|
addGsapAnimation: gsap.addGsapAnimation,
|
|
140
141
|
convertToKeyframes: gsap.convertToKeyframes,
|
|
141
142
|
setArcPath: gsap.setArcPath,
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
readStudioRotation,
|
|
14
14
|
} from "../components/editor/manualEdits";
|
|
15
15
|
import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
|
|
16
|
+
import { DomEditCropHandles } from "../components/editor/DomEditCropHandles";
|
|
17
|
+
import { withInlineLayoutBox } from "./domSelectionTestHarness";
|
|
16
18
|
|
|
17
19
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
20
|
|
|
@@ -170,3 +172,113 @@ describe("useDomGeometryCommits element position offset", () => {
|
|
|
170
172
|
unmount();
|
|
171
173
|
});
|
|
172
174
|
});
|
|
175
|
+
|
|
176
|
+
describe("useDomGeometryCommits resize of a cropped element", () => {
|
|
177
|
+
it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
|
|
178
|
+
const element = withInlineLayoutBox(document.createElement("div"));
|
|
179
|
+
element.id = "cropped";
|
|
180
|
+
element.style.cssText = "width: 300px; height: 200px; clip-path: inset(8px 60px 16px 30px)";
|
|
181
|
+
document.body.append(element);
|
|
182
|
+
const selection = {
|
|
183
|
+
id: "cropped",
|
|
184
|
+
selector: "#cropped",
|
|
185
|
+
element,
|
|
186
|
+
} as unknown as DomEditSelection;
|
|
187
|
+
const commitPositionPatchToHtml = vi
|
|
188
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
189
|
+
.mockResolvedValue(undefined);
|
|
190
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
|
|
191
|
+
const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
|
|
192
|
+
const host = document.createElement("div");
|
|
193
|
+
const crop = createRoot(host);
|
|
194
|
+
act(() =>
|
|
195
|
+
crop.render(
|
|
196
|
+
<DomEditCropHandles selection={selection} overlayRect={rect} onStyleCommit={vi.fn()} />,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await commits().handleDomBoxSizeCommit(selection, { width: 450, height: 250 });
|
|
201
|
+
|
|
202
|
+
const scaled = "inset(10px 90px 20px 45px)";
|
|
203
|
+
expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(commitPositionPatchToHtml.mock.calls[0]![1]).toContainEqual({
|
|
205
|
+
type: "inline-style",
|
|
206
|
+
property: "clip-path",
|
|
207
|
+
value: scaled,
|
|
208
|
+
});
|
|
209
|
+
act(() => crop.unmount());
|
|
210
|
+
expect(element.style.getPropertyValue("clip-path")).toBe(scaled);
|
|
211
|
+
unmount();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function mountCroppedSelection(id: string, authored: string) {
|
|
215
|
+
const element = withInlineLayoutBox(document.createElement("div"));
|
|
216
|
+
element.id = id;
|
|
217
|
+
element.style.cssText = authored;
|
|
218
|
+
document.body.append(element);
|
|
219
|
+
const selection = { id, selector: `#${id}`, element } as unknown as DomEditSelection;
|
|
220
|
+
const commitPositionPatchToHtml = vi
|
|
221
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
222
|
+
.mockResolvedValue(undefined);
|
|
223
|
+
const mounted = mountCommits(commitPositionPatchToHtml);
|
|
224
|
+
const onStyleCommit = vi.fn((property: string, value: string) => {
|
|
225
|
+
element.style.setProperty(property, value);
|
|
226
|
+
});
|
|
227
|
+
const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
|
|
228
|
+
const crop = createRoot(document.body.appendChild(document.createElement("div")));
|
|
229
|
+
const draw = () =>
|
|
230
|
+
act(() =>
|
|
231
|
+
crop.render(
|
|
232
|
+
<DomEditCropHandles
|
|
233
|
+
selection={selection}
|
|
234
|
+
overlayRect={rect}
|
|
235
|
+
onStyleCommit={onStyleCommit}
|
|
236
|
+
/>,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
draw();
|
|
240
|
+
const dragRightEdge = (by: number) => {
|
|
241
|
+
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
242
|
+
const press = (type: string, clientX: number) =>
|
|
243
|
+
act(() =>
|
|
244
|
+
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, clientX })),
|
|
245
|
+
);
|
|
246
|
+
press("pointerdown", 100);
|
|
247
|
+
press("pointermove", 100 - by);
|
|
248
|
+
press("pointerup", 100 - by);
|
|
249
|
+
};
|
|
250
|
+
const resize = () =>
|
|
251
|
+
mounted.commits().handleDomBoxSizeCommit(selection, { width: 450, height: 300 });
|
|
252
|
+
const done = () => {
|
|
253
|
+
act(() => crop.unmount());
|
|
254
|
+
mounted.unmount();
|
|
255
|
+
};
|
|
256
|
+
return { element, onStyleCommit, draw, dragRightEdge, resize, done };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
it("after an undo while still selected, the next crop drag starts from the undone crop", async () => {
|
|
260
|
+
const authored = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
|
|
261
|
+
const h = mountCroppedSelection("undone", authored);
|
|
262
|
+
await h.resize();
|
|
263
|
+
// Undo writes the file's style attribute back onto the same live element.
|
|
264
|
+
h.element.setAttribute("style", authored);
|
|
265
|
+
h.draw();
|
|
266
|
+
h.dragRightEdge(20);
|
|
267
|
+
|
|
268
|
+
expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 80px 0px 0px)");
|
|
269
|
+
h.done();
|
|
270
|
+
expect(h.element.style.getPropertyValue("clip-path")).toBe("inset(0px 80px 0px 0px)");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("a crop drag right after a resize, with no render between, starts from the scaled crop", async () => {
|
|
274
|
+
const h = mountCroppedSelection(
|
|
275
|
+
"resized",
|
|
276
|
+
"width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)",
|
|
277
|
+
);
|
|
278
|
+
await h.resize();
|
|
279
|
+
h.dragRightEdge(20);
|
|
280
|
+
|
|
281
|
+
expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 110px 0px 0px)");
|
|
282
|
+
h.done();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
clearStudioRotation,
|
|
16
16
|
} from "../components/editor/manualEdits";
|
|
17
17
|
import { stageElementOffset } from "./elementOffsetStager";
|
|
18
|
+
import { prepareCropResize } from "../components/editor/cropResize";
|
|
18
19
|
import {
|
|
19
20
|
buildPathOffsetPatches,
|
|
20
21
|
buildBoxSizePatches,
|
|
@@ -103,7 +104,9 @@ export function useDomGeometryCommits({
|
|
|
103
104
|
if (gsapFallback) return gsapFallback;
|
|
104
105
|
const beforeSize = captureStudioBoxSize(selection.element);
|
|
105
106
|
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
|
|
107
|
+
const stageCrop = prepareCropResize(selection.element);
|
|
106
108
|
applyStudioBoxSize(selection.element, next);
|
|
109
|
+
const crop = stageCrop();
|
|
107
110
|
// Anchored-corner resize (NW/NE/SW) also moves the element to keep the
|
|
108
111
|
// opposite corner fixed. Apply the offset and emit BOTH patch sets in a
|
|
109
112
|
// SINGLE commit: one persist = one undo entry, and there is no
|
|
@@ -111,6 +114,7 @@ export function useDomGeometryCommits({
|
|
|
111
114
|
// offset is not (that frame was the release "jump"). Both builders read
|
|
112
115
|
// the already-mutated live element, so concatenation is safe.
|
|
113
116
|
const patches = buildBoxSizePatches(selection.element);
|
|
117
|
+
if (crop) patches.push(crop.patch);
|
|
114
118
|
if (offset) {
|
|
115
119
|
applyStudioPathOffset(selection.element, offset);
|
|
116
120
|
patches.push(...buildPathOffsetPatches(selection.element));
|
|
@@ -121,6 +125,7 @@ export function useDomGeometryCommits({
|
|
|
121
125
|
}).catch((error) => {
|
|
122
126
|
restoreStudioBoxSize(selection.element, beforeSize);
|
|
123
127
|
if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
|
|
128
|
+
crop?.revert();
|
|
124
129
|
throw error;
|
|
125
130
|
});
|
|
126
131
|
},
|
|
@@ -92,7 +92,9 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
|
|
92
92
|
it("reports a successful SDK delete as landed", async () => {
|
|
93
93
|
const ops = mountDeleteOps({
|
|
94
94
|
projectIdRef: { current: "p1" },
|
|
95
|
-
onTrySdkDelete: vi.fn(
|
|
95
|
+
onTrySdkDelete: vi.fn(
|
|
96
|
+
async () => ({ status: "committed", version: "v1", before: "", after: "" }) as const,
|
|
97
|
+
),
|
|
96
98
|
});
|
|
97
99
|
|
|
98
100
|
const target = { ...selectionFor("a"), hfId: "hf-a" };
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
6
|
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
5
7
|
import {
|
|
6
8
|
gsapSourceFileForSelection,
|
|
7
9
|
selectElementAnimationsOrRetry,
|
|
10
|
+
useGsapAnimationFetchFallback,
|
|
8
11
|
} from "./useGsapAnimationFetchFallback";
|
|
12
|
+
import { fetchParsedAnimations } from "./useGsapTweenCache";
|
|
9
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
14
|
|
|
15
|
+
vi.mock("./useGsapTweenCache", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import("./useGsapTweenCache")>()),
|
|
17
|
+
fetchParsedAnimations: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
11
22
|
describe("gsapSourceFileForSelection", () => {
|
|
12
23
|
it("uses the explicit target source instead of ambient selection state", () => {
|
|
13
24
|
expect(
|
|
@@ -28,8 +39,11 @@ describe("selectElementAnimationsOrRetry", () => {
|
|
|
28
39
|
expect(selectElementAnimationsOrRetry(null, target)).toEqual({ kind: "fetch-error" });
|
|
29
40
|
});
|
|
30
41
|
|
|
31
|
-
it("
|
|
32
|
-
expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({
|
|
42
|
+
it("resolves to [] for a file with no tweens at all: the endpoint parses the file per request", () => {
|
|
43
|
+
expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({
|
|
44
|
+
kind: "resolved",
|
|
45
|
+
animations: [],
|
|
46
|
+
});
|
|
33
47
|
});
|
|
34
48
|
|
|
35
49
|
it("resolves the matching animations from a warm parse", () => {
|
|
@@ -58,3 +72,30 @@ describe("selectElementAnimationsOrRetry", () => {
|
|
|
58
72
|
expect(outcome).toEqual({ kind: "resolved", animations: [] });
|
|
59
73
|
});
|
|
60
74
|
});
|
|
75
|
+
|
|
76
|
+
describe("useGsapAnimationFetchFallback", () => {
|
|
77
|
+
const fetchFor = (projectId: string) => {
|
|
78
|
+
let fetcher: ReturnType<typeof useGsapAnimationFetchFallback> | null = null;
|
|
79
|
+
function Probe() {
|
|
80
|
+
fetcher = useGsapAnimationFetchFallback(projectId);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const root = createRoot(document.createElement("div"));
|
|
84
|
+
act(() => root.render(React.createElement(Probe)));
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
const element = document.createElement("div");
|
|
87
|
+
return fetcher!({ id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
it("answers a GSAP-free file with one parse request, no retries", async () => {
|
|
91
|
+
vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(parsed([]));
|
|
92
|
+
await expect(fetchFor("demo")()).resolves.toEqual([]);
|
|
93
|
+
expect(fetchParsedAnimations).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("retries a failed fetch once, then reports no animation", async () => {
|
|
97
|
+
vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(null);
|
|
98
|
+
await expect(fetchFor("demo")()).resolves.toEqual([]);
|
|
99
|
+
expect(fetchParsedAnimations).toHaveBeenCalledTimes(2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -3,13 +3,8 @@ import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
5
5
|
|
|
6
|
-
// A cold parse is the initial-load race: the endpoint is reachable but its parse
|
|
7
|
-
// isn't warm yet (zero total animations). It's worth waiting out (~600ms).
|
|
8
|
-
const COLD_PARSE_RETRIES = 5;
|
|
9
|
-
const COLD_PARSE_DELAY_MS = 120;
|
|
10
6
|
// A hard fetch error (404/403/network/JSON failure → `fetchParsedAnimations`
|
|
11
|
-
// returns null)
|
|
12
|
-
// cold-parse budget. One short retry covers a transient blip; beyond that the
|
|
7
|
+
// returns null) gets one short retry for a transient blip; beyond that the
|
|
13
8
|
// endpoint genuinely isn't serving this file, so fall through to "no animation".
|
|
14
9
|
const FETCH_ERROR_RETRIES = 1;
|
|
15
10
|
const FETCH_ERROR_DELAY_MS = 120;
|
|
@@ -18,17 +13,14 @@ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
|
|
|
18
13
|
|
|
19
14
|
/**
|
|
20
15
|
* Outcome of resolving an element's animations from a single parse result.
|
|
21
|
-
* - `resolved`: a definitive answer
|
|
22
|
-
*
|
|
16
|
+
* - `resolved`: a definitive answer. The endpoint parses the file on disk per request,
|
|
17
|
+
* so no match, or a file with no tweens at all, means the element has no animation.
|
|
23
18
|
* - `fetch-error`: `fetchParsedAnimations` returned null (HTTP/network/JSON
|
|
24
|
-
* failure) — retry briefly
|
|
25
|
-
* - `cold`: the parse came back reachable but with zero total animations — the
|
|
26
|
-
* initial-load warming race, worth the full cold-parse retry budget.
|
|
19
|
+
* failure) — retry briefly.
|
|
27
20
|
*/
|
|
28
21
|
export type ElementAnimationsOutcome =
|
|
29
22
|
| { kind: "resolved"; animations: GsapAnimation[] }
|
|
30
|
-
| { kind: "fetch-error" }
|
|
31
|
-
| { kind: "cold" };
|
|
23
|
+
| { kind: "fetch-error" };
|
|
32
24
|
|
|
33
25
|
export interface GsapAnimationFetchOptions {
|
|
34
26
|
/** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
|
|
@@ -41,26 +33,19 @@ export function gsapSourceFileForSelection(selection: DomEditSelection): string
|
|
|
41
33
|
return selection.sourceFile || "index.html";
|
|
42
34
|
}
|
|
43
35
|
|
|
44
|
-
/**
|
|
45
|
-
* Classify a parse result for one element. Differentiates a hard fetch failure
|
|
46
|
-
* (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
|
|
47
|
-
* so the caller can apply the right retry budget to each.
|
|
48
|
-
*/
|
|
36
|
+
/** Classify a parse result for one element: a hard fetch failure (`parsed === null`) or its animations. */
|
|
49
37
|
export function selectElementAnimationsOrRetry(
|
|
50
38
|
parsed: Pick<ParsedGsap, "animations"> | null,
|
|
51
39
|
target: { id: string | null; selector: string | null },
|
|
52
40
|
element?: Element | null,
|
|
53
41
|
): ElementAnimationsOutcome {
|
|
54
42
|
if (!parsed) return { kind: "fetch-error" };
|
|
55
|
-
if (parsed.animations.length === 0) return { kind: "cold" };
|
|
56
43
|
return {
|
|
57
44
|
kind: "resolved",
|
|
58
45
|
animations: getAnimationsForElement(parsed.animations, target, element),
|
|
59
46
|
};
|
|
60
47
|
}
|
|
61
48
|
|
|
62
|
-
// Retry policy deliberately distinguishes cold parses from hard fetch errors.
|
|
63
|
-
// fallow-ignore-next-line complexity
|
|
64
49
|
async function fetchElementAnimationsWithRetry(
|
|
65
50
|
projectId: string,
|
|
66
51
|
gsapSourceFile: string,
|
|
@@ -69,25 +54,16 @@ async function fetchElementAnimationsWithRetry(
|
|
|
69
54
|
failOnFetchError: boolean,
|
|
70
55
|
fresh: boolean,
|
|
71
56
|
): Promise<GsapAnimation[]> {
|
|
72
|
-
let
|
|
73
|
-
let errorAttempts = 0;
|
|
74
|
-
for (;;) {
|
|
57
|
+
for (let errorAttempts = 0; ; errorAttempts++) {
|
|
75
58
|
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile, { fresh });
|
|
76
59
|
fresh = false;
|
|
77
60
|
const outcome = selectElementAnimationsOrRetry(parsed, target, element);
|
|
78
61
|
if (outcome.kind === "resolved") return outcome.animations;
|
|
79
|
-
if (
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
errorAttempts++;
|
|
85
|
-
await delay(FETCH_ERROR_DELAY_MS);
|
|
86
|
-
continue;
|
|
62
|
+
if (errorAttempts >= FETCH_ERROR_RETRIES) {
|
|
63
|
+
if (failOnFetchError) throw new Error("GSAP animation ownership could not be verified");
|
|
64
|
+
return [];
|
|
87
65
|
}
|
|
88
|
-
|
|
89
|
-
coldAttempts++;
|
|
90
|
-
await delay(COLD_PARSE_DELAY_MS);
|
|
66
|
+
await delay(FETCH_ERROR_DELAY_MS);
|
|
91
67
|
}
|
|
92
68
|
}
|
|
93
69
|
|