@hyperframes/studio 0.8.3 → 0.8.4

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.
Files changed (49) hide show
  1. package/dist/assets/{hyperframes-player-C-CeZlk1.js → hyperframes-player-70BfjZRz.js} +1 -1
  2. package/dist/assets/{index-BU-aDh5r.js → index-DEnjB744.js} +1 -1
  3. package/dist/assets/{index-B4X3myFN.js → index-KH4Y5JCd.js} +202 -202
  4. package/dist/assets/{index-BSDQyOWB.js → index-RQ7S6nvA.js} +1 -1
  5. package/dist/{chunk-AZYHQC6V.js → chunk-ZALQ3CW7.js} +2 -2
  6. package/dist/chunk-ZALQ3CW7.js.map +1 -0
  7. package/dist/{domEditingLayers-7AMZ7GFI.js → domEditingLayers-XRVXRTNX.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +228 -157
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +9 -11
  13. package/src/components/editor/LayersPanel.tsx +10 -5
  14. package/src/components/editor/SnapToolbar.test.tsx +1 -1
  15. package/src/components/editor/domEditingLayers.test.ts +24 -0
  16. package/src/components/editor/domEditingLayers.ts +5 -1
  17. package/src/components/editor/marqueeCommit.ts +36 -23
  18. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +6 -0
  19. package/src/components/editor/offCanvasIndicatorRefresh.throttle.test.ts +18 -0
  20. package/src/components/editor/offCanvasIndicatorRefresh.ts +22 -1
  21. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +14 -9
  22. package/src/components/editor/propertyPanelFlatMediaSection.tsx +18 -7
  23. package/src/components/editor/propertyPanelMediaSection.tsx +15 -7
  24. package/src/components/nle/NLEPreview.tsx +0 -22
  25. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +93 -0
  26. package/src/hooks/useAppHotkeys.test.ts +85 -4
  27. package/src/hooks/useAppHotkeys.textEditing.test.tsx +8 -6
  28. package/src/hooks/useAppHotkeys.ts +50 -52
  29. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +9 -6
  30. package/src/hooks/useDomEditCommits.ts +16 -14
  31. package/src/hooks/useDomEditPreviewSync.ts +5 -5
  32. package/src/hooks/useDomEditSession.membersForDelete.test.ts +23 -0
  33. package/src/hooks/useDomEditSession.test.tsx +1 -1
  34. package/src/hooks/useDomEditSession.ts +38 -4
  35. package/src/hooks/useDomEditWiring.ts +3 -3
  36. package/src/hooks/useDomSelection.ts +13 -10
  37. package/src/hooks/useDomSelectionSelectionGuards.test.ts +49 -0
  38. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +100 -0
  39. package/src/hooks/useElementLifecycleOps.ts +74 -15
  40. package/src/hooks/useStudioUrlState.hydration.test.ts +39 -0
  41. package/src/hooks/useStudioUrlState.ts +9 -3
  42. package/src/hooks/useTimelineEditing.ts +55 -28
  43. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +32 -0
  44. package/src/hooks/useTimelineSelectionPreviewSync.ts +31 -4
  45. package/src/player/components/TimelineAutomationLane.test.tsx +31 -2
  46. package/src/player/components/automationClipboard.test.ts +10 -7
  47. package/src/player/components/automationLaneGeometry.test.ts +10 -4
  48. package/dist/chunk-AZYHQC6V.js.map +0 -1
  49. /package/dist/{domEditingLayers-7AMZ7GFI.js.map → domEditingLayers-XRVXRTNX.js.map} +0 -0
@@ -0,0 +1,93 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import type { Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { DomEditSelection } from "../components/editor/domEditing";
7
+ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
8
+ import { usePlayerStore } from "../player/store/playerStore";
9
+ import { useAppHotkeys } from "./useAppHotkeys";
10
+ import { mountReactHarness } from "./domSelectionTestHarness";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ const domDelete = vi.fn(async () => undefined);
15
+ let root: Root | null = null;
16
+ let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
17
+
18
+ function selection(): DomEditSelection {
19
+ const element = document.createElement("section");
20
+ element.id = "card";
21
+ return {
22
+ element,
23
+ id: "card",
24
+ selector: "#card",
25
+ selectorIndex: 0,
26
+ sourceFile: "index.html",
27
+ } as unknown as DomEditSelection;
28
+ }
29
+
30
+ function Harness() {
31
+ const selectionRef = useRef<DomEditSelection | null>(selection());
32
+ const hotkeys = useAppHotkeys({
33
+ handleTimelineElementsDelete: vi.fn(async () => undefined),
34
+ handleTimelineElementSplit: vi.fn(async () => undefined),
35
+ handleDomEditElementDelete: domDelete,
36
+ domEditSelectionRef: selectionRef,
37
+ clearDomSelectionRef: useRef<() => void>(() => undefined),
38
+ editHistory: {
39
+ undo: vi.fn(async () => ({ ok: false })),
40
+ redo: vi.fn(async () => ({ ok: false })),
41
+ state: { undo: [], redo: [] },
42
+ },
43
+ readOptionalProjectFile: vi.fn(async () => ""),
44
+ readProjectFile: vi.fn(async () => ""),
45
+ writeProjectFile: vi.fn(async () => undefined),
46
+ domEditSaveTimestampRef: useRef(0),
47
+ showToast: vi.fn(),
48
+ syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
49
+ waitForPendingDomEditSaves: vi.fn(async () => undefined),
50
+ leftSidebarRef: useRef<LeftSidebarHandle | null>(null),
51
+ handleCopy: vi.fn(() => false),
52
+ handlePaste: vi.fn(() => false),
53
+ handleCut: vi.fn(() => false),
54
+ onResetKeyframes: vi.fn(() => false),
55
+ onDeleteSelectedKeyframes: vi.fn(),
56
+ onAfterUndoRedo: vi.fn(),
57
+ } as unknown as Parameters<typeof useAppHotkeys>[0]);
58
+ sync = hotkeys.syncPreviewHotkeys;
59
+ return null;
60
+ }
61
+
62
+ afterEach(() => {
63
+ if (root) act(() => root?.unmount());
64
+ root = null;
65
+ document.body.innerHTML = "";
66
+ usePlayerStore.getState().reset();
67
+ domDelete.mockClear();
68
+ });
69
+
70
+ describe("preview iframe hotkey forwarding", () => {
71
+ it("still delivers Delete after the preview reloads", () => {
72
+ // A reload keeps the iframe element (no ref callback) and the same
73
+ // WindowProxy (an identity check sees no change) but replaces the window
74
+ // holding the listeners. Attaching once left Delete dead inside the canvas
75
+ // after the first reload — and clicking the canvas is what puts focus there.
76
+ root = mountReactHarness(<Harness />);
77
+
78
+ const iframe = document.createElement("iframe");
79
+ document.body.append(iframe);
80
+ act(() => sync?.(iframe));
81
+
82
+ // The reload: same element, a window that has lost its listeners.
83
+ act(() => sync?.(iframe));
84
+
85
+ const inner = iframe.contentWindow as (Window & typeof globalThis) | null;
86
+ if (!inner) throw new Error("expected an iframe window");
87
+ inner.document.body.dispatchEvent(
88
+ new inner.KeyboardEvent("keydown", { key: "Delete", bubbles: true }),
89
+ );
90
+
91
+ expect(domDelete).toHaveBeenCalledTimes(1);
92
+ });
93
+ });
@@ -22,6 +22,7 @@ const bgmElement: TimelineElement = {
22
22
  function callbacks() {
23
23
  return {
24
24
  handleTimelineElementDelete: vi.fn(async () => {}),
25
+ handleTimelineElementsDelete: vi.fn(async () => {}),
25
26
  handleTimelineElementSplit: vi.fn(async () => {}),
26
27
  handleDomEditElementDelete: vi.fn(async () => {}),
27
28
  handleUndo: vi.fn(async () => {}),
@@ -69,10 +70,90 @@ describe("dispatchPlainKey — Delete arbitration", () => {
69
70
  const e = press("Delete");
70
71
  dispatchPlainKey(e, "delete", cb);
71
72
  // The pre-existing contract, pinned so the new guard cannot widen.
72
- expect(cb.handleTimelineElementDelete).toHaveBeenCalledTimes(1);
73
+ expect(cb.handleTimelineElementsDelete).toHaveBeenCalledTimes(1);
74
+ expect(cb.handleTimelineElementsDelete).toHaveBeenCalledWith([bgmElement]);
73
75
  expect(e.defaultPrevented).toBe(true);
74
76
  });
75
77
 
78
+ it("deletes EVERY clip in a marquee selection, not just the first", () => {
79
+ // The reported bug: select all, press Delete, and one clip disappears while
80
+ // the rest stay — still drawn as selected. The handler used `elements.find`,
81
+ // which stops at the first match.
82
+ const clips = ["a", "b", "c"].map((id) => ({ ...bgmElement, id, key: id }));
83
+ usePlayerStore.setState({
84
+ elements: clips,
85
+ selectedElementId: null,
86
+ selectedElementIds: new Set(["a", "b", "c"]),
87
+ });
88
+
89
+ const cb = callbacks();
90
+ const e = press("Delete");
91
+ dispatchPlainKey(e, "delete", cb);
92
+
93
+ expect(cb.handleTimelineElementsDelete).toHaveBeenCalledTimes(1);
94
+ const [passed] = cb.handleTimelineElementsDelete.mock.calls[0] as [typeof clips];
95
+ expect(passed.map((c) => c.key)).toEqual(["a", "b", "c"]);
96
+ expect(e.defaultPrevented).toBe(true);
97
+ });
98
+
99
+ it("hands a canvas selection its whole group instead of the timeline's partial copy", () => {
100
+ // The reported bug: marquee 73 elements on the canvas, press Delete, and 14
101
+ // vanish. Only those 14 owned a timeline row, and the timeline mirror drops
102
+ // every member that does not — so deleting through it left 59 behind, still
103
+ // drawn as selected.
104
+ const clips = ["a", "b"].map((id) => ({ ...bgmElement, id, key: id }));
105
+ usePlayerStore.setState({
106
+ elements: clips,
107
+ selectedElementId: null,
108
+ selectedElementIds: new Set(["a", "b"]),
109
+ });
110
+ const domSelection = { selector: ".title", selectorIndex: 0, sourceFile: "index.html" };
111
+
112
+ const cb = callbacks();
113
+ cb.domEditSelectionRef = { current: domSelection } as typeof cb.domEditSelectionRef;
114
+ const e = press("Delete");
115
+ dispatchPlainKey(e, "delete", cb);
116
+
117
+ expect(cb.handleDomEditElementDelete).toHaveBeenCalledWith(domSelection, {
118
+ expandGroup: true,
119
+ });
120
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
121
+ expect(e.defaultPrevented).toBe(true);
122
+ });
123
+
124
+ it("asks for the whole group, so Cut can still take just the one it copied", () => {
125
+ // Expanding inside the delete handler meant every caller got the group.
126
+ // Cut copies the primary alone, so it put one element on the clipboard and
127
+ // removed every other member with it; paste brought back one.
128
+ usePlayerStore.setState({
129
+ elements: [],
130
+ selectedElementId: null,
131
+ selectedElementIds: new Set(),
132
+ });
133
+ const domSelection = { selector: ".title", selectorIndex: 0, sourceFile: "index.html" };
134
+
135
+ const cb = callbacks();
136
+ cb.domEditSelectionRef = { current: domSelection } as typeof cb.domEditSelectionRef;
137
+ dispatchPlainKey(press("Delete"), "delete", cb);
138
+
139
+ expect(cb.handleDomEditElementDelete).toHaveBeenCalledWith(domSelection, { expandGroup: true });
140
+ });
141
+
142
+ it("includes the primary selection alongside the marquee set", () => {
143
+ const clips = ["a", "b"].map((id) => ({ ...bgmElement, id, key: id }));
144
+ usePlayerStore.setState({
145
+ elements: clips,
146
+ selectedElementId: "b",
147
+ selectedElementIds: new Set(["a"]),
148
+ });
149
+
150
+ const cb = callbacks();
151
+ dispatchPlainKey(press("Delete"), "delete", cb);
152
+
153
+ const [passed] = cb.handleTimelineElementsDelete.mock.calls[0] as [typeof clips];
154
+ expect(passed.map((c) => c.key).sort()).toEqual(["a", "b"]);
155
+ });
156
+
76
157
  it("leaves the clip alone when an automation range is active", () => {
77
158
  // The bug: this listener is on window/capture so it runs BEFORE
78
159
  // useAutomationSelectionKeyboard's document/capture handler. Without the
@@ -82,7 +163,7 @@ describe("dispatchPlainKey — Delete arbitration", () => {
82
163
  const cb = callbacks();
83
164
  const e = press("Delete");
84
165
  dispatchPlainKey(e, "delete", cb);
85
- expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
166
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
86
167
  // Must NOT be consumed: the automation handler downstream still needs it.
87
168
  expect(e.defaultPrevented).toBe(false);
88
169
  });
@@ -99,7 +180,7 @@ describe("dispatchPlainKey — Delete arbitration", () => {
99
180
  const e = press("Backspace");
100
181
  dispatchPlainKey(e, "backspace", cb);
101
182
  expect(cb.onResetKeyframes).not.toHaveBeenCalled();
102
- expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
183
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
103
184
  expect(e.defaultPrevented).toBe(false);
104
185
  });
105
186
 
@@ -113,7 +194,7 @@ describe("dispatchPlainKey — Delete arbitration", () => {
113
194
  const e = press("Delete");
114
195
  dispatchPlainKey(e, "delete", cb);
115
196
  expect(cb.onDeleteSelectedKeyframes).toHaveBeenCalledTimes(1);
116
- expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
197
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
117
198
  expect(e.defaultPrevented).toBe(true);
118
199
  });
119
200
  });
@@ -11,7 +11,7 @@ import { useAppHotkeys } from "./useAppHotkeys";
11
11
 
12
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
13
 
14
- const timelineDelete = vi.fn(async () => undefined);
14
+ const timelineDeleteMany = vi.fn(async () => undefined);
15
15
  const domDelete = vi.fn(async () => undefined);
16
16
  const keyframeDelete = vi.fn();
17
17
  const textCommits = vi.fn();
@@ -74,7 +74,7 @@ function Harness() {
74
74
  const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
75
75
 
76
76
  useAppHotkeys({
77
- handleTimelineElementDelete: timelineDelete,
77
+ handleTimelineElementsDelete: timelineDeleteMany,
78
78
  handleTimelineElementSplit: vi.fn(async () => undefined),
79
79
  handleDomEditElementDelete: domDelete,
80
80
  domEditSelectionRef: selectionRef,
@@ -140,7 +140,7 @@ function pressBackspace(target: HTMLElement): KeyboardEvent {
140
140
 
141
141
  beforeEach(() => {
142
142
  vi.useFakeTimers();
143
- timelineDelete.mockClear();
143
+ timelineDeleteMany.mockClear();
144
144
  domDelete.mockClear();
145
145
  keyframeDelete.mockClear();
146
146
  textCommits.mockClear();
@@ -200,7 +200,7 @@ describe("useAppHotkeys text-field ownership", () => {
200
200
  expect(document.activeElement).toBe(textarea);
201
201
  expect(textarea.selectionStart).toBe(4);
202
202
  expect(textarea.selectionEnd).toBe(4);
203
- expect(timelineDelete).not.toHaveBeenCalled();
203
+ expect(timelineDeleteMany).not.toHaveBeenCalled();
204
204
  expect(domDelete).not.toHaveBeenCalled();
205
205
  expect(keyframeDelete).not.toHaveBeenCalled();
206
206
 
@@ -208,8 +208,10 @@ describe("useAppHotkeys text-field ownership", () => {
208
208
  const canvasDelete = pressBackspace(canvas);
209
209
 
210
210
  expect(canvasDelete.defaultPrevented).toBe(true);
211
- expect(timelineDelete).toHaveBeenCalledTimes(1);
212
- expect(domDelete).not.toHaveBeenCalled();
211
+ // The canvas holds a selection, so it owns the delete — the timeline's copy
212
+ // of that selection is derived and drops any member without a timeline row.
213
+ expect(domDelete).toHaveBeenCalledTimes(1);
214
+ expect(timelineDeleteMany).not.toHaveBeenCalled();
213
215
  expect(keyframeDelete).not.toHaveBeenCalled();
214
216
  });
215
217
  });
@@ -101,9 +101,12 @@ interface EditHistoryHandle {
101
101
  }
102
102
 
103
103
  interface UseAppHotkeysParams {
104
- handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
104
+ handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
105
105
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
106
- handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
106
+ handleDomEditElementDelete: (
107
+ selection: DomEditSelection,
108
+ options?: { expandGroup?: boolean },
109
+ ) => Promise<void>;
107
110
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
108
111
  clearDomSelectionRef: React.MutableRefObject<() => void>;
109
112
  editHistory: EditHistoryHandle;
@@ -142,9 +145,12 @@ interface UseAppHotkeysParams {
142
145
  // ── Extracted keydown dispatch (pure function, no hooks) ──
143
146
 
144
147
  interface HotkeyCallbacks {
145
- handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
148
+ handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
146
149
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
147
- handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
150
+ handleDomEditElementDelete: (
151
+ selection: DomEditSelection,
152
+ options?: { expandGroup?: boolean },
153
+ ) => Promise<void>;
148
154
  handleUndo: () => Promise<void>;
149
155
  handleRedo: () => Promise<void>;
150
156
  handleCopy: () => boolean;
@@ -322,24 +328,29 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
322
328
  return;
323
329
  }
324
330
  }
325
- // Delete acts on the primary selection OR the marquee multi-selection
326
- // the delete handler expands a clip that is part of the multi-selection
327
- // into an atomic delete of the whole selection (single undo).
331
+ // The canvas selection is what the user actually drew a marquee around, so
332
+ // it owns Delete whenever it holds something. The timeline mirror of that
333
+ // selection is derived and lossy a member with no timeline row of its own
334
+ // is dropped from it — so deleting through the timeline removed the handful
335
+ // of clips it knew about and left every other selected element behind,
336
+ // still drawn as selected. The timeline path stays as the fallback for rows
337
+ // with no canvas node to select (audio, a comp that is not the active one).
338
+ const domSel = cb.domEditSelectionRef.current;
339
+ if (domSel) {
340
+ event.preventDefault();
341
+ // The whole marquee group, not just the primary the ref holds.
342
+ void cb.handleDomEditElementDelete(domSel, { expandGroup: true });
343
+ return;
344
+ }
345
+ // Takes the WHOLE selection: `find` returned the first match, so selecting
346
+ // every clip and pressing Delete removed exactly one of them.
328
347
  const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
329
348
  const selectionKeys = new Set(selectedElementIds);
330
349
  if (selectedElementId) selectionKeys.add(selectedElementId);
331
- if (selectionKeys.size > 0) {
332
- const el = elements.find((e) => selectionKeys.has(e.key ?? e.id));
333
- if (el) {
334
- event.preventDefault();
335
- void cb.handleTimelineElementDelete(el);
336
- return;
337
- }
338
- }
339
- const domSel = cb.domEditSelectionRef.current;
340
- if (domSel) {
350
+ const selected = elements.filter((e) => selectionKeys.has(e.key ?? e.id));
351
+ if (selected.length > 0) {
341
352
  event.preventDefault();
342
- void cb.handleDomEditElementDelete(domSel);
353
+ void cb.handleTimelineElementsDelete(selected);
343
354
  }
344
355
  return;
345
356
  }
@@ -353,7 +364,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
353
364
  // ── Hook ──
354
365
 
355
366
  export function useAppHotkeys({
356
- handleTimelineElementDelete,
367
+ handleTimelineElementsDelete,
357
368
  handleTimelineElementSplit,
358
369
  handleDomEditElementDelete,
359
370
  domEditSelectionRef,
@@ -378,7 +389,6 @@ export function useAppHotkeys({
378
389
  activeCompPath,
379
390
  forceReloadSdkSession,
380
391
  }: UseAppHotkeysParams) {
381
- const previewHotkeyWindowRef = useRef<Window | null>(null);
382
392
  const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
383
393
 
384
394
  // ── Undo / Redo ──
@@ -454,7 +464,7 @@ export function useAppHotkeys({
454
464
 
455
465
  const cbRef = useRef<HotkeyCallbacks>(null!);
456
466
  cbRef.current = {
457
- handleTimelineElementDelete,
467
+ handleTimelineElementsDelete,
458
468
  handleTimelineElementSplit,
459
469
  handleDomEditElementDelete,
460
470
  handleUndo,
@@ -492,33 +502,6 @@ export function useAppHotkeys({
492
502
 
493
503
  // ── Preview iframe forwarding ──
494
504
 
495
- const syncPreviewTimelineHotkey = useCallback(
496
- (iframe: HTMLIFrameElement | null) => {
497
- const nextWindow = iframeContentWindow(iframe);
498
- if (previewHotkeyWindowRef.current === nextWindow) return;
499
- safeRemoveListener(
500
- previewHotkeyWindowRef.current,
501
- "keydown",
502
- handleAppKeyDown as EventListener,
503
- );
504
- previewHotkeyWindowRef.current = nextWindow;
505
- safeAddListener(nextWindow, "keydown", handleAppKeyDown as EventListener, true);
506
- },
507
- [handleAppKeyDown],
508
- );
509
-
510
- useEffect(
511
- () => () => {
512
- safeRemoveListener(
513
- previewHotkeyWindowRef.current,
514
- "keydown",
515
- handleAppKeyDown as EventListener,
516
- );
517
- previewHotkeyWindowRef.current = null;
518
- },
519
- [handleAppKeyDown],
520
- );
521
-
522
505
  const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
523
506
  if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
524
507
  handleUndoRedoKey(
@@ -528,7 +511,18 @@ export function useAppHotkeys({
528
511
  );
529
512
  }, []);
530
513
 
531
- const syncPreviewHistoryHotkey = useCallback(
514
+ /**
515
+ * Give the preview iframe the app's hotkeys, because a keypress lands in
516
+ * whichever document has focus and clicking the canvas puts focus in there.
517
+ *
518
+ * Must run on every iframe LOAD, not once when the element mounts: a reload
519
+ * keeps the same element (so no ref callback) and the same WindowProxy (so an
520
+ * identity check sees no change) while replacing the inner window that holds
521
+ * the listeners. Attaching once left Delete dead in the canvas after the first
522
+ * reload — press it with a selection and nothing happened, no toast, nothing
523
+ * to explain it — while undo/redo kept working because they re-attached here.
524
+ */
525
+ const syncPreviewHotkeys = useCallback(
532
526
  (iframe: HTMLIFrameElement | null) => {
533
527
  previewHistoryCleanupRef.current?.();
534
528
  previewHistoryCleanupRef.current = null;
@@ -541,14 +535,19 @@ export function useAppHotkeys({
541
535
  }
542
536
  if (!win && !doc) return;
543
537
  const handler = handleHistoryHotkey as EventListener;
538
+ const appHandler = handleAppKeyDown as EventListener;
544
539
  safeAddListener(win, "keydown", handler, true);
540
+ // Window only: the history pair also listens on the document, and a
541
+ // capture listener on both would run the app handler twice per press.
542
+ safeAddListener(win, "keydown", appHandler, true);
545
543
  doc?.addEventListener("keydown", handleHistoryHotkey, true);
546
544
  previewHistoryCleanupRef.current = () => {
547
545
  safeRemoveListener(win, "keydown", handler);
546
+ safeRemoveListener(win, "keydown", appHandler);
548
547
  doc?.removeEventListener("keydown", handleHistoryHotkey, true);
549
548
  };
550
549
  },
551
- [handleHistoryHotkey],
550
+ [handleAppKeyDown, handleHistoryHotkey],
552
551
  );
553
552
 
554
553
  useEffect(
@@ -562,7 +561,6 @@ export function useAppHotkeys({
562
561
  return {
563
562
  handleUndo,
564
563
  handleRedo,
565
- syncPreviewTimelineHotkey,
566
- syncPreviewHistoryHotkey,
564
+ syncPreviewHotkeys,
567
565
  };
568
566
  }
@@ -261,9 +261,10 @@ describe("useAutomationSelectionKeyboard", () => {
261
261
  t0: 5,
262
262
  t1: 7,
263
263
  // Full height: everything the paste landed is selected, so Delete straight
264
- // after undoes it in one press.
264
+ // after undoes it in one press. The volume axis tops out at the authoring
265
+ // ceiling, not at unity.
265
266
  v0: 0,
266
- v1: 1,
267
+ v1: VOLUME_RANGE.max,
267
268
  });
268
269
  });
269
270
 
@@ -300,9 +301,10 @@ describe("useAutomationSelectionKeyboard", () => {
300
301
  t0: 4,
301
302
  t1: 6,
302
303
  // Full height: everything the paste landed is selected, so Delete straight
303
- // after undoes it in one press.
304
+ // after undoes it in one press. The volume axis tops out at the authoring
305
+ // ceiling, not at unity.
304
306
  v0: 0,
305
- v1: 1,
307
+ v1: VOLUME_RANGE.max,
306
308
  });
307
309
  });
308
310
 
@@ -338,9 +340,10 @@ describe("useAutomationSelectionKeyboard", () => {
338
340
  t0: 4,
339
341
  t1: 6,
340
342
  // Full height: everything the paste landed is selected, so Delete straight
341
- // after undoes it in one press.
343
+ // after undoes it in one press. The volume axis tops out at the authoring
344
+ // ceiling, not at unity.
342
345
  v0: 0,
343
- v1: 1,
346
+ v1: VOLUME_RANGE.max,
344
347
  });
345
348
  });
346
349
 
@@ -420,20 +420,21 @@ export function useDomEditCommits({
420
420
 
421
421
  // ── Element lifecycle (delete, z-index reorder) ──
422
422
 
423
- const { handleDomEditElementDelete, handleDomZIndexReorderCommit } = useElementLifecycleOps({
424
- activeCompPath,
425
- showToast,
426
- writeProjectFile,
427
- domEditSaveTimestampRef,
428
- editHistory,
429
- projectIdRef,
430
- reloadPreview,
431
- clearDomSelection,
432
- onTrySdkDelete,
433
- onReorderShadow,
434
- forceReloadSdkSession,
435
- commitDomEditPatchBatches,
436
- });
423
+ const { handleDomEditElementDelete, handleDomEditElementsDelete, handleDomZIndexReorderCommit } =
424
+ useElementLifecycleOps({
425
+ activeCompPath,
426
+ showToast,
427
+ writeProjectFile,
428
+ domEditSaveTimestampRef,
429
+ editHistory,
430
+ projectIdRef,
431
+ reloadPreview,
432
+ clearDomSelection,
433
+ onTrySdkDelete,
434
+ onReorderShadow,
435
+ forceReloadSdkSession,
436
+ commitDomEditPatchBatches,
437
+ });
437
438
 
438
439
  return {
439
440
  resolveImportedFontAsset,
@@ -454,6 +455,7 @@ export function useDomEditCommits({
454
455
  handleDomRotationCommit,
455
456
  handleDomManualEditsReset,
456
457
  handleDomEditElementDelete,
458
+ handleDomEditElementsDelete,
457
459
  handleDomZIndexReorderCommit,
458
460
  };
459
461
  }
@@ -25,7 +25,7 @@ interface UseDomEditPreviewSyncParams {
25
25
  ) => void;
26
26
  buildDomSelectionFromTarget: (element: HTMLElement) => Promise<DomEditSelection | null>;
27
27
  refreshPreviewDocumentVersion: () => void;
28
- syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
28
+ syncPreviewHotkeys: (iframe: HTMLIFrameElement | null) => void;
29
29
  applyStudioManualEditsToPreviewRef: React.MutableRefObject<
30
30
  (iframe: HTMLIFrameElement) => Promise<void>
31
31
  >;
@@ -45,7 +45,7 @@ export function useDomEditPreviewSync({
45
45
  refreshDomEditGroupSelectionsFromPreview,
46
46
  buildDomSelectionFromTarget,
47
47
  refreshPreviewDocumentVersion,
48
- syncPreviewHistoryHotkey,
48
+ syncPreviewHotkeys,
49
49
  applyStudioManualEditsToPreviewRef,
50
50
  openSourceForSelection,
51
51
  getSidebarTab,
@@ -103,13 +103,13 @@ export function useDomEditPreviewSync({
103
103
  }
104
104
  };
105
105
 
106
- syncPreviewHistoryHotkey(previewIframe);
106
+ syncPreviewHotkeys(previewIframe);
107
107
  void applyStudioManualEditsToPreviewRef.current(previewIframe);
108
108
  void syncSelectionFromDocument();
109
109
  refreshPreviewDocumentVersion();
110
110
 
111
111
  const handleLoad = () => {
112
- syncPreviewHistoryHotkey(previewIframe);
112
+ syncPreviewHotkeys(previewIframe);
113
113
  void applyStudioManualEditsToPreviewRef.current(previewIframe);
114
114
  void syncSelectionFromDocument();
115
115
  refreshPreviewDocumentVersion();
@@ -129,7 +129,7 @@ export function useDomEditPreviewSync({
129
129
  previewIframe,
130
130
  refreshDomEditGroupSelectionsFromPreview,
131
131
  refreshPreviewDocumentVersion,
132
- syncPreviewHistoryHotkey,
132
+ syncPreviewHotkeys,
133
133
  applyStudioManualEditsToPreviewRef,
134
134
  gsapCacheVersion,
135
135
  ]);
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { membersForDelete } from "./useDomEditSession";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+
5
+ const sel = (id: string) => ({ id }) as DomEditSelection;
6
+
7
+ describe("membersForDelete", () => {
8
+ it("takes the whole group when the caller asks to expand", () => {
9
+ const group = [sel("a"), sel("b"), sel("c")];
10
+ expect(membersForDelete(sel("a"), group, { expandGroup: true })).toEqual(group);
11
+ });
12
+
13
+ it("takes only the primary otherwise, so Cut removes what it copied", () => {
14
+ // Cut copies the primary alone. Expanding here put one element on the
15
+ // clipboard and removed every other member of the group with it.
16
+ const group = [sel("a"), sel("b"), sel("c")];
17
+ expect(membersForDelete(sel("a"), group)).toEqual([sel("a")]);
18
+ });
19
+
20
+ it("falls back to the primary when there is no group", () => {
21
+ expect(membersForDelete(sel("a"), [], { expandGroup: true })).toEqual([sel("a")]);
22
+ });
23
+ });
@@ -93,7 +93,7 @@ function createSessionParams(
93
93
  previewDocumentVersion: 0,
94
94
  rightPanelTab: "design",
95
95
  applyStudioManualEditsToPreviewRef: { current: async () => {} },
96
- syncPreviewHistoryHotkey: vi.fn(),
96
+ syncPreviewHotkeys: vi.fn(),
97
97
  reloadPreview: vi.fn(),
98
98
  setRefreshKey: vi.fn(),
99
99
  ...overrides,