@hyperframes/studio 0.8.78 → 0.8.80
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-CgnoxDoD.js +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -77,7 +77,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
const setup = (binding: Partial<AutomationLaneBinding>) => {
|
|
80
|
-
const onCommit = vi.fn();
|
|
80
|
+
const onCommit = vi.fn().mockResolvedValue(undefined);
|
|
81
81
|
const automation = {
|
|
82
82
|
version: 1,
|
|
83
83
|
lanes: [
|
|
@@ -102,7 +102,6 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
102
102
|
onCommit,
|
|
103
103
|
onSelect: vi.fn(),
|
|
104
104
|
readOnly: false,
|
|
105
|
-
commitTargetKey: "bgm",
|
|
106
105
|
selection: null,
|
|
107
106
|
onRangeSelect: vi.fn(),
|
|
108
107
|
onRangeClear: vi.fn(),
|
|
@@ -230,7 +229,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
230
229
|
expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
|
|
231
230
|
});
|
|
232
231
|
|
|
233
|
-
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
|
|
232
|
+
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", async () => {
|
|
234
233
|
clearAutomationClipboard();
|
|
235
234
|
// Duration wide enough that the playhead (5s) is not clamped down by the
|
|
236
235
|
// 0..duration-span bound — this is a paste-at-playhead test, not a
|
|
@@ -255,6 +254,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
255
254
|
expect(times).toContain(7); // + clipboard span 2
|
|
256
255
|
|
|
257
256
|
// Pasting again immediately should land right after the first paste.
|
|
257
|
+
await act(async () => {});
|
|
258
258
|
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
259
259
|
elementKey: "bgm",
|
|
260
260
|
target: "volume",
|
|
@@ -268,7 +268,124 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
-
it("
|
|
271
|
+
it("marks nothing when the paste's save is refused, even for two quick presses", async () => {
|
|
272
|
+
clearAutomationClipboard();
|
|
273
|
+
usePlayerStore.setState({
|
|
274
|
+
elements: [{ ...bgmElement, duration: 10 }],
|
|
275
|
+
selectedElementId: "bgm",
|
|
276
|
+
});
|
|
277
|
+
const original = wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
|
|
278
|
+
usePlayerStore.getState().setAutomationSelection(original);
|
|
279
|
+
const onCommit = vi.fn().mockResolvedValue({ status: "refused", reason: "Locked" });
|
|
280
|
+
setup({ onCommit });
|
|
281
|
+
combo("c");
|
|
282
|
+
combo("v");
|
|
283
|
+
combo("v");
|
|
284
|
+
await act(async () => {});
|
|
285
|
+
expect(usePlayerStore.getState().automationSelection).toEqual(original);
|
|
286
|
+
combo("v");
|
|
287
|
+
const again = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
|
288
|
+
(p: { t: number }) => p.t,
|
|
289
|
+
);
|
|
290
|
+
expect(again).toContain(2);
|
|
291
|
+
expect(again).not.toContain(6);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it.each([{ status: "saved" as const }, { status: "refused" as const, reason: "Locked" }])(
|
|
295
|
+
"keeps a selection drawn while a paste was saving ($status)",
|
|
296
|
+
async (outcome) => {
|
|
297
|
+
clearAutomationClipboard();
|
|
298
|
+
usePlayerStore.setState({
|
|
299
|
+
elements: [{ ...bgmElement, duration: 10 }],
|
|
300
|
+
selectedElementId: "bgm",
|
|
301
|
+
});
|
|
302
|
+
usePlayerStore
|
|
303
|
+
.getState()
|
|
304
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
305
|
+
let land = () => {};
|
|
306
|
+
const onCommit = vi.fn(
|
|
307
|
+
() =>
|
|
308
|
+
new Promise<typeof outcome>((resolve) => {
|
|
309
|
+
land = () => resolve(outcome);
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
setup({ onCommit });
|
|
313
|
+
combo("c");
|
|
314
|
+
combo("v");
|
|
315
|
+
const drawn = wholeAxis({ elementKey: "bgm", target: "volume", t0: 6, t1: 8 });
|
|
316
|
+
usePlayerStore.getState().setAutomationSelection(drawn);
|
|
317
|
+
land();
|
|
318
|
+
await act(async () => {});
|
|
319
|
+
expect(usePlayerStore.getState().automationSelection).toEqual(drawn);
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
it.each(["in order", "reversed"])(
|
|
324
|
+
"marks the latest paste when pastes on two clips resolve %s",
|
|
325
|
+
async (order) => {
|
|
326
|
+
clearAutomationClipboard();
|
|
327
|
+
usePlayerStore.setState({
|
|
328
|
+
elements: [
|
|
329
|
+
{ ...bgmElement, duration: 10 },
|
|
330
|
+
{ ...bgmElement, id: "vo", key: "vo", duration: 10 },
|
|
331
|
+
],
|
|
332
|
+
selectedElementId: "bgm",
|
|
333
|
+
currentTime: 1,
|
|
334
|
+
});
|
|
335
|
+
usePlayerStore
|
|
336
|
+
.getState()
|
|
337
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
338
|
+
const lands: Array<() => void> = [];
|
|
339
|
+
const onCommit = vi.fn(
|
|
340
|
+
() =>
|
|
341
|
+
new Promise<{ status: "saved" }>((resolve) =>
|
|
342
|
+
lands.push(() => resolve({ status: "saved" })),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
setup({ onCommit });
|
|
346
|
+
combo("c");
|
|
347
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
348
|
+
combo("v");
|
|
349
|
+
usePlayerStore.setState({ selectedElementId: "vo" });
|
|
350
|
+
combo("v");
|
|
351
|
+
const order_ = order === "in order" ? [0, 1] : [1, 0];
|
|
352
|
+
for (const i of order_) lands[i]?.();
|
|
353
|
+
await act(async () => {});
|
|
354
|
+
expect(usePlayerStore.getState().automationSelection?.elementKey).toBe("vo");
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
it("does not mark a paste whose clip was left before it landed", async () => {
|
|
359
|
+
clearAutomationClipboard();
|
|
360
|
+
usePlayerStore.setState({
|
|
361
|
+
elements: [
|
|
362
|
+
{ ...bgmElement, duration: 10 },
|
|
363
|
+
{ ...bgmElement, id: "vo", key: "vo", duration: 10 },
|
|
364
|
+
],
|
|
365
|
+
selectedElementId: "bgm",
|
|
366
|
+
currentTime: 1,
|
|
367
|
+
});
|
|
368
|
+
usePlayerStore
|
|
369
|
+
.getState()
|
|
370
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
371
|
+
let land = () => {};
|
|
372
|
+
const onCommit = vi.fn(
|
|
373
|
+
() =>
|
|
374
|
+
new Promise<{ status: "saved" }>((resolve) => {
|
|
375
|
+
land = () => resolve({ status: "saved" });
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
setup({ onCommit });
|
|
379
|
+
combo("c");
|
|
380
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
381
|
+
combo("v");
|
|
382
|
+
usePlayerStore.setState({ selectedElementId: "vo" });
|
|
383
|
+
land();
|
|
384
|
+
await act(async () => {});
|
|
385
|
+
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("chains a second Cmd+V after the first instead of overwriting it", async () => {
|
|
272
389
|
// The regression this pins: paste leaves its own span selected, so anchoring
|
|
273
390
|
// at sel.t0 unconditionally made every later press recompute the same atT.
|
|
274
391
|
clearAutomationClipboard();
|
|
@@ -288,6 +405,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
288
405
|
);
|
|
289
406
|
expect(first).toContain(2);
|
|
290
407
|
expect(first).toContain(4);
|
|
408
|
+
await act(async () => {});
|
|
291
409
|
|
|
292
410
|
combo("v");
|
|
293
411
|
const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
|
@@ -295,6 +413,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
295
413
|
);
|
|
296
414
|
expect(second).toContain(4);
|
|
297
415
|
expect(second).toContain(6);
|
|
416
|
+
await act(async () => {});
|
|
298
417
|
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
299
418
|
elementKey: "bgm",
|
|
300
419
|
target: "volume",
|
|
@@ -308,7 +427,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
308
427
|
});
|
|
309
428
|
});
|
|
310
429
|
|
|
311
|
-
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
|
|
430
|
+
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", async () => {
|
|
312
431
|
// The playhead branch already clamps to duration - span; the
|
|
313
432
|
// selection-start branch didn't, so pasting a 2s clip at a selection
|
|
314
433
|
// sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
|
|
@@ -334,6 +453,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
334
453
|
expect(t).toBeLessThanOrEqual(bgmElement.duration);
|
|
335
454
|
}
|
|
336
455
|
// Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
|
|
456
|
+
await act(async () => {});
|
|
337
457
|
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
338
458
|
elementKey: "bgm",
|
|
339
459
|
target: "volume",
|
|
@@ -347,22 +467,6 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
347
467
|
});
|
|
348
468
|
});
|
|
349
469
|
|
|
350
|
-
it("refuses to paste when the dom-edit layer would write to a different clip", () => {
|
|
351
|
-
// selectedElementId says "bgm" but the commit channel is still on the
|
|
352
|
-
// previously selected clip — writing here would serialize bgm's automation
|
|
353
|
-
// onto that other clip and leave bgm untouched.
|
|
354
|
-
clearAutomationClipboard();
|
|
355
|
-
copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2);
|
|
356
|
-
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
357
|
-
usePlayerStore
|
|
358
|
-
.getState()
|
|
359
|
-
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
360
|
-
const { onCommit } = setup({ commitTargetKey: "some-other-clip" });
|
|
361
|
-
const e = combo("v");
|
|
362
|
-
expect(e.defaultPrevented).toBe(false);
|
|
363
|
-
expect(onCommit).not.toHaveBeenCalled();
|
|
364
|
-
});
|
|
365
|
-
|
|
366
470
|
it("does not paste from a playhead outside the clip", () => {
|
|
367
471
|
// No selection on this clip, and the playhead is past its end — there is no
|
|
368
472
|
// anchor. This used to collapse to the clip's own t=0.
|
|
@@ -108,7 +108,7 @@ function resolveDeleteWrite(
|
|
|
108
108
|
state: PlayerState,
|
|
109
109
|
lanes: UseAutomationLanesResult,
|
|
110
110
|
sel: AutomationSelection,
|
|
111
|
-
): { onCommit
|
|
111
|
+
): { onCommit: AutomationLaneBinding["onCommit"]; next: HfAutomation } | null {
|
|
112
112
|
const ctx = resolveSelectionContext(state, lanes, sel);
|
|
113
113
|
if (!ctx) return null;
|
|
114
114
|
const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel));
|
|
@@ -139,18 +139,7 @@ function pasteTargetName(
|
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* Where Cmd+V lands, or null when nothing is selected, the clip's lanes are
|
|
142
|
-
* read-only, it has no automation lane to fall back to
|
|
143
|
-
* would write the result to a DIFFERENT clip.
|
|
144
|
-
*
|
|
145
|
-
* That last guard is the one with teeth. `binding.onCommit` persists through
|
|
146
|
-
* handleDomAttributeQuietCommit, which targets whatever the dom-edit layer
|
|
147
|
-
* currently has selected — not the element `bind()` was handed (see the
|
|
148
|
-
* doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the
|
|
149
|
-
* timeline sets `selectedElementId` synchronously but resolves the dom-edit
|
|
150
|
-
* selection asynchronously, so clicking clip B and immediately pressing Cmd+V
|
|
151
|
-
* would serialize B's automation onto A. Every other lane path is a pointer
|
|
152
|
-
* gesture on the lane itself, which cannot run before the selection lands;
|
|
153
|
-
* paste is the only one that can, so it refuses rather than write blind.
|
|
142
|
+
* read-only, or it has no automation lane to fall back to.
|
|
154
143
|
*/
|
|
155
144
|
function resolvePasteTarget(
|
|
156
145
|
state: PlayerState,
|
|
@@ -169,7 +158,6 @@ function resolvePasteTarget(
|
|
|
169
158
|
const elementKey = elementKeyOf(element);
|
|
170
159
|
const binding = lanes.bind(element, true);
|
|
171
160
|
if (binding.readOnly) return null;
|
|
172
|
-
if (binding.commitTargetKey !== elementKey) return null;
|
|
173
161
|
const target = pasteTargetName(binding, elementKey, sel);
|
|
174
162
|
if (!target) return null;
|
|
175
163
|
const range = resolveAutomationRange(target, binding.chain ?? undefined);
|
|
@@ -225,6 +213,8 @@ function pasteAnchor(
|
|
|
225
213
|
return clampNumber(raw, 0, element.duration - span);
|
|
226
214
|
}
|
|
227
215
|
|
|
216
|
+
let latestPaste = 0;
|
|
217
|
+
|
|
228
218
|
/**
|
|
229
219
|
* Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active
|
|
230
220
|
* selection or the playhead. Returns false (untouched event) when the chord
|
|
@@ -252,12 +242,10 @@ function handlePaste(
|
|
|
252
242
|
|
|
253
243
|
e.preventDefault();
|
|
254
244
|
e.stopImmediatePropagation();
|
|
255
|
-
paste.binding.onCommit(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Full-height box over the pasted span: everything that landed is selected, so
|
|
260
|
-
// Delete straight after a paste undoes it in one press.
|
|
245
|
+
const saved = paste.binding.onCommit(
|
|
246
|
+
withLane(paste.binding.automation, { target: paste.target, points }),
|
|
247
|
+
);
|
|
248
|
+
const seq = ++latestPaste;
|
|
261
249
|
const mark = {
|
|
262
250
|
elementKey: paste.elementKey,
|
|
263
251
|
target: paste.target,
|
|
@@ -266,8 +254,17 @@ function handlePaste(
|
|
|
266
254
|
v0: paste.range.min,
|
|
267
255
|
v1: paste.range.max,
|
|
268
256
|
};
|
|
269
|
-
|
|
270
|
-
|
|
257
|
+
// Once the latest paste lands, and only over the selection it was pasted at, select
|
|
258
|
+
// and mark its full-height span: the feedback that it landed, what a second Cmd+V
|
|
259
|
+
// chains after, and what Delete takes back in one press.
|
|
260
|
+
void saved.then((outcome) => {
|
|
261
|
+
if (outcome && outcome.status !== "saved") return;
|
|
262
|
+
const current = usePlayerStore.getState();
|
|
263
|
+
const moved = current.selectedElementId !== state.selectedElementId;
|
|
264
|
+
if (seq !== latestPaste || moved || current.automationSelection !== sel) return;
|
|
265
|
+
current.setAutomationSelection(mark);
|
|
266
|
+
markLastPaste(mark);
|
|
267
|
+
});
|
|
271
268
|
return true;
|
|
272
269
|
}
|
|
273
270
|
|
|
@@ -300,8 +297,7 @@ function handleCopy(
|
|
|
300
297
|
*
|
|
301
298
|
* Store and clipboard only, no lane binding, so the dispatcher can call it
|
|
302
299
|
* without holding the binding factory. That leaves one accepted residual: the
|
|
303
|
-
* predicate cannot see a read-only lane
|
|
304
|
-
* dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases
|
|
300
|
+
* predicate cannot see a read-only lane or an unresolvable target, so in those rare cases
|
|
305
301
|
* the keystroke is a no-op instead of falling through to clip copy/paste. A
|
|
306
302
|
* dead key beats today's double write.
|
|
307
303
|
*/
|
|
@@ -333,7 +329,7 @@ function handleDelete(
|
|
|
333
329
|
if (!write) return false;
|
|
334
330
|
e.preventDefault();
|
|
335
331
|
e.stopImmediatePropagation();
|
|
336
|
-
write.onCommit(write.next);
|
|
332
|
+
void write.onCommit(write.next);
|
|
337
333
|
return true;
|
|
338
334
|
}
|
|
339
335
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
6
|
+
import { useBlockHandlers, type UseBlockHandlersResult } from "./useBlockHandlers";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
vi.mock("../utils/blockInstaller", () => ({
|
|
11
|
+
addBlockToProject: async (opts: { showToast: (message: string) => void }) => {
|
|
12
|
+
opts.showToast("Installing catalog items needs hyperframes preview");
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("useBlockHandlers", () => {
|
|
22
|
+
it("leaves only the error toast when an install fails", async () => {
|
|
23
|
+
const events: string[] = [];
|
|
24
|
+
let nextId = 0;
|
|
25
|
+
const showToast = (message: string) => {
|
|
26
|
+
events.push(`show ${message}`);
|
|
27
|
+
return ++nextId;
|
|
28
|
+
};
|
|
29
|
+
const dismissToast = (id: number) => events.push(`dismiss ${id}`);
|
|
30
|
+
let handlers: UseBlockHandlersResult | undefined;
|
|
31
|
+
function Harness() {
|
|
32
|
+
handlers = useBlockHandlers({
|
|
33
|
+
projectId: "demo",
|
|
34
|
+
blockCtxDeps: {
|
|
35
|
+
activeCompPath: null,
|
|
36
|
+
timelineElements: [],
|
|
37
|
+
readProjectFile: async () => "",
|
|
38
|
+
writeProjectFile: async () => {},
|
|
39
|
+
recordEdit: async () => {},
|
|
40
|
+
refreshFileTree: async () => {},
|
|
41
|
+
reloadPreview: () => {},
|
|
42
|
+
showToast,
|
|
43
|
+
dismissToast,
|
|
44
|
+
},
|
|
45
|
+
previewIframeRef: { current: null },
|
|
46
|
+
setRightCollapsed: () => {},
|
|
47
|
+
setRightPanelTab: () => {},
|
|
48
|
+
});
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const root = mountReactHarness(<Harness />);
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
handlers!.handleAddBlock("x-post");
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(events).toEqual([
|
|
59
|
+
"show Adding x-post…",
|
|
60
|
+
"show Installing catalog items needs hyperframes preview",
|
|
61
|
+
"dismiss 1",
|
|
62
|
+
]);
|
|
63
|
+
act(() => root.unmount());
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -23,7 +23,8 @@ interface BlockCtxDeps {
|
|
|
23
23
|
}) => Promise<void>;
|
|
24
24
|
refreshFileTree: () => Promise<void>;
|
|
25
25
|
reloadPreview: () => void;
|
|
26
|
-
showToast: (message: string, tone?: "error" | "info") =>
|
|
26
|
+
showToast: (message: string, tone?: "error" | "info") => number;
|
|
27
|
+
dismissToast: (id: number) => void;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
interface UseBlockHandlersParams {
|
|
@@ -87,21 +88,23 @@ export function useBlockHandlers({
|
|
|
87
88
|
// Block installs hit the server and end in a full preview reload; without a
|
|
88
89
|
// guard, repeat drops while one is in flight stack duplicate installs.
|
|
89
90
|
const installingBlockRef = useRef(false);
|
|
91
|
+
const { showToast, dismissToast } = blockCtxDeps;
|
|
90
92
|
const runBlockInstall = useCallback(
|
|
91
93
|
async <T>(blockName: string, install: () => Promise<T>): Promise<T | null> => {
|
|
92
94
|
if (installingBlockRef.current) {
|
|
93
|
-
|
|
95
|
+
showToast("A block is already installing — one moment…", "info");
|
|
94
96
|
return null;
|
|
95
97
|
}
|
|
96
98
|
installingBlockRef.current = true;
|
|
97
|
-
|
|
99
|
+
const progress = showToast(`Adding ${blockName}…`, "info");
|
|
98
100
|
try {
|
|
99
101
|
return await install();
|
|
100
102
|
} finally {
|
|
101
103
|
installingBlockRef.current = false;
|
|
104
|
+
dismissToast(progress);
|
|
102
105
|
}
|
|
103
106
|
},
|
|
104
|
-
[
|
|
107
|
+
[showToast, dismissToast],
|
|
105
108
|
);
|
|
106
109
|
|
|
107
110
|
const handleAddBlock = useCallback(
|