@hyperframes/studio 0.8.109 → 0.8.111
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-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
- package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
- package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
- package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
- package/dist/index.d.ts +22 -6
- package/dist/index.html +2 -2
- package/dist/index.js +5352 -4755
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaLinkEdits.test.ts +19 -6
- package/src/components/editor/mediaLinkEdits.ts +34 -13
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +9 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
- package/src/hooks/timelineLinkEditPlan.ts +6 -1
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useRenderClipContent.ts +3 -5
- package/src/hooks/useTimelineEditing.ts +13 -0
- package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
- package/src/hooks/useTimelineLinkEditing.ts +5 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +41 -0
- package/src/player/components/TimelineClip.tsx +3 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +60 -0
- package/src/player/components/audioClipLink.ts +77 -0
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
- package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
- package/src/player/components/clipMenuAudioItems.tsx +16 -37
- package/src/player/components/clipMenuLinkItems.test.ts +84 -0
- package/src/player/components/clipMenuLinkItems.tsx +54 -9
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +79 -6
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipGestureHandlers.ts +26 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +20 -2
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipDrag.ts +12 -5
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +0 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/studioPendingEdits.test.ts +18 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.111",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
63
|
+
"@hyperframes/player": "0.8.111",
|
|
64
|
+
"@hyperframes/core": "0.8.111",
|
|
65
|
+
"@hyperframes/shader-transitions": "0.8.111",
|
|
66
|
+
"@hyperframes/parsers": "0.8.111",
|
|
67
|
+
"@hyperframes/sdk": "0.8.111",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.111"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.111"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -35,6 +35,19 @@ function makeEl(id: string, clip: string): HTMLElement {
|
|
|
35
35
|
return el;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Presses at the first x, moves through the rest with their buttons, and lets go where the last held move was. */
|
|
39
|
+
function dragCropRight(handle: HTMLElement, pointerId: number, points: [number, number][]) {
|
|
40
|
+
const [[start], ...moves] = points;
|
|
41
|
+
const release = moves.filter(([, buttons]) => buttons & 1).at(-1)?.[0] ?? start;
|
|
42
|
+
const send = (type: string, clientX: number, buttons: number) =>
|
|
43
|
+
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId, buttons, clientX }));
|
|
44
|
+
act(() => {
|
|
45
|
+
send("pointerdown", start, 1);
|
|
46
|
+
for (const [x, buttons] of moves) send("pointermove", x, buttons);
|
|
47
|
+
send("pointerup", release, 0);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
function render(
|
|
39
52
|
el: HTMLElement,
|
|
40
53
|
onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
|
|
@@ -111,7 +124,14 @@ describe("DomEditCropHandles clip lift", () => {
|
|
|
111
124
|
] as const) {
|
|
112
125
|
const at = { clientX: 100 + d * c.dx, clientY: 50 + d * c.dy };
|
|
113
126
|
act(() =>
|
|
114
|
-
handle.dispatchEvent(
|
|
127
|
+
handle.dispatchEvent(
|
|
128
|
+
new PointerEvent(type, {
|
|
129
|
+
bubbles: true,
|
|
130
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
131
|
+
pointerId: 3,
|
|
132
|
+
...at,
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
115
135
|
);
|
|
116
136
|
}
|
|
117
137
|
expect(onStyleCommit).not.toHaveBeenCalled();
|
|
@@ -121,16 +141,23 @@ describe("DomEditCropHandles clip lift", () => {
|
|
|
121
141
|
const onStyleCommit = vi.fn();
|
|
122
142
|
render(makeEl("a", "inset(10px)"), onStyleCommit);
|
|
123
143
|
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
dragCropRight(handle, 4, [
|
|
145
|
+
[100, 1],
|
|
146
|
+
[90, 1],
|
|
147
|
+
[80, 1],
|
|
148
|
+
]);
|
|
149
|
+
expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("commits where the pointer let go, not at a buttonless move back at the press point", () => {
|
|
153
|
+
const onStyleCommit = vi.fn();
|
|
154
|
+
render(makeEl("a", "inset(10px)"), onStyleCommit);
|
|
155
|
+
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
156
|
+
dragCropRight(handle, 6, [
|
|
157
|
+
[100, 1],
|
|
158
|
+
[80, 1],
|
|
159
|
+
[100, 0],
|
|
160
|
+
]);
|
|
134
161
|
expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
|
|
135
162
|
});
|
|
136
163
|
|
|
@@ -138,16 +165,11 @@ describe("DomEditCropHandles clip lift", () => {
|
|
|
138
165
|
const a = makeEl("a", "");
|
|
139
166
|
const { root } = render(a, (property, value) => void a.style.setProperty(property, value));
|
|
140
167
|
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
["pointerup", 80],
|
|
147
|
-
] as const) {
|
|
148
|
-
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 5, clientX }));
|
|
149
|
-
}
|
|
150
|
-
});
|
|
168
|
+
dragCropRight(handle, 5, [
|
|
169
|
+
[100, 1],
|
|
170
|
+
[90, 1],
|
|
171
|
+
[80, 1],
|
|
172
|
+
]);
|
|
151
173
|
await act(async () => undefined);
|
|
152
174
|
act(() => root.unmount());
|
|
153
175
|
expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)");
|
|
@@ -163,7 +185,14 @@ describe("DomEditCropHandles clip lift", () => {
|
|
|
163
185
|
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
164
186
|
const press = (type: string, clientX: number) =>
|
|
165
187
|
act(() =>
|
|
166
|
-
handle.dispatchEvent(
|
|
188
|
+
handle.dispatchEvent(
|
|
189
|
+
new PointerEvent(type, {
|
|
190
|
+
bubbles: true,
|
|
191
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
192
|
+
pointerId: 1,
|
|
193
|
+
clientX,
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
167
196
|
);
|
|
168
197
|
press("pointerdown", 100);
|
|
169
198
|
press("pointermove", 80);
|
|
@@ -17,6 +17,7 @@ import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelp
|
|
|
17
17
|
import { readCropFollowingResize } from "./cropResize";
|
|
18
18
|
import { isCropBarTarget, useCropPresetBarStore } from "./cropPresetStore";
|
|
19
19
|
import { CropPresetBar } from "./CropPresetBar";
|
|
20
|
+
import { movesGesture } from "./domEditOverlayGestures";
|
|
20
21
|
|
|
21
22
|
interface CropGestureState {
|
|
22
23
|
edge: CropEdge | "move";
|
|
@@ -188,7 +189,7 @@ export function DomEditCropHandles({
|
|
|
188
189
|
|
|
189
190
|
const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
190
191
|
const gesture = gestureRef.current;
|
|
191
|
-
if (!gesture || gesture
|
|
192
|
+
if (!gesture || !movesGesture(gesture, event)) return;
|
|
192
193
|
event.preventDefault();
|
|
193
194
|
event.stopPropagation();
|
|
194
195
|
const local = rotateDeltaIntoFrame(
|
|
@@ -182,7 +182,10 @@ async function settle() {
|
|
|
182
182
|
|
|
183
183
|
const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
184
184
|
act(() => {
|
|
185
|
-
|
|
185
|
+
const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
|
|
186
|
+
target.dispatchEvent(
|
|
187
|
+
new MouseEvent(type, { bubbles: true, cancelable: true, buttons, ...init }),
|
|
188
|
+
);
|
|
186
189
|
});
|
|
187
190
|
};
|
|
188
191
|
|
|
@@ -49,6 +49,7 @@ export interface GestureState {
|
|
|
49
49
|
kind: GestureKind;
|
|
50
50
|
mode: "path-offset" | "box-size" | "rotation";
|
|
51
51
|
selection: DomEditSelection;
|
|
52
|
+
pointerId: number;
|
|
52
53
|
startX: number;
|
|
53
54
|
startY: number;
|
|
54
55
|
centerX: number;
|
|
@@ -93,6 +94,7 @@ export interface GestureState {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export interface GroupGestureState {
|
|
97
|
+
pointerId: number;
|
|
96
98
|
startX: number;
|
|
97
99
|
startY: number;
|
|
98
100
|
originItems: GroupOverlayItem[];
|
|
@@ -103,6 +105,14 @@ export interface GroupGestureState {
|
|
|
103
105
|
travelled?: boolean;
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/** Only the pressing pointer's moves with its button held drive a gesture, not Chromium's buttonless resends. */
|
|
109
|
+
export function movesGesture(
|
|
110
|
+
gesture: { pointerId: number },
|
|
111
|
+
e: { pointerId: number; buttons: number },
|
|
112
|
+
): boolean {
|
|
113
|
+
return e.pointerId === gesture.pointerId && (e.buttons & 1) === 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
export interface BlockedMoveState {
|
|
107
117
|
pointerId: number;
|
|
108
118
|
startX: number;
|
|
@@ -119,6 +119,7 @@ export function startGroupDrag(
|
|
|
119
119
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
120
120
|
opts.rafPausedRef.current = true;
|
|
121
121
|
opts.groupGestureRef.current = {
|
|
122
|
+
pointerId: e.pointerId,
|
|
122
123
|
startX: e.clientX,
|
|
123
124
|
startY: e.clientY,
|
|
124
125
|
originItems: items,
|
|
@@ -265,6 +266,7 @@ export function startGesture(
|
|
|
265
266
|
kind,
|
|
266
267
|
mode,
|
|
267
268
|
selection: sel,
|
|
269
|
+
pointerId: e.pointerId,
|
|
268
270
|
startX: e.clientX,
|
|
269
271
|
startY: e.clientY,
|
|
270
272
|
centerX,
|
|
@@ -66,8 +66,24 @@ const byId = (iframe: HTMLIFrameElement | null, id: string) =>
|
|
|
66
66
|
iframe!.contentDocument!.getElementById(id) as HTMLElement;
|
|
67
67
|
const api = (): TimelinePlayerApi => player.getApi();
|
|
68
68
|
|
|
69
|
-
function pointer(
|
|
70
|
-
|
|
69
|
+
function pointer(
|
|
70
|
+
target: Element,
|
|
71
|
+
type: string,
|
|
72
|
+
x: number,
|
|
73
|
+
y: number,
|
|
74
|
+
extra: PointerEventInit = {},
|
|
75
|
+
) {
|
|
76
|
+
const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
|
|
77
|
+
const init = {
|
|
78
|
+
bubbles: true,
|
|
79
|
+
cancelable: true,
|
|
80
|
+
button: 0,
|
|
81
|
+
buttons,
|
|
82
|
+
pointerId: 1,
|
|
83
|
+
clientX: x,
|
|
84
|
+
clientY: y,
|
|
85
|
+
...extra,
|
|
86
|
+
};
|
|
71
87
|
act(() => void target.dispatchEvent(new PointerEvent(type, init)));
|
|
72
88
|
}
|
|
73
89
|
|
|
@@ -252,6 +268,27 @@ describe("a reload during a drag", () => {
|
|
|
252
268
|
});
|
|
253
269
|
});
|
|
254
270
|
|
|
271
|
+
describe("a drag moves only with its own pointer pressed", () => {
|
|
272
|
+
it.each([
|
|
273
|
+
["no button held, as Chromium sends after a layout change", { buttons: 0 }],
|
|
274
|
+
["another pointer", { pointerId: 2 }],
|
|
275
|
+
])(
|
|
276
|
+
"a move with %s at a fixed point leaves the drop where the pointer let go",
|
|
277
|
+
async (_, stray) => {
|
|
278
|
+
const { live, overlay, box } = mountEditor(false);
|
|
279
|
+
pointer(box, "pointerdown", 150, 150);
|
|
280
|
+
pointer(overlay, "pointermove", 170, 160);
|
|
281
|
+
pointer(overlay, "pointermove", 20, 20, stray);
|
|
282
|
+
pointer(overlay, "pointermove", 190, 170);
|
|
283
|
+
pointer(overlay, "pointermove", 20, 20, stray);
|
|
284
|
+
expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
285
|
+
pointer(overlay, "pointerup", 190, 170);
|
|
286
|
+
await settle();
|
|
287
|
+
expect(file.title).toBe("translate: 40px 20px");
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
255
292
|
type Editor = ReturnType<typeof mountEditor>;
|
|
256
293
|
|
|
257
294
|
describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
|
|
@@ -35,18 +35,23 @@ function Overlay() {
|
|
|
35
35
|
onPointerMove={marquee.onPointerMove}
|
|
36
36
|
onPointerUp={marquee.onPointerUp}
|
|
37
37
|
>
|
|
38
|
-
{marquee.marqueeRect && <div data-band />}
|
|
38
|
+
{marquee.marqueeRect && <div data-band data-width={marquee.marqueeRect.width} />}
|
|
39
39
|
</div>
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const pointer = (type: string, clientX: number, clientY: number) =>
|
|
44
44
|
act(() => {
|
|
45
|
-
document
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
document.querySelector("[data-overlay]")!.dispatchEvent(
|
|
46
|
+
new PointerEvent(type, {
|
|
47
|
+
bubbles: true,
|
|
48
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
49
|
+
button: 0,
|
|
50
|
+
pointerId: 1,
|
|
51
|
+
clientX,
|
|
52
|
+
clientY,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
50
55
|
});
|
|
51
56
|
const escape = () => {
|
|
52
57
|
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
|
@@ -86,6 +91,25 @@ it("an escape cancels a preview band while the pointer is still down, and stops
|
|
|
86
91
|
expect(hostEscapes).toHaveLength(0);
|
|
87
92
|
});
|
|
88
93
|
|
|
94
|
+
it("a buttonless move back at the start does not shrink the band", () => {
|
|
95
|
+
pointer("pointerdown", 10, 10);
|
|
96
|
+
pointer("pointermove", 120, 90);
|
|
97
|
+
const band = () => document.querySelector<HTMLElement>("[data-band]")?.dataset.width;
|
|
98
|
+
expect(band()).toBe("110");
|
|
99
|
+
act(() => {
|
|
100
|
+
document.querySelector("[data-overlay]")!.dispatchEvent(
|
|
101
|
+
new PointerEvent("pointermove", {
|
|
102
|
+
bubbles: true,
|
|
103
|
+
pointerId: 1,
|
|
104
|
+
buttons: 0,
|
|
105
|
+
clientX: 10,
|
|
106
|
+
clientY: 10,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
expect(band()).toBe("110");
|
|
111
|
+
});
|
|
112
|
+
|
|
89
113
|
it("an escape with no band in flight still reaches the host", () => {
|
|
90
114
|
const event = escape();
|
|
91
115
|
|
|
@@ -133,6 +157,7 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
|
|
|
133
157
|
new PointerEvent(type, {
|
|
134
158
|
bubbles: true,
|
|
135
159
|
button: 0,
|
|
160
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
136
161
|
pointerId: 1,
|
|
137
162
|
clientX: x,
|
|
138
163
|
clientY: y,
|
|
@@ -6,6 +6,7 @@ import { isElementComputedVisible } from "./domEditingElement";
|
|
|
6
6
|
import { coversComposition } from "../../utils/studioPreviewHelpers";
|
|
7
7
|
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
|
8
8
|
import { toVisibleOverlayRect } from "./domEditOverlayGeometry";
|
|
9
|
+
import { movesGesture } from "./domEditOverlayGestures";
|
|
9
10
|
|
|
10
11
|
interface MarqueeState {
|
|
11
12
|
startX: number;
|
|
@@ -226,10 +227,23 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
|
|
226
227
|
window.removeEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
|
|
227
228
|
}, [cancel]);
|
|
228
229
|
|
|
230
|
+
const showCandidates = useCallback(
|
|
231
|
+
(rect: Rect) => {
|
|
232
|
+
const iframe = deps.iframeRef.current;
|
|
233
|
+
const overlay = deps.overlayRef.current;
|
|
234
|
+
if (!iframe || !overlay) return;
|
|
235
|
+
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
236
|
+
candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
|
|
237
|
+
setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
|
|
238
|
+
},
|
|
239
|
+
[deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
|
|
240
|
+
);
|
|
241
|
+
|
|
229
242
|
const onPointerMove = useCallback(
|
|
230
243
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
231
244
|
const m = marqueeRef.current;
|
|
232
245
|
if (m) {
|
|
246
|
+
if (!movesGesture(m, event)) return;
|
|
233
247
|
const oRect = deps.overlayRef.current?.getBoundingClientRect();
|
|
234
248
|
if (!oRect) return;
|
|
235
249
|
m.currentX = event.clientX - oRect.left;
|
|
@@ -248,18 +262,12 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
|
|
248
262
|
height: Math.abs(m.currentY - m.startY),
|
|
249
263
|
};
|
|
250
264
|
setMarqueeRect(rect);
|
|
251
|
-
|
|
252
|
-
const overlay = deps.overlayRef.current;
|
|
253
|
-
if (iframe && overlay) {
|
|
254
|
-
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
255
|
-
candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
|
|
256
|
-
setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
|
|
257
|
-
}
|
|
265
|
+
showCandidates(rect);
|
|
258
266
|
return;
|
|
259
267
|
}
|
|
260
268
|
deps.gestures?.onPointerMove(event);
|
|
261
269
|
},
|
|
262
|
-
[deps.gestures, deps.overlayRef,
|
|
270
|
+
[deps.gestures, deps.overlayRef, showCandidates],
|
|
263
271
|
);
|
|
264
272
|
|
|
265
273
|
const onPointerUp = useCallback(
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
pickDetachedAudioTrack,
|
|
11
11
|
removeElementInSource,
|
|
12
12
|
setLinkInSource,
|
|
13
|
+
sharesSourceFile,
|
|
13
14
|
} from "./mediaLinkEdits";
|
|
14
15
|
|
|
15
16
|
const automation = JSON.stringify({
|
|
@@ -192,6 +193,15 @@ describe("linkInSource", () => {
|
|
|
192
193
|
});
|
|
193
194
|
});
|
|
194
195
|
|
|
196
|
+
describe("linkInSource without a sync origin", () => {
|
|
197
|
+
it("writes only data-link", () => {
|
|
198
|
+
const src = '<video id="v"></video><audio id="a"></audio>';
|
|
199
|
+
expect(linkInSource(src, [{ id: "v" }, { id: "a" }], { syncOrigin: false })).toBe(
|
|
200
|
+
'<video id="v" data-link="lk-1"></video><audio id="a" data-link="lk-1"></audio>',
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
195
205
|
describe("setLinkInSource", () => {
|
|
196
206
|
it("writes and removes data-link on every target", () => {
|
|
197
207
|
const src = '<video id="v"></video><audio id="a"></audio>';
|
|
@@ -248,9 +258,12 @@ describe("predicates", () => {
|
|
|
248
258
|
expect(findMergePair(v, [v, el("a", "audio", { src: "other.mp4" })])).toBeNull();
|
|
249
259
|
});
|
|
250
260
|
|
|
251
|
-
it("links exactly one video and one audio
|
|
261
|
+
it("links exactly one unlinked video and one audio, whatever their timing or file", () => {
|
|
252
262
|
expect(canLinkPair([el("v", "video"), el("a", "audio")])).toBe(true);
|
|
253
|
-
expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3 })])).toBe(
|
|
263
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3, duration: 2 })])).toBe(true);
|
|
264
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio", { src: "other.mp3" })])).toBe(true);
|
|
265
|
+
expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio")])).toBe(false);
|
|
266
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio", { link: "y" })])).toBe(false);
|
|
254
267
|
expect(canLinkPair([el("v", "video"), el("w", "video")])).toBe(false);
|
|
255
268
|
expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio", { link: "x" })])).toBe(
|
|
256
269
|
false,
|
|
@@ -266,16 +279,16 @@ describe("predicates compare the whole asset path", () => {
|
|
|
266
279
|
const v = el("v", "video", { muted: true, ...one });
|
|
267
280
|
const a = el("a", "audio", two);
|
|
268
281
|
expect(findMergePair(v, [v, a])).toBeNull();
|
|
269
|
-
expect(
|
|
282
|
+
expect(sharesSourceFile([el("v", "video", one), el("a", "audio", two)])).toBe(false);
|
|
270
283
|
});
|
|
271
284
|
|
|
272
285
|
it("resolves each src against its own source file, and links only inside one", () => {
|
|
273
286
|
const v = el("v", "video", { src: "../assets/talk.mp4", sourceFile: "scenes/a.html" });
|
|
274
287
|
const a = el("a", "audio", { src: "./assets/talk.mp4", sourceFile: "index.html" });
|
|
275
288
|
expect(canLinkPair([v, a])).toBe(false);
|
|
276
|
-
expect(
|
|
277
|
-
|
|
278
|
-
).toBe(true);
|
|
289
|
+
expect(sharesSourceFile([v, a])).toBe(false);
|
|
290
|
+
const sameFileAudio = { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" };
|
|
291
|
+
expect(sharesSourceFile([v, sameFileAudio])).toBe(true);
|
|
279
292
|
});
|
|
280
293
|
|
|
281
294
|
it("does not take a same-id link from another source file as the merge partner", () => {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MEDIA_LINK_ATTR,
|
|
4
|
+
SYNC_ORIGIN_ATTR,
|
|
5
|
+
mintLinkId,
|
|
6
|
+
sourceZeroTime,
|
|
7
|
+
} from "@hyperframes/core/media-link";
|
|
3
8
|
import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
|
|
4
9
|
import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
|
|
5
10
|
import {
|
|
@@ -62,14 +67,17 @@ export function setLinkInSource(
|
|
|
62
67
|
);
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
/** Link the targets under one
|
|
66
|
-
export function linkInSource(
|
|
70
|
+
/** Link the targets under one fresh id, also their sync origin when they share a source file. */
|
|
71
|
+
export function linkInSource(
|
|
72
|
+
source: string,
|
|
73
|
+
targets: readonly PatchTarget[],
|
|
74
|
+
options: { syncOrigin: boolean } = { syncOrigin: true },
|
|
75
|
+
): string {
|
|
67
76
|
const linkId = mintLinkIdForSource(source);
|
|
68
|
-
|
|
69
|
-
(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
77
|
+
const ops = options.syncOrigin
|
|
78
|
+
? [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]
|
|
79
|
+
: [dataOp(LINK_PROPERTY, linkId)];
|
|
80
|
+
return targets.reduce((html, target) => applyOps(html, target, ops), source);
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
/** Remove one element (open tag through its matching close tag) and its line's indentation. */
|
|
@@ -242,15 +250,28 @@ export function findMergePair<T extends TimedElement>(
|
|
|
242
250
|
return video.muted === true ? { video, audio } : null;
|
|
243
251
|
}
|
|
244
252
|
|
|
245
|
-
/**
|
|
253
|
+
/** One video and one audio in one composition, neither linked: timing and file don't matter. */
|
|
246
254
|
export function canLinkPair(selected: readonly TimedElement[]): boolean {
|
|
247
255
|
if (selected.length !== 2) return false;
|
|
248
256
|
const [a, b] = selected;
|
|
249
|
-
if (!a || !b) return false;
|
|
257
|
+
if (!a || !b || a.link || b.link) return false;
|
|
250
258
|
const tags = new Set([tagOf(a), tagOf(b)]);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
259
|
+
return tags.has("video") && tags.has("audio") && sameCompositionScope(a, b);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function sharesSourceFile(selected: readonly TimedElement[]): boolean {
|
|
263
|
+
const [a, b] = selected;
|
|
264
|
+
return selected.length === 2 && !!a && !!b && sameAssetInScope(a, b);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function isPairInSync(a: TimedElement, b: TimedElement): boolean {
|
|
268
|
+
const timing = (el: TimedElement) => ({
|
|
269
|
+
start: el.start,
|
|
270
|
+
mediaStart: el.playbackStart ?? 0,
|
|
271
|
+
playbackRate: el.playbackRate ?? 1,
|
|
272
|
+
});
|
|
273
|
+
const [x, y] = [timing(a), timing(b)];
|
|
274
|
+
return near(x.playbackRate, y.playbackRate) && near(sourceZeroTime(x), sourceZeroTime(y));
|
|
254
275
|
}
|
|
255
276
|
|
|
256
277
|
type TrackedElement = Pick<TimelineElement, "tag" | "track" | "start" | "duration"> & {
|
|
@@ -105,7 +105,14 @@ function fixture(
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
108
|
-
const
|
|
108
|
+
const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
|
|
109
|
+
const event = new MouseEvent(type, {
|
|
110
|
+
bubbles: true,
|
|
111
|
+
cancelable: true,
|
|
112
|
+
button: 0,
|
|
113
|
+
buttons,
|
|
114
|
+
...init,
|
|
115
|
+
});
|
|
109
116
|
act(() => {
|
|
110
117
|
target.dispatchEvent(event);
|
|
111
118
|
});
|
|
@@ -31,7 +31,7 @@ function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestu
|
|
|
31
31
|
};
|
|
32
32
|
const handlers = createDomEditOverlayGestureHandlers(opts as never);
|
|
33
33
|
const pointer = (clientX: number, clientY: number, shiftKey: boolean) =>
|
|
34
|
-
({ clientX, clientY, shiftKey, altKey: false }) as never;
|
|
34
|
+
({ clientX, clientY, shiftKey, altKey: false, pointerId: 1, buttons: 1 }) as never;
|
|
35
35
|
return { opts, handlers, pointer };
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -41,6 +41,7 @@ function singleDrag() {
|
|
|
41
41
|
{
|
|
42
42
|
kind: "drag",
|
|
43
43
|
mode: "path-offset",
|
|
44
|
+
pointerId: 1,
|
|
44
45
|
selection: { element } as unknown as DomEditSelection,
|
|
45
46
|
startX: 100,
|
|
46
47
|
startY: 100,
|
|
@@ -57,7 +58,13 @@ function singleDrag() {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
const groupGesture = () =>
|
|
60
|
-
({
|
|
61
|
+
({
|
|
62
|
+
pointerId: 1,
|
|
63
|
+
startX: 100,
|
|
64
|
+
startY: 100,
|
|
65
|
+
originItems: [],
|
|
66
|
+
members: [],
|
|
67
|
+
}) as unknown as GroupGestureState;
|
|
61
68
|
|
|
62
69
|
describe("shift+drag locks a move to the axis the pointer travels further on", () => {
|
|
63
70
|
it("keeps a mostly horizontal single drag on its row", () => {
|
|
@@ -278,6 +285,7 @@ function singleMemberDrag() {
|
|
|
278
285
|
{
|
|
279
286
|
kind: "drag",
|
|
280
287
|
mode: "path-offset",
|
|
288
|
+
pointerId: 1,
|
|
281
289
|
selection,
|
|
282
290
|
startX: 100,
|
|
283
291
|
startY: 100,
|
|
@@ -300,7 +308,13 @@ function singleMemberDrag() {
|
|
|
300
308
|
function groupMemberDrag() {
|
|
301
309
|
const a = draggableMember("a");
|
|
302
310
|
const b = draggableMember("b");
|
|
303
|
-
const group = {
|
|
311
|
+
const group = {
|
|
312
|
+
pointerId: 1,
|
|
313
|
+
startX: 100,
|
|
314
|
+
startY: 100,
|
|
315
|
+
originItems: [],
|
|
316
|
+
members: [a.member, b.member],
|
|
317
|
+
};
|
|
304
318
|
const harness = gestureHarness(null, group as unknown as GroupGestureState);
|
|
305
319
|
return { ...harness, at: () => [a.at(), b.at()] };
|
|
306
320
|
}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
ROTATED_SNAP_BYPASS_DEGREES,
|
|
39
39
|
hasDomEditRotationChanged,
|
|
40
40
|
lockDragToDominantAxis,
|
|
41
|
+
movesGesture,
|
|
41
42
|
resolveDomEditRotationGesture,
|
|
42
43
|
} from "./domEditOverlayGestures";
|
|
43
44
|
import { resolveCenterResizeSize } from "./domEditResizeLocal";
|
|
@@ -131,6 +132,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
131
132
|
if (!blockedMove && !g && !groupG) {
|
|
132
133
|
opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
|
|
133
134
|
}
|
|
135
|
+
const held = g ?? groupG;
|
|
136
|
+
if (held && !movesGesture(held, e)) return;
|
|
134
137
|
|
|
135
138
|
if (blockedMove) {
|
|
136
139
|
const dx = e.clientX - blockedMove.startX;
|
|
@@ -72,6 +72,7 @@ export interface TimelineEditCallbackDeps {
|
|
|
72
72
|
value: string | null,
|
|
73
73
|
label: string,
|
|
74
74
|
) => Promise<TimelineEditOutcome | void>;
|
|
75
|
+
setMany?: TimelineEditCallbacks["onSetElementsAttributeQuiet"];
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -228,6 +229,7 @@ export function useTimelineEditCallbacks({
|
|
|
228
229
|
onGroupClips: handleGroupClips,
|
|
229
230
|
onSetElementAttributeLive: setElementFxAttribute?.setLive,
|
|
230
231
|
onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
|
|
232
|
+
onSetElementsAttributeQuiet: setElementFxAttribute?.setMany,
|
|
231
233
|
onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
|
|
232
234
|
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
|
233
235
|
onSplitElement: handleTimelineElementSplit,
|
|
@@ -37,6 +37,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
|
|
|
37
37
|
onGroupClips: true,
|
|
38
38
|
onSetElementAttributeLive: true,
|
|
39
39
|
onSetElementAttributeQuiet: true,
|
|
40
|
+
onSetElementsAttributeQuiet: true,
|
|
40
41
|
onRevertElementAttributeLive: true,
|
|
41
42
|
onBlockedEditAttempt: true,
|
|
42
43
|
onLinkEdit: true,
|