@hyperframes/studio 0.8.82 → 0.8.84
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-Dd74fv7B.js} +3 -3
- package/dist/assets/{index-DStmfnpZ.js → index-D9dmtDap.js} +1 -1
- package/dist/assets/{index-BiLCTunu.js → index-DFcIBFM8.js} +1 -1
- package/dist/assets/{index-DSazIt7R.js → index-gjbSm-vt.js} +193 -193
- 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-7VDQIGOU.js +1871 -0
- package/dist/chunk-7VDQIGOU.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 +20512 -23172
- 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 +234 -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/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -0
- package/src/hooks/useElementLifecycleOps.ts +1 -1
- package/src/hooks/useInlineTextEdit.test.tsx +39 -0
- package/src/hooks/useInlineTextEdit.ts +5 -9
- 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/player/hooks/timelinePlayerSync.test.ts +1 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +41 -7
- package/src/player/lib/timelineElementHelpers.ts +7 -7
- 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,24 @@ 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
|
+
const actions = vi.hoisted(() => ({ current: null as unknown }));
|
|
24
|
+
vi.mock("../../contexts/DomEditContext", async (importOriginal) => ({
|
|
25
|
+
...(await importOriginal<typeof import("../../contexts/DomEditContext")>()),
|
|
26
|
+
useDomEditActionsContextOptional: () => actions.current,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("./useOffCanvasIndicators", () => ({
|
|
29
|
+
useOffCanvasIndicators: () => ({
|
|
30
|
+
offCanvasRects: layout.offCanvas,
|
|
31
|
+
offCanvasElementsRef: { current: layout.offCanvasElements },
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
17
34
|
|
|
18
35
|
vi.mock("./useDomEditOverlayRects", () => ({
|
|
19
36
|
useDomEditOverlayRects: () => ({
|
|
@@ -22,7 +39,7 @@ vi.mock("./useDomEditOverlayRects", () => ({
|
|
|
22
39
|
current: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
|
|
23
40
|
},
|
|
24
41
|
setOverlayRect: () => undefined,
|
|
25
|
-
hoverRect:
|
|
42
|
+
hoverRect: layout.hover,
|
|
26
43
|
groupOverlayItems: layout.group,
|
|
27
44
|
groupOverlayItemsRef: { current: layout.group },
|
|
28
45
|
setGroupOverlayItems: () => undefined,
|
|
@@ -57,6 +74,8 @@ function fixture(
|
|
|
57
74
|
onStyleCommit: vi.fn(),
|
|
58
75
|
onDeleteSelection: vi.fn(),
|
|
59
76
|
onApplyZIndex: vi.fn(),
|
|
77
|
+
onMarqueeSelect: vi.fn(),
|
|
78
|
+
onTextEditingChange: vi.fn(),
|
|
60
79
|
};
|
|
61
80
|
const selection = makeSelection("Title", textElement("title"));
|
|
62
81
|
selection.capabilities.canApplyManualRotation = true;
|
|
@@ -92,6 +111,33 @@ const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
|
92
111
|
return event;
|
|
93
112
|
};
|
|
94
113
|
|
|
114
|
+
const pressEnter = (overlay: HTMLElement) =>
|
|
115
|
+
act(() => {
|
|
116
|
+
overlay.dispatchEvent(
|
|
117
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const clickBox = (overlay: HTMLElement) => {
|
|
122
|
+
const box = overlay.querySelector(BOX)!;
|
|
123
|
+
for (const type of ["pointerdown", "pointerup", "click"]) fire(box, type);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const enterOn = (target: Element) =>
|
|
127
|
+
act(() => {
|
|
128
|
+
target.dispatchEvent(
|
|
129
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const rightClick = async (overlay: HTMLElement) => {
|
|
134
|
+
await act(async () => {
|
|
135
|
+
overlay.dispatchEvent(
|
|
136
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
95
141
|
beforeEach(() => {
|
|
96
142
|
vi.useFakeTimers();
|
|
97
143
|
__resetForTests();
|
|
@@ -100,8 +146,12 @@ beforeEach(() => {
|
|
|
100
146
|
|
|
101
147
|
afterEach(() => {
|
|
102
148
|
act(() => root.unmount());
|
|
149
|
+
actions.current = null;
|
|
103
150
|
document.body.innerHTML = "";
|
|
104
151
|
layout.group = [];
|
|
152
|
+
layout.hover = null;
|
|
153
|
+
layout.offCanvas = [];
|
|
154
|
+
layout.offCanvasElements.clear();
|
|
105
155
|
vi.useRealTimers();
|
|
106
156
|
});
|
|
107
157
|
|
|
@@ -196,13 +246,6 @@ describe("DomEditOverlay with the preview read-only", () => {
|
|
|
196
246
|
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
197
247
|
});
|
|
198
248
|
|
|
199
|
-
const pressEnter = (overlay: HTMLElement) =>
|
|
200
|
-
act(() => {
|
|
201
|
-
overlay.dispatchEvent(
|
|
202
|
-
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
203
|
-
);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
249
|
it("control: with the flag off Enter opens the text for editing", () => {
|
|
207
250
|
const { selection, overlay } = fixture();
|
|
208
251
|
pressEnter(overlay);
|
|
@@ -215,14 +258,6 @@ describe("DomEditOverlay with the preview read-only", () => {
|
|
|
215
258
|
expect(selection.element.hasAttribute("contenteditable")).toBe(false);
|
|
216
259
|
});
|
|
217
260
|
|
|
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
261
|
it("control: with the flag off right-click offers delete and z-order", async () => {
|
|
227
262
|
const { overlay } = fixture();
|
|
228
263
|
await rightClick(overlay);
|
|
@@ -258,3 +293,185 @@ describe("DomEditOverlay with the preview read-only", () => {
|
|
|
258
293
|
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
259
294
|
});
|
|
260
295
|
});
|
|
296
|
+
|
|
297
|
+
describe("DomEditOverlay with canvasInput host", () => {
|
|
298
|
+
const HOST = { canvasInput: "host" } as const;
|
|
299
|
+
|
|
300
|
+
it("lets presses through its root, and the overlay default keeps them", () => {
|
|
301
|
+
expect(fixture().overlay.className).toContain("pointer-events-auto");
|
|
302
|
+
act(() => root.unmount());
|
|
303
|
+
expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("draws no hover box", () => {
|
|
307
|
+
layout.hover = RECT;
|
|
308
|
+
const hovered = (props = {}) => {
|
|
309
|
+
const { overlay } = fixture(props);
|
|
310
|
+
const box = overlay.querySelector('[data-dom-edit-hover-box="true"]');
|
|
311
|
+
act(() => root.unmount());
|
|
312
|
+
return box;
|
|
313
|
+
};
|
|
314
|
+
const selection = makeSelection("Hover", textElement("hover"));
|
|
315
|
+
expect(hovered({ hoverSelection: selection })).not.toBeNull();
|
|
316
|
+
expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("starts no marquee and makes no selection from a press on empty canvas", () => {
|
|
320
|
+
const off = fixture({ selection: null });
|
|
321
|
+
fire(off.overlay, "pointerdown");
|
|
322
|
+
fire(off.overlay, "pointerup");
|
|
323
|
+
expect(off.spies.onMarqueeSelect).toHaveBeenCalledWith([], false);
|
|
324
|
+
act(() => root.unmount());
|
|
325
|
+
const { spies, overlay } = fixture({ ...HOST, selection: null });
|
|
326
|
+
fire(overlay, "pointerdown");
|
|
327
|
+
fire(overlay, "mousedown");
|
|
328
|
+
fire(overlay, "pointerup");
|
|
329
|
+
expect(spies.onMarqueeSelect).not.toHaveBeenCalled();
|
|
330
|
+
expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("opens no context menu and selects nothing on right-click", async () => {
|
|
334
|
+
const { spies, overlay } = fixture({ ...HOST, selection: null });
|
|
335
|
+
await rightClick(overlay);
|
|
336
|
+
expect(spies.onSelectionChange).not.toHaveBeenCalled();
|
|
337
|
+
act(() => root.unmount());
|
|
338
|
+
await rightClick(fixture(HOST).overlay);
|
|
339
|
+
expect(document.body.textContent).not.toContain("Delete");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("does not re-select on a click of the selection box", () => {
|
|
343
|
+
const off = fixture();
|
|
344
|
+
clickBox(off.overlay);
|
|
345
|
+
expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
346
|
+
act(() => root.unmount());
|
|
347
|
+
const { spies, overlay } = fixture(HOST);
|
|
348
|
+
clickBox(overlay);
|
|
349
|
+
fire(overlay.querySelector(BOX)!, "click");
|
|
350
|
+
expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("offers no off-canvas indicator to press, so nothing re-selects from one", async () => {
|
|
354
|
+
const MARK = '[aria-label="Select off-canvas element stray"]';
|
|
355
|
+
const offCanvas = (props = {}) => {
|
|
356
|
+
layout.offCanvasElements.set("stray", textElement("stray"));
|
|
357
|
+
layout.offCanvas = [{ key: "stray", left: 820, top: 10, width: 120, height: 40 }];
|
|
358
|
+
return fixture(props);
|
|
359
|
+
};
|
|
360
|
+
const off = offCanvas();
|
|
361
|
+
fire(off.overlay.querySelector(MARK)!, "click");
|
|
362
|
+
await vi.waitFor(() => expect(off.spies.onSelectionChange).toHaveBeenCalledTimes(1));
|
|
363
|
+
act(() => root.unmount());
|
|
364
|
+
const { spies, overlay } = offCanvas(HOST);
|
|
365
|
+
expect(overlay.querySelector(MARK)).toBeNull();
|
|
366
|
+
expect(spies.onSelectionChange).not.toHaveBeenCalled();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("reports each selection-box click to the host once, by either route", () => {
|
|
370
|
+
const cases = [
|
|
371
|
+
{ props: {}, movable: true, reselects: 1 },
|
|
372
|
+
{ props: HOST, movable: true, reselects: 0 },
|
|
373
|
+
{ props: HOST, movable: false, reselects: 0 },
|
|
374
|
+
];
|
|
375
|
+
for (const { props, movable, reselects } of cases) {
|
|
376
|
+
const onSelectionBoxClick = vi.fn();
|
|
377
|
+
const { spies, selection, overlay } = fixture({ ...props, onSelectionBoxClick });
|
|
378
|
+
selection.capabilities.canApplyManualOffset = movable;
|
|
379
|
+
clickBox(overlay);
|
|
380
|
+
expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
|
|
381
|
+
expect(onSelectionBoxClick).toHaveBeenCalledWith(expect.anything(), selection);
|
|
382
|
+
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(reselects);
|
|
383
|
+
act(() => root.unmount());
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("does not report a press off the box, or a click on a group's box", () => {
|
|
388
|
+
const onSelectionBoxClick = vi.fn();
|
|
389
|
+
const off = fixture({ onSelectionBoxClick, onMarqueeSelect: undefined });
|
|
390
|
+
for (const type of ["pointerdown", "mousedown", "pointerup", "click"]) fire(off.overlay, type);
|
|
391
|
+
expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
392
|
+
act(() => root.unmount());
|
|
393
|
+
const members = ["a", "b"].map((id) => {
|
|
394
|
+
const selection = makeSelection(id, textElement(id));
|
|
395
|
+
return { key: id, selection, element: selection.element, rect: RECT };
|
|
396
|
+
});
|
|
397
|
+
members[1].selection.capabilities.canApplyManualOffset = false;
|
|
398
|
+
layout.group = members;
|
|
399
|
+
const { spies, overlay } = fixture({
|
|
400
|
+
onSelectionBoxClick,
|
|
401
|
+
selection: members[0].selection,
|
|
402
|
+
groupSelections: members.map((m) => m.selection),
|
|
403
|
+
});
|
|
404
|
+
clickBox(overlay);
|
|
405
|
+
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
406
|
+
expect(onSelectionBoxClick).not.toHaveBeenCalled();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("reports the first box click after a handle resize", () => {
|
|
410
|
+
const onSelectionBoxClick = vi.fn();
|
|
411
|
+
const { selection, overlay } = fixture({ ...HOST, onSelectionBoxClick });
|
|
412
|
+
for (const type of ["pointerdown", "pointerup", "click"]) {
|
|
413
|
+
fire(overlay.querySelector("div.h-4.w-4")!, type);
|
|
414
|
+
}
|
|
415
|
+
selection.capabilities.canApplyManualOffset = false;
|
|
416
|
+
clickBox(overlay);
|
|
417
|
+
expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("opens the text on Enter when nothing else has focus, not from a focused field", () => {
|
|
421
|
+
const off = fixture();
|
|
422
|
+
enterOn(document.body);
|
|
423
|
+
expect(off.selection.element.hasAttribute("contenteditable")).toBe(false);
|
|
424
|
+
act(() => root.unmount());
|
|
425
|
+
const { spies, selection } = fixture(HOST);
|
|
426
|
+
const field = document.createElement("input");
|
|
427
|
+
document.body.append(field);
|
|
428
|
+
enterOn(field);
|
|
429
|
+
expect(selection.element.hasAttribute("contenteditable")).toBe(false);
|
|
430
|
+
enterOn(document.body);
|
|
431
|
+
expect(selection.element.hasAttribute("contenteditable")).toBe(true);
|
|
432
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("keeps the handles working: drag, resize dots and rotate", () => {
|
|
436
|
+
const { spies, overlay } = fixture(HOST);
|
|
437
|
+
fire(overlay.querySelector(BOX)!, "pointerdown");
|
|
438
|
+
expect(spies.onManualDragStart).toHaveBeenCalledTimes(1);
|
|
439
|
+
fire(overlay.querySelector(BOX)!, "pointerup");
|
|
440
|
+
expect(overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(4);
|
|
441
|
+
const rotate = overlay.querySelector('[aria-label="Rotate selection"]')!;
|
|
442
|
+
fire(rotate, "pointerdown", { clientX: 200, clientY: 250 });
|
|
443
|
+
fire(rotate, "pointermove", { clientX: 300, clientY: 150 });
|
|
444
|
+
fire(rotate, "pointerup", { clientX: 300, clientY: 150 });
|
|
445
|
+
expect(spies.onRotationCommit).toHaveBeenCalledTimes(1);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("DomEditOverlay onTextEditingChange", () => {
|
|
450
|
+
it("reports true when the caret goes live and false when the edit ends", () => {
|
|
451
|
+
const { spies, selection, overlay } = fixture();
|
|
452
|
+
expect(spies.onTextEditingChange).not.toHaveBeenCalled();
|
|
453
|
+
pressEnter(overlay);
|
|
454
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
|
|
455
|
+
act(() => {
|
|
456
|
+
selection.element.dispatchEvent(
|
|
457
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("saves the typed words and reports false when the overlay unmounts mid-edit", () => {
|
|
464
|
+
const handleDomRichTextCommit = vi.fn();
|
|
465
|
+
actions.current = { handleDomRichTextCommit };
|
|
466
|
+
const { spies, selection, overlay } = fixture();
|
|
467
|
+
pressEnter(overlay);
|
|
468
|
+
selection.element.textContent = "Typed";
|
|
469
|
+
act(() => root.unmount());
|
|
470
|
+
expect(handleDomRichTextCommit).toHaveBeenCalledWith({
|
|
471
|
+
element: selection.element,
|
|
472
|
+
html: "Typed",
|
|
473
|
+
previousHtml: "Title",
|
|
474
|
+
});
|
|
475
|
+
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
@@ -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", () => {
|