@hyperframes/studio 0.8.82 → 0.8.83
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-zGIbZhLJ.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-BiLCTunu.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-DSazIt7R.js → index-BYhK_KMc.js} +193 -193
- package/dist/assets/{index-DStmfnpZ.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +669 -330
- package/dist/index.html +1 -1
- package/dist/index.js +20501 -23167
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +7 -43
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/useElementLifecycleOps.ts +1 -1
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/index.ts +45 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/playerEntry.ts +1 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -14,6 +14,7 @@ interface MarqueeState {
|
|
|
14
14
|
currentY: number;
|
|
15
15
|
pointerId: number;
|
|
16
16
|
pastThreshold: boolean;
|
|
17
|
+
target: Element;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const MARQUEE_THRESHOLD_PX = 4;
|
|
@@ -80,14 +81,13 @@ function hitsWithin(rect: Rect, candidates: MarqueeHit[]): MarqueeHit[] {
|
|
|
80
81
|
return candidates.filter((candidate) => rectsOverlap(rect, candidate.rect));
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
async function
|
|
84
|
-
|
|
85
|
-
candidates: MarqueeHit[],
|
|
84
|
+
async function resolveDomEditSelections(
|
|
85
|
+
elements: HTMLElement[],
|
|
86
86
|
activeCompositionPath: string,
|
|
87
87
|
): Promise<DomEditSelection[]> {
|
|
88
88
|
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
|
|
89
89
|
const hits: DomEditSelection[] = [];
|
|
90
|
-
for (const
|
|
90
|
+
for (const element of elements) {
|
|
91
91
|
const sel = await resolveDomEditSelection(element, {
|
|
92
92
|
activeCompositionPath,
|
|
93
93
|
isMasterView,
|
|
@@ -98,23 +98,48 @@ async function runMarqueeIntersection(
|
|
|
98
98
|
return hits;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
interface MarqueeGesturesDeps {
|
|
101
|
+
export interface MarqueeGesturesDeps<T = DomEditSelection> {
|
|
102
102
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
103
103
|
overlayRef: React.RefObject<HTMLDivElement | null>;
|
|
104
104
|
activeCompositionPathRef: React.RefObject<string | null>;
|
|
105
|
-
onMarqueeSelectRef: React.RefObject<
|
|
106
|
-
|
|
107
|
-
>;
|
|
108
|
-
selectionRef
|
|
109
|
-
|
|
105
|
+
onMarqueeSelectRef: React.RefObject<((selections: T[], additive: boolean) => void) | undefined>;
|
|
106
|
+
/** Turns the elements a drag touched into picks; without it, Studio's edit selections. */
|
|
107
|
+
resolveHits?: (elements: HTMLElement[]) => T[] | Promise<T[]>;
|
|
108
|
+
selectionRef?: React.RefObject<DomEditSelection | null>;
|
|
109
|
+
/** Studio's pointer handling, for the events that are not part of a marquee. */
|
|
110
|
+
gestures?: {
|
|
110
111
|
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
111
112
|
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
112
113
|
clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
export interface MarqueeGestures {
|
|
118
|
+
marqueeRect: Rect | null;
|
|
119
|
+
candidateRects: Rect[];
|
|
120
|
+
/** Starts a marquee at this press; the host has decided it landed on empty canvas. */
|
|
121
|
+
begin: (event: React.PointerEvent<HTMLElement>) => void;
|
|
122
|
+
/** Drops an active marquee without selecting anything. */
|
|
123
|
+
cancel: () => void;
|
|
124
|
+
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
125
|
+
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
126
|
+
onPointerCancel: () => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function releaseCapture(m: MarqueeState): void {
|
|
130
|
+
try {
|
|
131
|
+
m.target.releasePointerCapture(m.pointerId);
|
|
132
|
+
} catch {
|
|
133
|
+
/* already released */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function useMarqueeGestures(deps: MarqueeGesturesDeps): MarqueeGestures;
|
|
138
|
+
export function useMarqueeGestures<T>(
|
|
139
|
+
deps: MarqueeGesturesDeps<T> & Required<Pick<MarqueeGesturesDeps<T>, "resolveHits">>,
|
|
140
|
+
): MarqueeGestures;
|
|
116
141
|
// fallow-ignore-next-line complexity
|
|
117
|
-
export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
|
142
|
+
export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGestures {
|
|
118
143
|
const marqueeRef = useRef<MarqueeState | null>(null);
|
|
119
144
|
const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
|
|
120
145
|
// Live "candidate" highlight: the elements the marquee currently touches,
|
|
@@ -135,12 +160,55 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
|
|
135
160
|
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
|
|
136
161
|
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
137
162
|
const candidates = candidatesRef.current ?? collectMarqueeCandidates(iframe, overlay, acp);
|
|
138
|
-
const
|
|
139
|
-
deps.
|
|
163
|
+
const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
|
|
164
|
+
const resolveHits = deps.resolveHits;
|
|
165
|
+
const picks = resolveHits
|
|
166
|
+
? await resolveHits(elements)
|
|
167
|
+
: ((await resolveDomEditSelections(elements, acp)) as T[]);
|
|
168
|
+
deps.onMarqueeSelectRef.current?.(picks, additive);
|
|
140
169
|
},
|
|
141
|
-
[
|
|
170
|
+
[
|
|
171
|
+
deps.iframeRef,
|
|
172
|
+
deps.overlayRef,
|
|
173
|
+
deps.onMarqueeSelectRef,
|
|
174
|
+
deps.activeCompositionPathRef,
|
|
175
|
+
deps.resolveHits,
|
|
176
|
+
],
|
|
142
177
|
);
|
|
143
178
|
|
|
179
|
+
const reset = useCallback(() => {
|
|
180
|
+
marqueeRef.current = null;
|
|
181
|
+
setMarqueeRect(null);
|
|
182
|
+
setCandidateRects([]);
|
|
183
|
+
candidatesRef.current = null;
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
const begin = useCallback(
|
|
187
|
+
(event: React.PointerEvent<HTMLElement>) => {
|
|
188
|
+
const oRect = deps.overlayRef.current?.getBoundingClientRect();
|
|
189
|
+
if (!oRect) return;
|
|
190
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
191
|
+
const x = event.clientX - oRect.left;
|
|
192
|
+
const y = event.clientY - oRect.top;
|
|
193
|
+
marqueeRef.current = {
|
|
194
|
+
startX: x,
|
|
195
|
+
startY: y,
|
|
196
|
+
currentX: x,
|
|
197
|
+
currentY: y,
|
|
198
|
+
pointerId: event.pointerId,
|
|
199
|
+
pastThreshold: false,
|
|
200
|
+
target: event.currentTarget,
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
[deps.overlayRef],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const cancel = useCallback(() => {
|
|
207
|
+
if (!marqueeRef.current) return;
|
|
208
|
+
releaseCapture(marqueeRef.current);
|
|
209
|
+
reset();
|
|
210
|
+
}, [reset]);
|
|
211
|
+
|
|
144
212
|
const onPointerMove = useCallback(
|
|
145
213
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
146
214
|
const m = marqueeRef.current;
|
|
@@ -172,7 +240,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
|
|
172
240
|
}
|
|
173
241
|
return;
|
|
174
242
|
}
|
|
175
|
-
deps.gestures
|
|
243
|
+
deps.gestures?.onPointerMove(event);
|
|
176
244
|
},
|
|
177
245
|
[deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
|
|
178
246
|
);
|
|
@@ -181,12 +249,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
|
|
181
249
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
182
250
|
const m = marqueeRef.current;
|
|
183
251
|
if (m) {
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
(event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
|
|
187
|
-
} catch {
|
|
188
|
-
/* already released */
|
|
189
|
-
}
|
|
252
|
+
releaseCapture(m);
|
|
190
253
|
if (m.pastThreshold) {
|
|
191
254
|
commitMarquee(
|
|
192
255
|
{
|
|
@@ -200,26 +263,26 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
|
|
200
263
|
} else {
|
|
201
264
|
deps.onMarqueeSelectRef.current?.([], false);
|
|
202
265
|
}
|
|
203
|
-
|
|
204
|
-
setCandidateRects([]);
|
|
205
|
-
candidatesRef.current = null;
|
|
266
|
+
reset();
|
|
206
267
|
return;
|
|
207
268
|
}
|
|
208
|
-
deps.gestures
|
|
269
|
+
deps.gestures?.onPointerUp(event);
|
|
209
270
|
},
|
|
210
|
-
[deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
|
|
271
|
+
[deps.gestures, commitMarquee, deps.onMarqueeSelectRef, reset],
|
|
211
272
|
);
|
|
212
273
|
|
|
213
274
|
const onPointerCancel = useCallback(() => {
|
|
214
|
-
if (marqueeRef.current)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
275
|
+
if (marqueeRef.current) return cancel();
|
|
276
|
+
if (deps.selectionRef) deps.gestures?.clearPointerState(deps.selectionRef);
|
|
277
|
+
}, [deps.gestures, deps.selectionRef, cancel]);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
marqueeRect,
|
|
281
|
+
candidateRects,
|
|
282
|
+
begin,
|
|
283
|
+
cancel,
|
|
284
|
+
onPointerMove,
|
|
285
|
+
onPointerUp,
|
|
286
|
+
onPointerCancel,
|
|
287
|
+
};
|
|
225
288
|
}
|
|
@@ -13,7 +13,19 @@ import { DomEditOverlay } from "./DomEditOverlay";
|
|
|
13
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
14
|
|
|
15
15
|
const RECT = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
|
|
16
|
-
const layout = vi.hoisted(() => ({
|
|
16
|
+
const layout = vi.hoisted(() => ({
|
|
17
|
+
group: [] as unknown[],
|
|
18
|
+
hover: null as unknown,
|
|
19
|
+
offCanvas: [] as unknown[],
|
|
20
|
+
offCanvasElements: new Map<string, HTMLElement>(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("./useOffCanvasIndicators", () => ({
|
|
24
|
+
useOffCanvasIndicators: () => ({
|
|
25
|
+
offCanvasRects: layout.offCanvas,
|
|
26
|
+
offCanvasElementsRef: { current: layout.offCanvasElements },
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
17
29
|
|
|
18
30
|
vi.mock("./useDomEditOverlayRects", () => ({
|
|
19
31
|
useDomEditOverlayRects: () => ({
|
|
@@ -22,7 +34,7 @@ vi.mock("./useDomEditOverlayRects", () => ({
|
|
|
22
34
|
current: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
|
|
23
35
|
},
|
|
24
36
|
setOverlayRect: () => undefined,
|
|
25
|
-
hoverRect:
|
|
37
|
+
hoverRect: layout.hover,
|
|
26
38
|
groupOverlayItems: layout.group,
|
|
27
39
|
groupOverlayItemsRef: { current: layout.group },
|
|
28
40
|
setGroupOverlayItems: () => undefined,
|
|
@@ -57,6 +69,8 @@ function fixture(
|
|
|
57
69
|
onStyleCommit: vi.fn(),
|
|
58
70
|
onDeleteSelection: vi.fn(),
|
|
59
71
|
onApplyZIndex: vi.fn(),
|
|
72
|
+
onMarqueeSelect: vi.fn(),
|
|
73
|
+
onTextEditingChange: vi.fn(),
|
|
60
74
|
};
|
|
61
75
|
const selection = makeSelection("Title", textElement("title"));
|
|
62
76
|
selection.capabilities.canApplyManualRotation = true;
|
|
@@ -92,6 +106,33 @@ const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
|
92
106
|
return event;
|
|
93
107
|
};
|
|
94
108
|
|
|
109
|
+
const pressEnter = (overlay: HTMLElement) =>
|
|
110
|
+
act(() => {
|
|
111
|
+
overlay.dispatchEvent(
|
|
112
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const clickBox = (overlay: HTMLElement) => {
|
|
117
|
+
const box = overlay.querySelector(BOX)!;
|
|
118
|
+
for (const type of ["pointerdown", "pointerup", "click"]) fire(box, type);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const enterOn = (target: Element) =>
|
|
122
|
+
act(() => {
|
|
123
|
+
target.dispatchEvent(
|
|
124
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const rightClick = async (overlay: HTMLElement) => {
|
|
129
|
+
await act(async () => {
|
|
130
|
+
overlay.dispatchEvent(
|
|
131
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
95
136
|
beforeEach(() => {
|
|
96
137
|
vi.useFakeTimers();
|
|
97
138
|
__resetForTests();
|
|
@@ -102,6 +143,9 @@ afterEach(() => {
|
|
|
102
143
|
act(() => root.unmount());
|
|
103
144
|
document.body.innerHTML = "";
|
|
104
145
|
layout.group = [];
|
|
146
|
+
layout.hover = null;
|
|
147
|
+
layout.offCanvas = [];
|
|
148
|
+
layout.offCanvasElements.clear();
|
|
105
149
|
vi.useRealTimers();
|
|
106
150
|
});
|
|
107
151
|
|
|
@@ -196,13 +240,6 @@ describe("DomEditOverlay with the preview read-only", () => {
|
|
|
196
240
|
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
197
241
|
});
|
|
198
242
|
|
|
199
|
-
const pressEnter = (overlay: HTMLElement) =>
|
|
200
|
-
act(() => {
|
|
201
|
-
overlay.dispatchEvent(
|
|
202
|
-
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
203
|
-
);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
243
|
it("control: with the flag off Enter opens the text for editing", () => {
|
|
207
244
|
const { selection, overlay } = fixture();
|
|
208
245
|
pressEnter(overlay);
|
|
@@ -215,14 +252,6 @@ describe("DomEditOverlay with the preview read-only", () => {
|
|
|
215
252
|
expect(selection.element.hasAttribute("contenteditable")).toBe(false);
|
|
216
253
|
});
|
|
217
254
|
|
|
218
|
-
const rightClick = async (overlay: HTMLElement) => {
|
|
219
|
-
await act(async () => {
|
|
220
|
-
overlay.dispatchEvent(
|
|
221
|
-
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
|
|
222
|
-
);
|
|
223
|
-
});
|
|
224
|
-
};
|
|
225
|
-
|
|
226
255
|
it("control: with the flag off right-click offers delete and z-order", async () => {
|
|
227
256
|
const { overlay } = fixture();
|
|
228
257
|
await rightClick(overlay);
|
|
@@ -258,3 +287,177 @@ describe("DomEditOverlay with the preview read-only", () => {
|
|
|
258
287
|
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
259
288
|
});
|
|
260
289
|
});
|
|
290
|
+
|
|
291
|
+
describe("DomEditOverlay with canvasInput host", () => {
|
|
292
|
+
const HOST = { canvasInput: "host" } as const;
|
|
293
|
+
|
|
294
|
+
it("lets presses through its root, and the overlay default keeps them", () => {
|
|
295
|
+
expect(fixture().overlay.className).toContain("pointer-events-auto");
|
|
296
|
+
act(() => root.unmount());
|
|
297
|
+
expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("draws no hover box", () => {
|
|
301
|
+
layout.hover = RECT;
|
|
302
|
+
const hovered = (props = {}) => {
|
|
303
|
+
const { overlay } = fixture(props);
|
|
304
|
+
const box = overlay.querySelector('[data-dom-edit-hover-box="true"]');
|
|
305
|
+
act(() => root.unmount());
|
|
306
|
+
return box;
|
|
307
|
+
};
|
|
308
|
+
const selection = makeSelection("Hover", textElement("hover"));
|
|
309
|
+
expect(hovered({ hoverSelection: selection })).not.toBeNull();
|
|
310
|
+
expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("starts no marquee and makes no selection from a press on empty canvas", () => {
|
|
314
|
+
const off = fixture({ selection: null });
|
|
315
|
+
fire(off.overlay, "pointerdown");
|
|
316
|
+
fire(off.overlay, "pointerup");
|
|
317
|
+
expect(off.spies.onMarqueeSelect).toHaveBeenCalledWith([], false);
|
|
318
|
+
act(() => root.unmount());
|
|
319
|
+
const { spies, overlay } = fixture({ ...HOST, selection: null });
|
|
320
|
+
fire(overlay, "pointerdown");
|
|
321
|
+
fire(overlay, "mousedown");
|
|
322
|
+
fire(overlay, "pointerup");
|
|
323
|
+
expect(spies.onMarqueeSelect).not.toHaveBeenCalled();
|
|
324
|
+
expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("opens no context menu and selects nothing on right-click", async () => {
|
|
328
|
+
const { spies, overlay } = fixture({ ...HOST, selection: null });
|
|
329
|
+
await rightClick(overlay);
|
|
330
|
+
expect(spies.onSelectionChange).not.toHaveBeenCalled();
|
|
331
|
+
act(() => root.unmount());
|
|
332
|
+
await rightClick(fixture(HOST).overlay);
|
|
333
|
+
expect(document.body.textContent).not.toContain("Delete");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("does not re-select on a click of the selection box", () => {
|
|
337
|
+
const off = fixture();
|
|
338
|
+
clickBox(off.overlay);
|
|
339
|
+
expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
340
|
+
act(() => root.unmount());
|
|
341
|
+
const { spies, overlay } = fixture(HOST);
|
|
342
|
+
clickBox(overlay);
|
|
343
|
+
fire(overlay.querySelector(BOX)!, "click");
|
|
344
|
+
expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("offers no off-canvas indicator to press, so nothing re-selects from one", async () => {
|
|
348
|
+
const MARK = '[aria-label="Select off-canvas element stray"]';
|
|
349
|
+
const offCanvas = (props = {}) => {
|
|
350
|
+
layout.offCanvasElements.set("stray", textElement("stray"));
|
|
351
|
+
layout.offCanvas = [{ key: "stray", left: 820, top: 10, width: 120, height: 40 }];
|
|
352
|
+
return fixture(props);
|
|
353
|
+
};
|
|
354
|
+
const off = offCanvas();
|
|
355
|
+
fire(off.overlay.querySelector(MARK)!, "click");
|
|
356
|
+
await vi.waitFor(() => expect(off.spies.onSelectionChange).toHaveBeenCalledTimes(1));
|
|
357
|
+
act(() => root.unmount());
|
|
358
|
+
const { spies, overlay } = offCanvas(HOST);
|
|
359
|
+
expect(overlay.querySelector(MARK)).toBeNull();
|
|
360
|
+
expect(spies.onSelectionChange).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("reports each selection-box click to the host once, by either route", () => {
|
|
364
|
+
const cases = [
|
|
365
|
+
{ props: {}, movable: true, reselects: 1 },
|
|
366
|
+
{ props: HOST, movable: true, reselects: 0 },
|
|
367
|
+
{ props: HOST, movable: false, reselects: 0 },
|
|
368
|
+
];
|
|
369
|
+
for (const { props, movable, reselects } of cases) {
|
|
370
|
+
const onSelectionBoxClick = vi.fn();
|
|
371
|
+
const { spies, selection, overlay } = fixture({ ...props, onSelectionBoxClick });
|
|
372
|
+
selection.capabilities.canApplyManualOffset = movable;
|
|
373
|
+
clickBox(overlay);
|
|
374
|
+
expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
|
|
375
|
+
expect(onSelectionBoxClick).toHaveBeenCalledWith(expect.anything(), selection);
|
|
376
|
+
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(reselects);
|
|
377
|
+
act(() => root.unmount());
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("does not report a press off the box, or a click on a group's box", () => {
|
|
382
|
+
const onSelectionBoxClick = vi.fn();
|
|
383
|
+
const off = fixture({ onSelectionBoxClick, onMarqueeSelect: undefined });
|
|
384
|
+
for (const type of ["pointerdown", "mousedown", "pointerup", "click"]) fire(off.overlay, type);
|
|
385
|
+
expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
386
|
+
act(() => root.unmount());
|
|
387
|
+
const members = ["a", "b"].map((id) => {
|
|
388
|
+
const selection = makeSelection(id, textElement(id));
|
|
389
|
+
return { key: id, selection, element: selection.element, rect: RECT };
|
|
390
|
+
});
|
|
391
|
+
members[1].selection.capabilities.canApplyManualOffset = false;
|
|
392
|
+
layout.group = members;
|
|
393
|
+
const { spies, overlay } = fixture({
|
|
394
|
+
onSelectionBoxClick,
|
|
395
|
+
selection: members[0].selection,
|
|
396
|
+
groupSelections: members.map((m) => m.selection),
|
|
397
|
+
});
|
|
398
|
+
clickBox(overlay);
|
|
399
|
+
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
400
|
+
expect(onSelectionBoxClick).not.toHaveBeenCalled();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("reports the first box click after a handle resize", () => {
|
|
404
|
+
const onSelectionBoxClick = vi.fn();
|
|
405
|
+
const { selection, overlay } = fixture({ ...HOST, onSelectionBoxClick });
|
|
406
|
+
for (const type of ["pointerdown", "pointerup", "click"]) {
|
|
407
|
+
fire(overlay.querySelector("div.h-4.w-4")!, type);
|
|
408
|
+
}
|
|
409
|
+
selection.capabilities.canApplyManualOffset = false;
|
|
410
|
+
clickBox(overlay);
|
|
411
|
+
expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("opens the text on Enter when nothing else has focus, not from a focused field", () => {
|
|
415
|
+
const off = fixture();
|
|
416
|
+
enterOn(document.body);
|
|
417
|
+
expect(off.selection.element.hasAttribute("contenteditable")).toBe(false);
|
|
418
|
+
act(() => root.unmount());
|
|
419
|
+
const { spies, selection } = fixture(HOST);
|
|
420
|
+
const field = document.createElement("input");
|
|
421
|
+
document.body.append(field);
|
|
422
|
+
enterOn(field);
|
|
423
|
+
expect(selection.element.hasAttribute("contenteditable")).toBe(false);
|
|
424
|
+
enterOn(document.body);
|
|
425
|
+
expect(selection.element.hasAttribute("contenteditable")).toBe(true);
|
|
426
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("keeps the handles working: drag, resize dots and rotate", () => {
|
|
430
|
+
const { spies, overlay } = fixture(HOST);
|
|
431
|
+
fire(overlay.querySelector(BOX)!, "pointerdown");
|
|
432
|
+
expect(spies.onManualDragStart).toHaveBeenCalledTimes(1);
|
|
433
|
+
fire(overlay.querySelector(BOX)!, "pointerup");
|
|
434
|
+
expect(overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(4);
|
|
435
|
+
const rotate = overlay.querySelector('[aria-label="Rotate selection"]')!;
|
|
436
|
+
fire(rotate, "pointerdown", { clientX: 200, clientY: 250 });
|
|
437
|
+
fire(rotate, "pointermove", { clientX: 300, clientY: 150 });
|
|
438
|
+
fire(rotate, "pointerup", { clientX: 300, clientY: 150 });
|
|
439
|
+
expect(spies.onRotationCommit).toHaveBeenCalledTimes(1);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("DomEditOverlay onTextEditingChange", () => {
|
|
444
|
+
it("reports true when the caret goes live and false when the edit ends", () => {
|
|
445
|
+
const { spies, selection, overlay } = fixture();
|
|
446
|
+
expect(spies.onTextEditingChange).not.toHaveBeenCalled();
|
|
447
|
+
pressEnter(overlay);
|
|
448
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
|
|
449
|
+
act(() => {
|
|
450
|
+
selection.element.dispatchEvent(
|
|
451
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("reports false when the overlay unmounts mid-edit", () => {
|
|
458
|
+
const { spies, overlay } = fixture();
|
|
459
|
+
pressEnter(overlay);
|
|
460
|
+
act(() => root.unmount());
|
|
461
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -989,6 +989,141 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
989
989
|
expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
|
|
990
990
|
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
|
|
991
991
|
});
|
|
992
|
+
|
|
993
|
+
describe("on each clip's own clock", () => {
|
|
994
|
+
type Point = { t: number; v: number };
|
|
995
|
+
/** `hz` at `amp` over [from, to) of a `seconds`-long file, silence elsewhere. */
|
|
996
|
+
const tone = (seconds: number, [from, to]: [number, number], hz = 1000, amp = 0.7) =>
|
|
997
|
+
Float32Array.from({ length: SAMPLE_RATE * seconds }, (_, i) => {
|
|
998
|
+
const t = i / SAMPLE_RATE;
|
|
999
|
+
return t >= from && t < to ? amp * Math.sin(2 * Math.PI * hz * t) : 0;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
/** Each file decodes to its own samples, found by the URL that was fetched. */
|
|
1003
|
+
function stubFiles(files: Record<string, Float32Array>): void {
|
|
1004
|
+
vi.stubGlobal(
|
|
1005
|
+
"fetch",
|
|
1006
|
+
vi.fn(async (url: string) => ({ arrayBuffer: async () => url })),
|
|
1007
|
+
);
|
|
1008
|
+
vi.stubGlobal(
|
|
1009
|
+
"OfflineAudioContext",
|
|
1010
|
+
class {
|
|
1011
|
+
decodeAudioData = async (url: string) => {
|
|
1012
|
+
const data = Object.entries(files).find(([name]) => url.endsWith(name))![1];
|
|
1013
|
+
return { sampleRate: SAMPLE_RATE, getChannelData: () => data };
|
|
1014
|
+
};
|
|
1015
|
+
},
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const valueAt = (points: Point[], t: number): number => {
|
|
1020
|
+
const i = points.findIndex((p) => p.t >= t);
|
|
1021
|
+
if (i <= 0) return points.at(i)!.v;
|
|
1022
|
+
const [a, b] = [points[i - 1]!, points[i]!];
|
|
1023
|
+
return a.v + ((b.v - a.v) * (t - a.t)) / (b.t - a.t);
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
/** Carves the bed against `vo` and reads back the lane driving the first node of `type`. */
|
|
1027
|
+
async function carveLane(
|
|
1028
|
+
type: string,
|
|
1029
|
+
voice: Record<string, string>,
|
|
1030
|
+
bed: Record<string, string> = {},
|
|
1031
|
+
strength = 0,
|
|
1032
|
+
) {
|
|
1033
|
+
const { host, onSetAttributeQuiet } = mount({
|
|
1034
|
+
"fx-chain": carvedChain,
|
|
1035
|
+
"fx-carve": settings(true, { strength, sources: [] }),
|
|
1036
|
+
start: "0",
|
|
1037
|
+
...bed,
|
|
1038
|
+
});
|
|
1039
|
+
const vo = document.getElementById("vo")!;
|
|
1040
|
+
vo.setAttribute("src", "voice.wav");
|
|
1041
|
+
for (const [name, value] of Object.entries(voice)) vo.setAttribute(`data-${name}`, value);
|
|
1042
|
+
document.getElementById("bed")!.setAttribute("src", "bed.wav");
|
|
1043
|
+
await act(async () => {
|
|
1044
|
+
pickSource(host, "vo");
|
|
1045
|
+
});
|
|
1046
|
+
const calls = onSetAttributeQuiet.mock.calls;
|
|
1047
|
+
const nodes = parseWrite(writeTo(calls, "data-fx-chain")!).nodes as {
|
|
1048
|
+
id: string;
|
|
1049
|
+
type: string;
|
|
1050
|
+
params: { gain: number; frequency: number };
|
|
1051
|
+
}[];
|
|
1052
|
+
const node = nodes.find((n) => n.type === type)!;
|
|
1053
|
+
const lanes = parseWrite(writeTo(calls, "data-automation")!).lanes as {
|
|
1054
|
+
target: string;
|
|
1055
|
+
points: Point[];
|
|
1056
|
+
}[];
|
|
1057
|
+
const points = lanes.find((l) => l.target === `fx.${node.id}.gain`)!.points;
|
|
1058
|
+
return {
|
|
1059
|
+
depth: node.params.gain,
|
|
1060
|
+
frequency: node.params.frequency,
|
|
1061
|
+
points,
|
|
1062
|
+
at: (t: number) => valueAt(points, t),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Quiet 1 kHz speech at 0-2 s, loud 4 kHz speech at 4-6 s, in an 8 s file. */
|
|
1067
|
+
const lateSpeech = () => {
|
|
1068
|
+
const after = tone(8, [4, 6], 4000, 0.9);
|
|
1069
|
+
return tone(8, [0, 2], 1000, 0.3).map((v, i) => v + after[i]!);
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
it("ignores speech after the bed has stopped playing", async () => {
|
|
1073
|
+
// The bed file runs 8 s, but the clip plays only its first 3 s.
|
|
1074
|
+
stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(8, [0, 8], 200) });
|
|
1075
|
+
const lane = await carveLane("peaking", { start: "0" }, { duration: "3" });
|
|
1076
|
+
expect(lane.frequency).toBe(1000);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it("ignores speech after a bed with no duration runs out, when it ducks", async () => {
|
|
1080
|
+
// No data-duration: the bed plays its 3 s file. Strength 0.05 ducks and keeps one band.
|
|
1081
|
+
stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
|
|
1082
|
+
const lane = await carveLane("peaking", { start: "0" }, {}, 0.05);
|
|
1083
|
+
expect(lane.frequency).toBe(1000);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it.each([
|
|
1087
|
+
["no duration", {}],
|
|
1088
|
+
["a duration longer than its file", { duration: "6" }],
|
|
1089
|
+
])("ignores speech after a 3 s bed file with %s, without ducking", async (_, bed) => {
|
|
1090
|
+
stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
|
|
1091
|
+
const lane = await carveLane("peaking", { start: "0" }, bed);
|
|
1092
|
+
expect(lane.frequency).toBe(1000);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it("cuts where a trimmed voice speaks, not where its file does", async () => {
|
|
1096
|
+
// File speech 2-3.5 s; the clip plays 2-4 s of it, so the bed hears it at 0-1.5 s.
|
|
1097
|
+
stubFiles({ "voice.wav": tone(6, [2, 3.5]), "bed.wav": tone(8, [0, 8], 200) });
|
|
1098
|
+
const lane = await carveLane("peaking", { start: "0", "media-start": "2", duration: "2" });
|
|
1099
|
+
expect({ speaking: lane.at(0.8) <= lane.depth * 0.5, after: lane.at(3) }).toEqual({
|
|
1100
|
+
speaking: true,
|
|
1101
|
+
after: 0,
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("cuts over half the source length for a voice at 2x", async () => {
|
|
1106
|
+
// File speech 1-3 s at 2x: the bed hears it at 0.5-1.5 s.
|
|
1107
|
+
stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [0, 8], 200) });
|
|
1108
|
+
const lane = await carveLane("peaking", { start: "0", "playback-rate": "2", duration: "2" });
|
|
1109
|
+
expect({
|
|
1110
|
+
speaking: lane.at(0.8) <= lane.depth * 0.5 && lane.at(1.3) <= lane.depth * 0.5,
|
|
1111
|
+
after: lane.at(2.5),
|
|
1112
|
+
}).toEqual({ speaking: true, after: 0 });
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("ducks against the part of the bed that plays", async () => {
|
|
1116
|
+
// The bed file is silent for 4 s and loud after; the clip starts 4 s in, so it is loud under the voice.
|
|
1117
|
+
stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [4, 8], 200, 0.9) });
|
|
1118
|
+
const lane = await carveLane(
|
|
1119
|
+
"gain",
|
|
1120
|
+
{ start: "0" },
|
|
1121
|
+
{ "media-start": "4", duration: "4" },
|
|
1122
|
+
1,
|
|
1123
|
+
);
|
|
1124
|
+
expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThan(0);
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
992
1127
|
});
|
|
993
1128
|
|
|
994
1129
|
describe("AudioFxGroup successive edits", () => {
|
|
@@ -33,7 +33,8 @@ import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAu
|
|
|
33
33
|
import { trackCarveChanged } from "./audioFxTelemetry.js";
|
|
34
34
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
35
35
|
import { usePlayerStore } from "../../player";
|
|
36
|
-
import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
|
|
36
|
+
import { carveLanes, measureCarve, mintCarveNodes, type CarveClip } from "./useFxCarveNodes.js";
|
|
37
|
+
import { readClipClock } from "./clipAudioClock.js";
|
|
37
38
|
import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
|
|
38
39
|
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
39
40
|
|
|
@@ -93,11 +94,8 @@ function carveNeedsReanalysis(
|
|
|
93
94
|
* what lets the src and the start be non-null by construction downstream
|
|
94
95
|
* instead of by assertion.
|
|
95
96
|
*/
|
|
96
|
-
function resolveCarveVoices(
|
|
97
|
-
|
|
98
|
-
sources: readonly string[],
|
|
99
|
-
): { src: string; start: string | null }[] {
|
|
100
|
-
const voices: { src: string; start: string | null }[] = [];
|
|
97
|
+
function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
|
|
98
|
+
const voices: CarveClip[] = [];
|
|
101
99
|
for (const id of sources) {
|
|
102
100
|
const el = doc.getElementById(id);
|
|
103
101
|
// By tag name, not `instanceof HTMLAudioElement`: these elements belong to
|
|
@@ -106,7 +104,8 @@ function resolveCarveVoices(
|
|
|
106
104
|
if (el?.tagName !== "AUDIO") continue;
|
|
107
105
|
const src = el.getAttribute("src");
|
|
108
106
|
if (!src) continue;
|
|
109
|
-
|
|
107
|
+
const clock = readClipClock((name) => el.getAttribute(name));
|
|
108
|
+
voices.push({ src, start: el.getAttribute("data-start"), clock });
|
|
110
109
|
}
|
|
111
110
|
return voices;
|
|
112
111
|
}
|
|
@@ -363,14 +362,11 @@ export function useFxCarve(
|
|
|
363
362
|
if (voices.length === 0) return;
|
|
364
363
|
setAnalysing(true);
|
|
365
364
|
try {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
element.dataAttributes?.["start"],
|
|
372
|
-
bedSrc,
|
|
373
|
-
);
|
|
365
|
+
const measured = await measureCarve(doc, voices, active.strength, {
|
|
366
|
+
src: element.element?.getAttribute("src"),
|
|
367
|
+
start: element.dataAttributes?.["start"],
|
|
368
|
+
clock: readClipClock((name) => element.dataAttributes?.[name.slice(5)]),
|
|
369
|
+
});
|
|
374
370
|
if (!measured) return;
|
|
375
371
|
await persistMeasuredCarve(measured);
|
|
376
372
|
} catch {
|