@hyperframes/studio 0.8.2 → 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.
- package/dist/assets/{hyperframes-player-BTo132Bc.js → hyperframes-player-70BfjZRz.js} +1 -1
- package/dist/assets/{index-CEkoC1xI.js → index-DEnjB744.js} +1 -1
- package/dist/assets/{index-BsU5eQo4.js → index-KH4Y5JCd.js} +202 -202
- package/dist/assets/{index-A4oM8HYD.js → index-RQ7S6nvA.js} +1 -1
- package/dist/{chunk-AZYHQC6V.js → chunk-ZALQ3CW7.js} +2 -2
- package/dist/chunk-ZALQ3CW7.js.map +1 -0
- package/dist/{domEditingLayers-7AMZ7GFI.js → domEditingLayers-XRVXRTNX.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +228 -157
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -11
- package/src/components/editor/LayersPanel.tsx +10 -5
- package/src/components/editor/SnapToolbar.test.tsx +1 -1
- package/src/components/editor/domEditingLayers.test.ts +24 -0
- package/src/components/editor/domEditingLayers.ts +5 -1
- package/src/components/editor/marqueeCommit.ts +36 -23
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +6 -0
- package/src/components/editor/offCanvasIndicatorRefresh.throttle.test.ts +18 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +22 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +14 -9
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +18 -7
- package/src/components/editor/propertyPanelMediaSection.tsx +15 -7
- package/src/components/nle/NLEPreview.tsx +0 -22
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +93 -0
- package/src/hooks/useAppHotkeys.test.ts +85 -4
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +8 -6
- package/src/hooks/useAppHotkeys.ts +50 -52
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +9 -6
- package/src/hooks/useDomEditCommits.ts +16 -14
- package/src/hooks/useDomEditPreviewSync.ts +5 -5
- package/src/hooks/useDomEditSession.membersForDelete.test.ts +23 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +38 -4
- package/src/hooks/useDomEditWiring.ts +3 -3
- package/src/hooks/useDomSelection.ts +13 -10
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +49 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +100 -0
- package/src/hooks/useElementLifecycleOps.ts +74 -15
- package/src/hooks/useStudioUrlState.hydration.test.ts +39 -0
- package/src/hooks/useStudioUrlState.ts +9 -3
- package/src/hooks/useTimelineEditing.ts +55 -28
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +32 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +31 -4
- package/src/player/components/TimelineAutomationLane.test.tsx +31 -2
- package/src/player/components/automationClipboard.test.ts +10 -7
- package/src/player/components/automationLaneGeometry.test.ts +10 -4
- package/dist/chunk-AZYHQC6V.js.map +0 -1
- /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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
104
|
+
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
105
105
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
106
|
-
handleDomEditElementDelete: (
|
|
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
|
-
|
|
148
|
+
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
146
149
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
147
|
-
handleDomEditElementDelete: (
|
|
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
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
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
|
-
|
|
332
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 } =
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
+
syncPreviewHotkeys(previewIframe);
|
|
107
107
|
void applyStudioManualEditsToPreviewRef.current(previewIframe);
|
|
108
108
|
void syncSelectionFromDocument();
|
|
109
109
|
refreshPreviewDocumentVersion();
|
|
110
110
|
|
|
111
111
|
const handleLoad = () => {
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
syncPreviewHotkeys: vi.fn(),
|
|
97
97
|
reloadPreview: vi.fn(),
|
|
98
98
|
setRefreshKey: vi.fn(),
|
|
99
99
|
...overrides,
|