@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
|
@@ -32,9 +32,9 @@ import {
|
|
|
32
32
|
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
33
33
|
STUDIO_ROTATION_TRANSFORM_ORIGIN,
|
|
34
34
|
} from "./manualEditsTypes";
|
|
35
|
-
import { roundRotationAngle } from "./manualEditsParsing";
|
|
36
35
|
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
37
36
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
37
|
+
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
|
|
38
38
|
|
|
39
39
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
|
40
40
|
let studioManualEditGestureId = 0;
|
|
@@ -203,8 +203,8 @@ function writeStudioPathOffsetVars(
|
|
|
203
203
|
): void {
|
|
204
204
|
prepareStudioPathOffsetBase(element, options.updateBase ?? true);
|
|
205
205
|
element.setAttribute(STUDIO_PATH_OFFSET_ATTR, "true");
|
|
206
|
-
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${
|
|
207
|
-
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${
|
|
206
|
+
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${roundTo3(offset.x)}px`);
|
|
207
|
+
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${roundTo3(offset.y)}px`);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
/* ── Path offset apply ────────────────────────────────────────────── */
|
|
@@ -325,7 +325,7 @@ export function applyStudioPathOffsetDraft(
|
|
|
325
325
|
// Non-GSAP elements: use CSS translate as before.
|
|
326
326
|
element.style.setProperty(
|
|
327
327
|
"translate",
|
|
328
|
-
composeTranslateValue(element, `${
|
|
328
|
+
composeTranslateValue(element, `${roundTo3(offset.x)}px`, `${roundTo3(offset.y)}px`),
|
|
329
329
|
);
|
|
330
330
|
stripGsapTranslateFromTransform(element);
|
|
331
331
|
}
|
|
@@ -342,7 +342,7 @@ function readParentFlexBasisPixels(
|
|
|
342
342
|
if (display !== "flex" && display !== "inline-flex") return null;
|
|
343
343
|
|
|
344
344
|
const direction = readStyleOrComputed(parent, "flex-direction").trim();
|
|
345
|
-
return
|
|
345
|
+
return roundToLayoutPx(Math.max(1, direction.startsWith("column") ? size.height : size.width));
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
function restoreStaleStudioScaleResize(element: HTMLElement): void {
|
|
@@ -417,8 +417,8 @@ function writeStudioBoxSizeVars(
|
|
|
417
417
|
}
|
|
418
418
|
|
|
419
419
|
element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
|
|
420
|
-
element.style.setProperty(STUDIO_WIDTH_PROP, `${
|
|
421
|
-
element.style.setProperty(STUDIO_HEIGHT_PROP, `${
|
|
420
|
+
element.style.setProperty(STUDIO_WIDTH_PROP, `${roundToLayoutPx(Math.max(1, size.width))}px`);
|
|
421
|
+
element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutPx(Math.max(1, size.height))}px`);
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
function applyStudioBoxSizeDimensions(
|
|
@@ -428,8 +428,8 @@ function applyStudioBoxSizeDimensions(
|
|
|
428
428
|
writeStudioBoxSizeVars(element, size);
|
|
429
429
|
restoreStaleStudioScaleResize(element);
|
|
430
430
|
|
|
431
|
-
const width =
|
|
432
|
-
const height =
|
|
431
|
+
const width = roundToLayoutPx(Math.max(1, size.width));
|
|
432
|
+
const height = roundToLayoutPx(Math.max(1, size.height));
|
|
433
433
|
element.style.setProperty("box-sizing", "border-box");
|
|
434
434
|
element.style.setProperty("width", `${width}px`);
|
|
435
435
|
element.style.setProperty("height", `${height}px`);
|
|
@@ -457,10 +457,26 @@ export function applyStudioBoxSize(
|
|
|
457
457
|
applyStudioBoxSizeDimensions(element, size);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
// The element's box before a resize draft first changed it; gone once the resize saves or is restored.
|
|
461
|
+
const boxSizeDraftBases = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
462
|
+
|
|
463
|
+
export function readStudioBoxSizeDraftBase(
|
|
464
|
+
element: HTMLElement,
|
|
465
|
+
): { width: number; height: number } | null {
|
|
466
|
+
return boxSizeDraftBases.get(element) ?? null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function forgetStudioBoxSizeDraftBase(element: HTMLElement): void {
|
|
470
|
+
boxSizeDraftBases.delete(element);
|
|
471
|
+
}
|
|
472
|
+
|
|
460
473
|
export function applyStudioBoxSizeDraft(
|
|
461
474
|
element: HTMLElement,
|
|
462
475
|
size: { width: number; height: number },
|
|
463
476
|
): void {
|
|
477
|
+
if (!boxSizeDraftBases.has(element)) {
|
|
478
|
+
boxSizeDraftBases.set(element, { width: element.offsetWidth, height: element.offsetHeight });
|
|
479
|
+
}
|
|
464
480
|
promoteInlineForTransform(element);
|
|
465
481
|
applyStudioBoxSizeDimensions(element, size);
|
|
466
482
|
}
|
|
@@ -509,7 +525,7 @@ function writeStudioRotationVars(
|
|
|
509
525
|
): void {
|
|
510
526
|
prepareStudioRotationBase(element, options.updateBase ?? true);
|
|
511
527
|
element.setAttribute(STUDIO_ROTATION_ATTR, "true");
|
|
512
|
-
element.style.setProperty(STUDIO_ROTATION_PROP, `${
|
|
528
|
+
element.style.setProperty(STUDIO_ROTATION_PROP, `${roundTo3(rotation.angle)}deg`);
|
|
513
529
|
element.style.setProperty("transform-origin", STUDIO_ROTATION_TRANSFORM_ORIGIN);
|
|
514
530
|
}
|
|
515
531
|
|
|
@@ -529,6 +545,6 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
|
|
|
529
545
|
element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
|
|
530
546
|
element.style.setProperty(
|
|
531
547
|
"rotate",
|
|
532
|
-
composeStudioRotationValue(element, `${
|
|
548
|
+
composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
|
|
533
549
|
);
|
|
534
550
|
}
|
|
@@ -3,10 +3,6 @@ export function finiteNumber(value: unknown): number | null {
|
|
|
3
3
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
export function roundRotationAngle(angle: number): number {
|
|
7
|
-
return Math.round(angle * 10) / 10;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
6
|
/* ── File path utilities ──────────────────────────────────────────── */
|
|
11
7
|
function normalizeStudioFileChangePath(path: string): string {
|
|
12
8
|
return path
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
styleUsesStudioSize,
|
|
4
4
|
styleUsesStudioRotation,
|
|
5
5
|
restoreInlineDisplay,
|
|
6
|
+
forgetStudioBoxSizeDraftBase,
|
|
6
7
|
} from "./manualEditsDom";
|
|
7
8
|
import {
|
|
8
9
|
STUDIO_OFFSET_X_PROP,
|
|
@@ -114,6 +115,7 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void {
|
|
118
|
+
forgetStudioBoxSizeDraftBase(element);
|
|
117
119
|
restoreStyleProperty(element, "width", previous.width);
|
|
118
120
|
restoreStyleProperty(element, "height", previous.height);
|
|
119
121
|
restoreStyleProperty(element, "min-width", previous.minWidth);
|
|
@@ -290,6 +292,7 @@ export function clearStudioRotation(element: HTMLElement): void {
|
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
export function clearStudioBoxSize(element: HTMLElement): void {
|
|
295
|
+
forgetStudioBoxSizeDraftBase(element);
|
|
293
296
|
if (
|
|
294
297
|
element.hasAttribute(STUDIO_BOX_SIZE_ATTR) ||
|
|
295
298
|
styleUsesStudioSize(element.style.getPropertyValue("width")) ||
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ManualOffsetDragMatrix,
|
|
14
14
|
} from "./manualOffsetDrag";
|
|
15
15
|
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
|
|
16
|
+
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
16
17
|
|
|
17
18
|
function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
|
|
18
19
|
expect(actual.a).toBeCloseTo(expected.a, 6);
|
|
@@ -449,6 +450,19 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
|
|
|
449
450
|
});
|
|
450
451
|
});
|
|
451
452
|
|
|
453
|
+
describe("sub-pixel drags", () => {
|
|
454
|
+
it("draft and commit both land the exact fractional position", () => {
|
|
455
|
+
const { element, sets, member } = makeGsapDot();
|
|
456
|
+
const m = member();
|
|
457
|
+
applyManualOffsetDragDraft(m, 10.25, -3.5);
|
|
458
|
+
const draft = sets.at(-1)!;
|
|
459
|
+
expect(draft).toEqual({ x: m.baseGsap.x + 10.25, y: m.baseGsap.y - 3.5 });
|
|
460
|
+
const offset = applyManualOffsetDragCommit(m, 10.25, -3.5);
|
|
461
|
+
const { newX, newY } = computeDraggedGsapPosition(element, offset, m.baseGsap);
|
|
462
|
+
expect({ x: newX, y: newY }).toEqual(draft);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
452
466
|
describe("a stylesheet translate after a committed GSAP drag", () => {
|
|
453
467
|
function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
|
|
454
468
|
const window = new Window();
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { FontFamilyField } from "./propertyPanelFont";
|
|
7
|
+
import { sortFontOptions } from "./propertyPanelHelpers";
|
|
8
|
+
|
|
9
|
+
vi.mock("./propertyPanelHelpers", async (importOriginal) => {
|
|
10
|
+
const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
|
|
11
|
+
return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
|
|
12
|
+
});
|
|
7
13
|
|
|
8
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
15
|
|
|
16
|
+
// The font APIs are stubbed and every family is off the Google lists, so no test reaches the network.
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
"fetch",
|
|
20
|
+
vi.fn(async (url: string) => ({
|
|
21
|
+
ok: true,
|
|
22
|
+
json: async () => ({ fonts: url.includes("google") ? ["Roboto Slab"] : ["Arial"] }),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
10
27
|
afterEach(() => {
|
|
11
28
|
document.body.innerHTML = "";
|
|
29
|
+
document.head.innerHTML = "";
|
|
30
|
+
vi.unstubAllGlobals();
|
|
12
31
|
});
|
|
13
32
|
|
|
14
33
|
describe("FontFamilyField flat trigger", () => {
|
|
@@ -17,14 +36,38 @@ describe("FontFamilyField flat trigger", () => {
|
|
|
17
36
|
document.body.append(host);
|
|
18
37
|
const root = createRoot(host);
|
|
19
38
|
act(() => {
|
|
20
|
-
root.render(
|
|
21
|
-
<FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
|
|
22
|
-
);
|
|
39
|
+
root.render(<FontFamilyField flat value="Georgia" importedFonts={[]} onCommit={vi.fn()} />);
|
|
23
40
|
});
|
|
24
41
|
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
25
42
|
expect(trigger).not.toBeNull();
|
|
26
43
|
expect(trigger?.className).not.toContain("border-neutral-800");
|
|
27
|
-
expect(host.textContent).toContain("
|
|
44
|
+
expect(host.textContent).toContain("Georgia");
|
|
28
45
|
act(() => root.unmount());
|
|
29
46
|
});
|
|
30
47
|
});
|
|
48
|
+
|
|
49
|
+
describe("FontFamilyField font list", () => {
|
|
50
|
+
it("builds the list only while the dropdown is open", async () => {
|
|
51
|
+
vi.mocked(sortFontOptions).mockClear();
|
|
52
|
+
const host = document.createElement("div");
|
|
53
|
+
document.body.append(host);
|
|
54
|
+
const root = createRoot(host);
|
|
55
|
+
const render = (value: string) =>
|
|
56
|
+
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
|
|
57
|
+
try {
|
|
58
|
+
await act(async () => render("Arial"));
|
|
59
|
+
// The font lists arrive and the value changes while the dropdown is closed, as during a drag.
|
|
60
|
+
await act(async () => render("Georgia"));
|
|
61
|
+
expect(sortFontOptions).not.toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
64
|
+
await act(async () => trigger?.click());
|
|
65
|
+
expect(sortFontOptions).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(host.textContent).toContain("Roboto Slab");
|
|
67
|
+
expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
|
|
68
|
+
} finally {
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
host.remove();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -276,6 +276,7 @@ export function FontFamilyField({
|
|
|
276
276
|
);
|
|
277
277
|
|
|
278
278
|
const options = useMemo(() => {
|
|
279
|
+
if (!open) return [];
|
|
279
280
|
const documentFonts = collectDocumentFontFamilies();
|
|
280
281
|
const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
|
|
281
282
|
const taggedLocal = localFonts.map(
|
|
@@ -294,7 +295,7 @@ export function FontFamilyField({
|
|
|
294
295
|
...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
|
|
295
296
|
]),
|
|
296
297
|
);
|
|
297
|
-
}, [currentFamily, googleFonts, localFonts, projectFontAssets]);
|
|
298
|
+
}, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
|
|
298
299
|
|
|
299
300
|
const filteredOptions = useMemo(() => {
|
|
300
301
|
const matches = options.filter((o) => fontMatchesQuery(o.family, query));
|
|
@@ -193,7 +193,6 @@ describe("a shift press on a selected box starts the drag", () => {
|
|
|
193
193
|
selectionKey="box"
|
|
194
194
|
groupSelectionCount={0}
|
|
195
195
|
gestures={spies as never}
|
|
196
|
-
onBoxMouseDown={vi.fn()}
|
|
197
196
|
onBoxClick={vi.fn()}
|
|
198
197
|
/>
|
|
199
198
|
));
|
|
@@ -207,7 +206,6 @@ describe("a shift press on a selected box starts the drag", () => {
|
|
|
207
206
|
allowBodyDrag
|
|
208
207
|
groupCanMove={groupCanMove}
|
|
209
208
|
gestures={spies as never}
|
|
210
|
-
onBoxMouseDown={vi.fn()}
|
|
211
209
|
onBoxClick={vi.fn()}
|
|
212
210
|
/>
|
|
213
211
|
));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
3
|
import type { TimelineElement, TimelineTimeRange } from "../../player";
|
|
4
|
+
import type { TimelineProps } from "../../player/components/TimelineTypes";
|
|
4
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
6
|
import { AudioMeterStrip } from "./AudioMeterStrip";
|
|
6
7
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
@@ -45,6 +46,7 @@ export interface TimelinePaneProps {
|
|
|
45
46
|
onPasteClip?: () => Promise<void>;
|
|
46
47
|
onDuplicateClip?: () => Promise<boolean>;
|
|
47
48
|
canPasteClip?: () => boolean;
|
|
49
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export function TimelinePane({
|
|
@@ -64,6 +66,7 @@ export function TimelinePane({
|
|
|
64
66
|
onPasteClip,
|
|
65
67
|
onDuplicateClip,
|
|
66
68
|
canPasteClip,
|
|
69
|
+
clipMenuItems,
|
|
67
70
|
}: TimelinePaneProps) {
|
|
68
71
|
const {
|
|
69
72
|
seek,
|
|
@@ -119,6 +122,7 @@ export function TimelinePane({
|
|
|
119
122
|
onPasteClip={onPasteClip}
|
|
120
123
|
onDuplicateClip={onDuplicateClip}
|
|
121
124
|
canPasteClip={canPasteClip}
|
|
125
|
+
clipMenuItems={clipMenuItems}
|
|
122
126
|
/>
|
|
123
127
|
</div>
|
|
124
128
|
<AudioMeterStrip />
|
|
@@ -12,6 +12,14 @@ export function installReactActEnvironment(): void {
|
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function withInlineLayoutBox<T extends HTMLElement>(el: T): T {
|
|
16
|
+
Object.defineProperties(el, {
|
|
17
|
+
offsetWidth: { get: () => Number.parseFloat(el.style.width) || 0 },
|
|
18
|
+
offsetHeight: { get: () => Number.parseFloat(el.style.height) || 0 },
|
|
19
|
+
});
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
/** Mount a React element into a fresh detached host and return its root. */
|
|
16
24
|
export function mountReactHarness(node: React.ReactElement): Root {
|
|
17
25
|
const host = document.createElement("div");
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { roundTo3 } from "../utils/rounding";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Drag → GSAP position math, shared by the commit path
|
|
3
5
|
* (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and
|
|
@@ -39,8 +41,8 @@ export function computeDraggedGsapPosition(
|
|
|
39
41
|
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
|
|
40
42
|
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
|
|
41
43
|
return {
|
|
42
|
-
newX:
|
|
43
|
-
newY:
|
|
44
|
+
newX: roundTo3(baseGsapX + adjX),
|
|
45
|
+
newY: roundTo3(baseGsapY + adjY),
|
|
44
46
|
baseGsapX,
|
|
45
47
|
baseGsapY,
|
|
46
48
|
};
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "../components/editor/manualEditsTypes";
|
|
11
11
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
12
12
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
13
|
-
import { roundTo3 } from "../utils/rounding";
|
|
13
|
+
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
14
14
|
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
|
|
15
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
16
16
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
@@ -289,8 +289,8 @@ export async function commitStaticGsapSize(
|
|
|
289
289
|
existingSet: GsapAnimation | null,
|
|
290
290
|
callbacks: GsapDragCommitCallbacks,
|
|
291
291
|
): Promise<void> {
|
|
292
|
-
const width =
|
|
293
|
-
const height =
|
|
292
|
+
const width = roundToLayoutPx(size.width);
|
|
293
|
+
const height = roundToLayoutPx(size.height);
|
|
294
294
|
if (existingSet) {
|
|
295
295
|
await callbacks.commitMutation(
|
|
296
296
|
selection,
|
|
@@ -320,7 +320,7 @@ export async function commitStaticGsapSize(
|
|
|
320
320
|
|
|
321
321
|
/** Rounded `n` when it's a positive finite number, else `fallback`. */
|
|
322
322
|
function positiveOr(n: number, fallback: number): number {
|
|
323
|
-
return Number.isFinite(n) && n > 0 ?
|
|
323
|
+
return Number.isFinite(n) && n > 0 ? roundToLayoutPx(n) : fallback;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
/**
|
|
@@ -366,8 +366,8 @@ export async function commitKeyframedSizeFromResize(
|
|
|
366
366
|
const td = resolveTweenDuration(animatedTween);
|
|
367
367
|
if (!(td > 0)) return false;
|
|
368
368
|
|
|
369
|
-
const newW =
|
|
370
|
-
const newH =
|
|
369
|
+
const newW = roundToLayoutPx(size.width);
|
|
370
|
+
const newH = roundToLayoutPx(size.height);
|
|
371
371
|
const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
|
|
372
372
|
|
|
373
373
|
const ct = usePlayerStore.getState().currentTime;
|
|
@@ -67,6 +67,12 @@ export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
|
|
|
67
67
|
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** A move only a shared tween positions is saved on the element itself; a blocked one throws. */
|
|
71
|
+
export async function saveMove(outcome: GsapEditOutcome, saveOnElement: () => Promise<void>) {
|
|
72
|
+
if (outcome.status === "element-offset") return saveOnElement();
|
|
73
|
+
assertGsapEditPersisted(outcome);
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
|
|
71
77
|
const editability = editabilityForProvenance(animation.provenance);
|
|
72
78
|
if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
|
|
@@ -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 {
|
|
@@ -446,7 +447,7 @@ export async function tryGsapRotationIntercept(
|
|
|
446
447
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
447
448
|
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
|
448
449
|
// gesture's live preview already gsap.set this value (single source of truth).
|
|
449
|
-
const newRotation =
|
|
450
|
+
const newRotation = roundTo3(angle);
|
|
450
451
|
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
|
|
451
452
|
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
|
|
452
453
|
// 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,47 @@ 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
|
+
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
|
|
409
|
+
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
|
|
410
|
+
const win = el.ownerDocument.defaultView as {
|
|
411
|
+
__timelines?: Record<string, RuntimeTimeline>;
|
|
412
|
+
} | null;
|
|
413
|
+
return hasNonHoldTween(win?.__timelines ?? null, el, channels);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// fallow-ignore-next-line complexity
|
|
417
|
+
function hasNonHoldTween(
|
|
418
|
+
timelines: Record<string, RuntimeTimeline> | null,
|
|
419
|
+
targetEl: Element,
|
|
420
|
+
channels?: string[],
|
|
421
|
+
compositionId?: string,
|
|
422
|
+
): boolean {
|
|
423
|
+
if (!timelines) return false;
|
|
424
|
+
for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
|
|
425
|
+
// fallow-ignore-next-line code-duplication
|
|
426
|
+
for (const tween of timelines[tlId]?.getChildren?.(true) ?? []) {
|
|
427
|
+
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
428
|
+
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
429
|
+
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
430
|
+
if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
|
|
431
|
+
const read = readTween(tween.vars);
|
|
432
|
+
if (read && (!channels || readCarriesChannel(read, channels))) return true;
|
|
433
|
+
}
|
|
421
434
|
}
|
|
422
435
|
return false;
|
|
423
436
|
}
|