@hyperframes/studio 0.8.3 → 0.8.5
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-C-CeZlk1.js → hyperframes-player-DcM3fVcp.js} +1 -1
- package/dist/assets/{index-B4X3myFN.js → index-Cx_UrhoW.js} +202 -202
- package/dist/assets/{index-BSDQyOWB.js → index-Del6XWZj.js} +1 -1
- package/dist/assets/{index-BU-aDh5r.js → index-DgJrvYGf.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 +240 -163
- 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/components/storyboard/FramePoster.test.tsx +61 -0
- package/src/components/storyboard/FramePoster.tsx +14 -5
- package/src/components/storyboard/StoryboardFrameFocus.tsx +1 -1
- 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/CompositionThumbnail.test.ts +15 -0
- package/src/player/components/CompositionThumbnail.tsx +9 -0
- 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
|
@@ -20,6 +20,7 @@ import { useDomEditWiring } from "./useDomEditWiring";
|
|
|
20
20
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
21
21
|
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
22
22
|
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
|
23
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
23
24
|
|
|
24
25
|
interface RecordEditInput {
|
|
25
26
|
label: string;
|
|
@@ -42,6 +43,7 @@ export interface UseDomEditSessionParams {
|
|
|
42
43
|
setRightCollapsed: (collapsed: boolean) => void;
|
|
43
44
|
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
44
45
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
46
|
+
isRecordingRef?: React.RefObject<boolean>;
|
|
45
47
|
refreshPreviewDocumentVersion: () => void;
|
|
46
48
|
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
|
|
47
49
|
readProjectFile: (path: string) => Promise<string>;
|
|
@@ -60,7 +62,7 @@ export interface UseDomEditSessionParams {
|
|
|
60
62
|
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
|
61
63
|
(iframe: HTMLIFrameElement) => Promise<void>
|
|
62
64
|
>;
|
|
63
|
-
|
|
65
|
+
syncPreviewHotkeys: (iframe: HTMLIFrameElement | null) => void;
|
|
64
66
|
reloadPreview: () => void;
|
|
65
67
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
66
68
|
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
|
|
@@ -71,6 +73,22 @@ export interface UseDomEditSessionParams {
|
|
|
71
73
|
forceReloadSdkSession?: () => void;
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Which elements a delete acts on. `expandGroup` widens the primary to the
|
|
78
|
+
* whole marquee group, which is what the Delete key means.
|
|
79
|
+
*
|
|
80
|
+
* The caller chooses rather than the delete deciding for everyone: Cut copies
|
|
81
|
+
* the primary alone, so expanding for it put one element on the clipboard and
|
|
82
|
+
* removed every other member of the group with it.
|
|
83
|
+
*/
|
|
84
|
+
export function membersForDelete(
|
|
85
|
+
selection: DomEditSelection,
|
|
86
|
+
group: DomEditSelection[],
|
|
87
|
+
options?: { expandGroup?: boolean },
|
|
88
|
+
): DomEditSelection[] {
|
|
89
|
+
return options?.expandGroup && group.length > 0 ? group : [selection];
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
export function useDomEditSession({
|
|
75
93
|
projectId,
|
|
76
94
|
activeCompPath,
|
|
@@ -85,6 +103,7 @@ export function useDomEditSession({
|
|
|
85
103
|
setRightCollapsed,
|
|
86
104
|
setRightPanelTab,
|
|
87
105
|
showToast,
|
|
106
|
+
isRecordingRef,
|
|
88
107
|
refreshPreviewDocumentVersion,
|
|
89
108
|
queueDomEditSave,
|
|
90
109
|
readProjectFile,
|
|
@@ -101,7 +120,7 @@ export function useDomEditSession({
|
|
|
101
120
|
previewDocumentVersion,
|
|
102
121
|
rightPanelTab,
|
|
103
122
|
applyStudioManualEditsToPreviewRef,
|
|
104
|
-
|
|
123
|
+
syncPreviewHotkeys,
|
|
105
124
|
reloadPreview,
|
|
106
125
|
setRefreshKey: _setRefreshKey,
|
|
107
126
|
openSourceForSelection,
|
|
@@ -238,7 +257,7 @@ export function useDomEditSession({
|
|
|
238
257
|
handleDomRemoveTextField,
|
|
239
258
|
handleDomBoxSizeCommit,
|
|
240
259
|
handleDomManualEditsReset,
|
|
241
|
-
|
|
260
|
+
handleDomEditElementsDelete,
|
|
242
261
|
handleDomZIndexReorderCommit,
|
|
243
262
|
} = useDomEditCommits({
|
|
244
263
|
activeCompPath,
|
|
@@ -332,6 +351,21 @@ export function useDomEditSession({
|
|
|
332
351
|
forceReloadSdkSession,
|
|
333
352
|
});
|
|
334
353
|
|
|
354
|
+
const handleDomEditElementDelete = useCallback(
|
|
355
|
+
async (selection: DomEditSelection, options?: { expandGroup?: boolean }) => {
|
|
356
|
+
// Same structural edit the timeline delete refuses mid-recording, so it
|
|
357
|
+
// refuses here too — this is now the path a Delete press takes whenever
|
|
358
|
+
// the canvas holds a selection.
|
|
359
|
+
if (isRecordingRef?.current) {
|
|
360
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const members = membersForDelete(selection, domEditGroupSelectionsRef.current, options);
|
|
364
|
+
await handleDomEditElementsDelete(members);
|
|
365
|
+
},
|
|
366
|
+
[domEditGroupSelectionsRef, handleDomEditElementsDelete, isRecordingRef, showToast],
|
|
367
|
+
);
|
|
368
|
+
|
|
335
369
|
const handleGroupSelection = useCallback(() => {
|
|
336
370
|
const group = domEditGroupSelectionsRef.current;
|
|
337
371
|
const single = domEditSelectionRef.current;
|
|
@@ -400,7 +434,7 @@ export function useDomEditSession({
|
|
|
400
434
|
bumpGsapCache,
|
|
401
435
|
showToast,
|
|
402
436
|
refreshPreviewDocumentVersion,
|
|
403
|
-
|
|
437
|
+
syncPreviewHotkeys,
|
|
404
438
|
applyStudioManualEditsToPreviewRef,
|
|
405
439
|
applyDomSelection,
|
|
406
440
|
buildDomSelectionFromTarget,
|
|
@@ -33,7 +33,7 @@ export interface UseDomEditWiringParams {
|
|
|
33
33
|
bumpGsapCache: () => void;
|
|
34
34
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
35
35
|
refreshPreviewDocumentVersion: () => void;
|
|
36
|
-
|
|
36
|
+
syncPreviewHotkeys: (iframe: HTMLIFrameElement | null) => void;
|
|
37
37
|
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
|
38
38
|
(iframe: HTMLIFrameElement) => Promise<void>
|
|
39
39
|
>;
|
|
@@ -127,7 +127,7 @@ export function useDomEditWiring({
|
|
|
127
127
|
bumpGsapCache,
|
|
128
128
|
showToast,
|
|
129
129
|
refreshPreviewDocumentVersion,
|
|
130
|
-
|
|
130
|
+
syncPreviewHotkeys,
|
|
131
131
|
applyStudioManualEditsToPreviewRef,
|
|
132
132
|
applyDomSelection,
|
|
133
133
|
buildDomSelectionFromTarget,
|
|
@@ -264,7 +264,7 @@ export function useDomEditWiring({
|
|
|
264
264
|
refreshDomEditGroupSelectionsFromPreview,
|
|
265
265
|
buildDomSelectionFromTarget,
|
|
266
266
|
refreshPreviewDocumentVersion,
|
|
267
|
-
|
|
267
|
+
syncPreviewHotkeys,
|
|
268
268
|
applyStudioManualEditsToPreviewRef,
|
|
269
269
|
openSourceForSelection,
|
|
270
270
|
getSidebarTab,
|
|
@@ -29,6 +29,9 @@ export interface ApplyDomSelectionOptions {
|
|
|
29
29
|
revealPanel?: boolean;
|
|
30
30
|
additive?: boolean;
|
|
31
31
|
preserveGroup?: boolean;
|
|
32
|
+
// A clear that came FROM the timeline must not be echoed back, or picking a
|
|
33
|
+
// clip with no canvas node would deselect the clip you just picked.
|
|
34
|
+
announce?: boolean;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export interface ResolveDomSelectionOptions {
|
|
@@ -170,21 +173,14 @@ export function useDomSelection({
|
|
|
170
173
|
|
|
171
174
|
const applyDomSelection = useCallback(
|
|
172
175
|
// fallow-ignore-next-line complexity
|
|
173
|
-
(
|
|
174
|
-
selection: DomEditSelection | null,
|
|
175
|
-
options?: {
|
|
176
|
-
revealPanel?: boolean;
|
|
177
|
-
additive?: boolean;
|
|
178
|
-
preserveGroup?: boolean;
|
|
179
|
-
},
|
|
180
|
-
) => {
|
|
176
|
+
(selection: DomEditSelection | null, options?: ApplyDomSelectionOptions) => {
|
|
181
177
|
if (!selection) {
|
|
182
178
|
logSelect("clear", { hadGroup: domEditGroupSelectionsRef.current.length });
|
|
183
179
|
domEditSelectionRef.current = null;
|
|
184
180
|
domEditGroupSelectionsRef.current = [];
|
|
185
181
|
setDomEditSelection(null);
|
|
186
182
|
setDomEditGroupSelections([]);
|
|
187
|
-
announceTimelineSelection([], null);
|
|
183
|
+
if (options?.announce !== false) announceTimelineSelection([], null);
|
|
188
184
|
return;
|
|
189
185
|
}
|
|
190
186
|
|
|
@@ -393,7 +389,14 @@ export function useDomSelection({
|
|
|
393
389
|
const selection = await buildDomSelectionForTimelineElement(element);
|
|
394
390
|
// A newer selection superseded this one while we were resolving — drop the stale result.
|
|
395
391
|
if (seq !== timelineSelectSeqRef.current) return;
|
|
396
|
-
if (selection)
|
|
392
|
+
if (selection) {
|
|
393
|
+
applyDomSelection(selection);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// No canvas node (audio, a comp that is not the active one). Leaving the
|
|
397
|
+
// previous selection pointed the canvas at something the user did not pick,
|
|
398
|
+
// and Delete acts on the canvas first — so it removed that, not the clip.
|
|
399
|
+
applyDomSelection(null, { revealPanel: false, announce: false });
|
|
397
400
|
},
|
|
398
401
|
[applyDomSelection, buildDomSelectionForTimelineElement],
|
|
399
402
|
);
|
|
@@ -278,3 +278,52 @@ describe("useDomSelection — marquee multi-select survives the late async prima
|
|
|
278
278
|
iframe.remove();
|
|
279
279
|
});
|
|
280
280
|
});
|
|
281
|
+
|
|
282
|
+
describe("useDomSelection — picking a clip with no canvas node", () => {
|
|
283
|
+
function timelineEl(id: string): TimelineElement {
|
|
284
|
+
return { id, domId: id, tag: "div", start: 0, duration: 1, track: 0 };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
it("drops the canvas selection without deselecting the clip", async () => {
|
|
288
|
+
// Delete acts on the canvas first, so a canvas selection left pointing at
|
|
289
|
+
// the previous element removed THAT element when the user pressed Delete
|
|
290
|
+
// right after picking an audio clip. Clearing it has to stay quiet, though:
|
|
291
|
+
// announcing the clear back would deselect the clip that was just picked.
|
|
292
|
+
const iframe = document.createElement("iframe");
|
|
293
|
+
document.body.append(iframe);
|
|
294
|
+
const doc = iframe.contentDocument!;
|
|
295
|
+
const onCanvas = doc.createElement("div");
|
|
296
|
+
onCanvas.id = "on-canvas";
|
|
297
|
+
doc.body.append(onCanvas);
|
|
298
|
+
|
|
299
|
+
const setSelectedTimelineElementId = vi.fn();
|
|
300
|
+
const setTimelineSelectionSet = vi.fn();
|
|
301
|
+
const harness = renderHarness({
|
|
302
|
+
rightPanelTab: "design",
|
|
303
|
+
setRightPanelTab: vi.fn(),
|
|
304
|
+
iframe,
|
|
305
|
+
timelineElements: [timelineEl("on-canvas"), timelineEl("audio-only")],
|
|
306
|
+
setSelectedTimelineElementId,
|
|
307
|
+
setTimelineSelectionSet,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await act(async () => {
|
|
311
|
+
const pending = harness.current().handleTimelineElementSelect(timelineEl("on-canvas"));
|
|
312
|
+
deferreds.get("on-canvas")?.resolve();
|
|
313
|
+
await pending;
|
|
314
|
+
});
|
|
315
|
+
expect(harness.current().domEditSelectionRef.current).not.toBeNull();
|
|
316
|
+
|
|
317
|
+
setSelectedTimelineElementId.mockClear();
|
|
318
|
+
setTimelineSelectionSet.mockClear();
|
|
319
|
+
await act(async () => {
|
|
320
|
+
await harness.current().handleTimelineElementSelect(timelineEl("audio-only"));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(harness.current().domEditSelectionRef.current).toBeNull();
|
|
324
|
+
expect(setSelectedTimelineElementId).not.toHaveBeenCalled();
|
|
325
|
+
expect(setTimelineSelectionSet).not.toHaveBeenCalled();
|
|
326
|
+
|
|
327
|
+
harness.cleanup();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
6
|
+
import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils";
|
|
7
|
+
import { mountReactHarness, makeSelection } from "./domSelectionTestHarness";
|
|
8
|
+
|
|
9
|
+
function selectionFor(id: string) {
|
|
10
|
+
const el = document.createElement("div");
|
|
11
|
+
el.id = id;
|
|
12
|
+
document.body.append(el);
|
|
13
|
+
return { ...makeSelection(id, el), sourceFile: "index.html" };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
|
17
|
+
const removed: string[] = [];
|
|
18
|
+
const requests: string[] = [];
|
|
19
|
+
let changes = true;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
removed.length = 0;
|
|
23
|
+
requests.length = 0;
|
|
24
|
+
changes = true;
|
|
25
|
+
vi.stubGlobal(
|
|
26
|
+
"fetch",
|
|
27
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
28
|
+
requests.push(String(url));
|
|
29
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
30
|
+
targets?: { id?: string; selector?: string }[];
|
|
31
|
+
};
|
|
32
|
+
for (const target of body.targets ?? []) {
|
|
33
|
+
const key = target.id ?? target.selector;
|
|
34
|
+
if (key) removed.push(key);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
json: async () => ({ changed: changes, content: "<html></html>" }),
|
|
39
|
+
} as unknown as Response;
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.unstubAllGlobals();
|
|
45
|
+
document.body.innerHTML = "";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("removes every selected element, not just the first", async () => {
|
|
49
|
+
// The reported bug: select several elements on the canvas, press Delete, and
|
|
50
|
+
// one disappears while the rest stay — still drawn as selected.
|
|
51
|
+
let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
|
|
52
|
+
function Probe() {
|
|
53
|
+
ops = useElementLifecycleOps(
|
|
54
|
+
makeLifecycleOpsParams({
|
|
55
|
+
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
|
56
|
+
projectIdRef: { current: "p1" },
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
mountReactHarness(<Probe />);
|
|
62
|
+
|
|
63
|
+
const selections = ["a", "b", "c"].map(selectionFor);
|
|
64
|
+
await act(async () => {
|
|
65
|
+
await ops!.handleDomEditElementsDelete(selections);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// The defect: only the first was ever removed.
|
|
69
|
+
expect(removed).toEqual(["a", "b", "c"]);
|
|
70
|
+
// And one request for the selection, not one per member: a canvas selection
|
|
71
|
+
// runs to hundreds, and a round trip each made Delete look like a no-op.
|
|
72
|
+
expect(requests.filter((url) => url.includes("remove-elements"))).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("says so when the preview is stale instead of claiming a delete", async () => {
|
|
76
|
+
// Every target missing means the preview is describing a document the file
|
|
77
|
+
// does not have. Reporting success there is what read as Delete doing
|
|
78
|
+
// nothing at all, with nothing on screen to explain it.
|
|
79
|
+
changes = false;
|
|
80
|
+
const showToast = vi.fn();
|
|
81
|
+
let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
|
|
82
|
+
function Probe() {
|
|
83
|
+
ops = useElementLifecycleOps(
|
|
84
|
+
makeLifecycleOpsParams({
|
|
85
|
+
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
|
86
|
+
projectIdRef: { current: "p1" },
|
|
87
|
+
showToast,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
mountReactHarness(<Probe />);
|
|
93
|
+
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await ops!.handleDomEditElementsDelete([selectionFor("a")]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(showToast.mock.calls.flat().join(" ")).toContain("out of date");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -85,39 +85,77 @@ export function useElementLifecycleOps({
|
|
|
85
85
|
onElementDeleted,
|
|
86
86
|
}: UseElementLifecycleOpsParams) {
|
|
87
87
|
// fallow-ignore-next-line complexity
|
|
88
|
-
const
|
|
88
|
+
const handleDomEditElementsDelete = useCallback(
|
|
89
89
|
// fallow-ignore-next-line complexity
|
|
90
|
-
async (
|
|
90
|
+
async (selections: DomEditSelection[]) => {
|
|
91
91
|
const pid = projectIdRef.current;
|
|
92
92
|
if (!pid) return;
|
|
93
|
-
const
|
|
93
|
+
const [selection] = selections;
|
|
94
|
+
if (!selection) return;
|
|
95
|
+
const label =
|
|
96
|
+
selections.length === 1
|
|
97
|
+
? selection.label || selection.id || selection.selector || selection.tagName
|
|
98
|
+
: `${selections.length} elements`;
|
|
99
|
+
// Say the press landed before doing the work. Deleting a marquee selection
|
|
100
|
+
// takes seconds — reading the file, removing every member, saving, then
|
|
101
|
+
// reloading the preview — and until it finishes the canvas looks exactly
|
|
102
|
+
// like it did before. With nothing acknowledging the key, that silence is
|
|
103
|
+
// indistinguishable from Delete being broken, which is how it got read.
|
|
104
|
+
if (selections.length > 1) showToast(`Deleting ${label}...`, "info");
|
|
94
105
|
|
|
106
|
+
// One file per pass; anything authored elsewhere is dropped rather than
|
|
107
|
+
// patched into the wrong document.
|
|
95
108
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
109
|
+
const sameFile = selections.filter(
|
|
110
|
+
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
111
|
+
);
|
|
96
112
|
try {
|
|
97
113
|
const originalContent = await readProjectFileContent(pid, targetPath);
|
|
98
114
|
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
115
|
+
const patchTargets = sameFile.map((member) => buildDomEditPatchTarget(member));
|
|
116
|
+
if (patchTargets.some((t) => !t.id && !t.selector && !t.hfId)) {
|
|
101
117
|
throw new Error("Selected element has no patchable target");
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
// The SDK path can take the whole selection only when every member is
|
|
121
|
+
// addressable in the SDK doc; otherwise fall through to REST for all of
|
|
122
|
+
// them rather than deleting a subset through each route.
|
|
123
|
+
const hfIds = sameFile
|
|
124
|
+
.map((member) => member.hfId)
|
|
125
|
+
.filter((hfId): hfId is string => Boolean(hfId));
|
|
126
|
+
if (onTrySdkDelete && hfIds.length === sameFile.length) {
|
|
127
|
+
let allHandled = true;
|
|
128
|
+
for (const hfId of hfIds) {
|
|
129
|
+
// The SDK owns the document it edits, so every member is removed
|
|
130
|
+
// against the same starting content rather than a threaded copy.
|
|
131
|
+
const handled = await onTrySdkDelete(hfId, originalContent, targetPath);
|
|
132
|
+
if (!cutoverCommittedOrThrow(handled)) {
|
|
133
|
+
allHandled = false;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (allHandled) {
|
|
107
138
|
clearDomSelection();
|
|
108
139
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
109
|
-
showToast(
|
|
140
|
+
showToast(
|
|
141
|
+
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
142
|
+
"info",
|
|
143
|
+
);
|
|
110
144
|
return;
|
|
111
145
|
}
|
|
112
146
|
}
|
|
113
147
|
|
|
114
148
|
domEditSaveTimestampRef.current = Date.now();
|
|
149
|
+
// One request for the whole selection. Removing members one at a time
|
|
150
|
+
// cost a round trip and a rewrite of the file EACH, and a canvas
|
|
151
|
+
// selection runs to hundreds of members — the file ended up correct, but
|
|
152
|
+
// only after long enough that Delete looked like it had done nothing.
|
|
115
153
|
const removeResponse = await fetch(
|
|
116
|
-
`/api/projects/${pid}/file-mutations/remove-
|
|
154
|
+
`/api/projects/${pid}/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
|
|
117
155
|
{
|
|
118
156
|
method: "POST",
|
|
119
157
|
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
120
|
-
body: JSON.stringify({
|
|
158
|
+
body: JSON.stringify({ targets: patchTargets }),
|
|
121
159
|
},
|
|
122
160
|
);
|
|
123
161
|
if (!removeResponse.ok) {
|
|
@@ -126,8 +164,18 @@ export function useElementLifecycleOps({
|
|
|
126
164
|
`Failed to delete element from ${targetPath}`,
|
|
127
165
|
);
|
|
128
166
|
}
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
const removeData = (await removeResponse.json()) as {
|
|
168
|
+
changed?: boolean;
|
|
169
|
+
content?: string;
|
|
170
|
+
};
|
|
171
|
+
if (!removeData.changed) {
|
|
172
|
+
// A member the file no longer holds simply does not match, which is
|
|
173
|
+
// normal for one nested inside another member already removed. Nothing
|
|
174
|
+
// matching at all means the preview is describing a document the file
|
|
175
|
+
// does not have — say so rather than reporting a delete that happened.
|
|
176
|
+
reloadPreview();
|
|
177
|
+
throw new Error("Nothing to delete — the preview was out of date. Try again.");
|
|
178
|
+
}
|
|
131
179
|
const patchedContent =
|
|
132
180
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
133
181
|
// ponytail: the server remove-element route (removeElementFromHtml) strips
|
|
@@ -155,8 +203,11 @@ export function useElementLifecycleOps({
|
|
|
155
203
|
// SDK edit doesn't resurrect the deleted element.
|
|
156
204
|
forceReloadSdkSession?.();
|
|
157
205
|
reloadPreview();
|
|
158
|
-
onElementDeleted?.(
|
|
159
|
-
showToast(
|
|
206
|
+
for (const member of sameFile) onElementDeleted?.(member);
|
|
207
|
+
showToast(
|
|
208
|
+
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
209
|
+
"info",
|
|
210
|
+
);
|
|
160
211
|
} catch (error) {
|
|
161
212
|
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
162
213
|
showToast(message);
|
|
@@ -337,8 +388,16 @@ export function useElementLifecycleOps({
|
|
|
337
388
|
[commitDomEditPatchBatches, onReorderShadow],
|
|
338
389
|
);
|
|
339
390
|
|
|
391
|
+
const handleDomEditElementDelete = useCallback(
|
|
392
|
+
async (selection: DomEditSelection) => {
|
|
393
|
+
await handleDomEditElementsDelete([selection]);
|
|
394
|
+
},
|
|
395
|
+
[handleDomEditElementsDelete],
|
|
396
|
+
);
|
|
397
|
+
|
|
340
398
|
return {
|
|
341
399
|
handleDomEditElementDelete,
|
|
400
|
+
handleDomEditElementsDelete,
|
|
342
401
|
handleDomZIndexReorderCommit,
|
|
343
402
|
};
|
|
344
403
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { resolveUrlSelections } from "./useStudioUrlState";
|
|
4
|
+
|
|
5
|
+
describe("restoring a selection from the URL", () => {
|
|
6
|
+
it("probes the source for the primary only, never for the group", async () => {
|
|
7
|
+
// Each probe is its own request and they are awaited one after another,
|
|
8
|
+
// while the URL carries the whole selection — so a marquee over a captured
|
|
9
|
+
// page turned every later reload into hundreds of serial round trips before
|
|
10
|
+
// the canvas answered anything.
|
|
11
|
+
const doc = document.implementation.createHTMLDocument();
|
|
12
|
+
const ids = ["a", "b", "c", "d"];
|
|
13
|
+
for (const id of ids) {
|
|
14
|
+
const el = doc.createElement("div");
|
|
15
|
+
el.id = id;
|
|
16
|
+
doc.body.append(el);
|
|
17
|
+
}
|
|
18
|
+
const probed: (boolean | undefined)[] = [];
|
|
19
|
+
const buildDomSelection = vi.fn(
|
|
20
|
+
async (element: HTMLElement, options?: { skipSourceProbe?: boolean }) => {
|
|
21
|
+
probed.push(options?.skipSourceProbe);
|
|
22
|
+
return { element, id: element.id } as never;
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
await resolveUrlSelections({
|
|
27
|
+
doc,
|
|
28
|
+
primaryElement: doc.getElementById("a") as HTMLElement,
|
|
29
|
+
selection: { sourceFile: "index.html" } as never,
|
|
30
|
+
group: ids.slice(1).map((id) => ({ id, sourceFile: "index.html" })) as never,
|
|
31
|
+
activeCompPath: "index.html",
|
|
32
|
+
isCurrent: () => true,
|
|
33
|
+
buildDomSelection,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(probed[0]).toBeUndefined();
|
|
37
|
+
expect(probed.slice(1)).toEqual([true, true, true]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -27,7 +27,7 @@ interface UseStudioUrlStateParams {
|
|
|
27
27
|
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
28
28
|
buildDomSelectionFromTarget: (
|
|
29
29
|
target: HTMLElement,
|
|
30
|
-
options?: { preferClipAncestor?: boolean },
|
|
30
|
+
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
|
|
31
31
|
) => Promise<DomEditSelection | null>;
|
|
32
32
|
applyDomSelection: (
|
|
33
33
|
selection: DomEditSelection | null,
|
|
@@ -124,10 +124,16 @@ async function buildOptionalDomSelection(
|
|
|
124
124
|
buildDomSelection: UseStudioUrlStateParams["buildDomSelectionFromTarget"],
|
|
125
125
|
): Promise<DomEditSelection | null> {
|
|
126
126
|
if (!element) return null;
|
|
127
|
-
|
|
127
|
+
// No source probe for group members. Each one costs a request, they are
|
|
128
|
+
// resolved one after another, and the URL carries the whole selection — so a
|
|
129
|
+
// marquee over a captured page turned every reload into hundreds of serial
|
|
130
|
+
// round trips before the canvas answered anything, including a Delete press.
|
|
131
|
+
// The marquee that produced these members already skips the probe for the
|
|
132
|
+
// same reason; only the primary, whose panel reads the flag, still pays it.
|
|
133
|
+
return buildDomSelection(element, { preferClipAncestor: false, skipSourceProbe: true });
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
async function resolveUrlSelections({
|
|
136
|
+
export async function resolveUrlSelections({
|
|
131
137
|
doc,
|
|
132
138
|
primaryElement,
|
|
133
139
|
selection,
|
|
@@ -389,44 +389,59 @@ export function useTimelineEditing({
|
|
|
389
389
|
});
|
|
390
390
|
|
|
391
391
|
// fallow-ignore-next-line complexity
|
|
392
|
-
const
|
|
392
|
+
const handleTimelineElementsDelete = useCallback(
|
|
393
393
|
// fallow-ignore-next-line complexity
|
|
394
|
-
async (
|
|
394
|
+
async (selection: TimelineElement[]) => {
|
|
395
395
|
if (isRecordingRef?.current) {
|
|
396
396
|
showToast("Cannot edit timeline while recording", "error");
|
|
397
397
|
return;
|
|
398
398
|
}
|
|
399
399
|
const pid = projectIdRef.current;
|
|
400
400
|
if (!pid) throw new Error("No active project");
|
|
401
|
-
const
|
|
401
|
+
const [element] = selection;
|
|
402
|
+
if (!element) return;
|
|
403
|
+
const label =
|
|
404
|
+
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
|
402
405
|
|
|
406
|
+
// One file per delete pass. Every element in a marquee selection lives in
|
|
407
|
+
// the composition being edited, so they share a target; anything that
|
|
408
|
+
// does not is dropped rather than written to the wrong file.
|
|
403
409
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
410
|
+
const sameFile = selection.filter(
|
|
411
|
+
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
412
|
+
);
|
|
404
413
|
try {
|
|
405
414
|
const originalContent = await readFileContent(pid, targetPath);
|
|
406
415
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
416
|
+
// Remove every selected element before saving once. The server rewrites
|
|
417
|
+
// the file per call, so `removedContent` after the last one holds them
|
|
418
|
+
// all — which is what makes this a single history entry, and a single
|
|
419
|
+
// undo, rather than one per clip.
|
|
420
|
+
let removedContent = originalContent;
|
|
421
|
+
for (const target of sameFile) {
|
|
422
|
+
const patchTarget = buildPatchTarget(target);
|
|
423
|
+
if (!patchTarget) {
|
|
424
|
+
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
|
425
|
+
}
|
|
411
426
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
427
|
+
const removeResponse = await fetch(
|
|
428
|
+
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
429
|
+
{
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
432
|
+
body: JSON.stringify({ target: patchTarget }),
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
if (!removeResponse.ok) {
|
|
436
|
+
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
|
437
|
+
}
|
|
423
438
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
439
|
+
const removeData = (await removeResponse.json()) as {
|
|
440
|
+
changed?: boolean;
|
|
441
|
+
content?: string;
|
|
442
|
+
};
|
|
443
|
+
if (typeof removeData.content === "string") removedContent = removeData.content;
|
|
444
|
+
}
|
|
430
445
|
// Content-driven duration: shrink the composition to the furthest
|
|
431
446
|
// remaining clip end, read from the post-removal SOURCE (raw
|
|
432
447
|
// data-duration), so deleting the last/longest clip removes trailing
|
|
@@ -460,15 +475,18 @@ export function useTimelineEditing({
|
|
|
460
475
|
throw error;
|
|
461
476
|
}
|
|
462
477
|
|
|
478
|
+
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
|
463
479
|
usePlayerStore
|
|
464
480
|
.getState()
|
|
465
|
-
.setElements(
|
|
466
|
-
timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
|
|
467
|
-
);
|
|
481
|
+
.setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
|
|
468
482
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
483
|
+
usePlayerStore.getState().setSelectedElementIds(new Set());
|
|
469
484
|
forceReloadSdkSession?.();
|
|
470
485
|
reloadPreview();
|
|
471
|
-
showToast(
|
|
486
|
+
showToast(
|
|
487
|
+
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
488
|
+
"info",
|
|
489
|
+
);
|
|
472
490
|
} catch (error) {
|
|
473
491
|
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
474
492
|
showToast(message);
|
|
@@ -488,6 +506,14 @@ export function useTimelineEditing({
|
|
|
488
506
|
],
|
|
489
507
|
);
|
|
490
508
|
|
|
509
|
+
/** Single-clip delete — the context menu and clip chrome path. */
|
|
510
|
+
const handleTimelineElementDelete = useCallback(
|
|
511
|
+
async (element: TimelineElement) => {
|
|
512
|
+
await handleTimelineElementsDelete([element]);
|
|
513
|
+
},
|
|
514
|
+
[handleTimelineElementsDelete],
|
|
515
|
+
);
|
|
516
|
+
|
|
491
517
|
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
|
|
492
518
|
useTimelineAssetDropOps({
|
|
493
519
|
projectIdRef,
|
|
@@ -533,6 +559,7 @@ export function useTimelineEditing({
|
|
|
533
559
|
handleToggleTrackHidden,
|
|
534
560
|
handleToggleElementHidden,
|
|
535
561
|
handleTimelineElementDelete,
|
|
562
|
+
handleTimelineElementsDelete,
|
|
536
563
|
handleTimelineElementSplit: handleRazorSplit,
|
|
537
564
|
handleRazorSplit,
|
|
538
565
|
handleRazorSplitAll,
|