@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.
Files changed (54) hide show
  1. package/dist/assets/{hyperframes-player-C-CeZlk1.js → hyperframes-player-DcM3fVcp.js} +1 -1
  2. package/dist/assets/{index-B4X3myFN.js → index-Cx_UrhoW.js} +202 -202
  3. package/dist/assets/{index-BSDQyOWB.js → index-Del6XWZj.js} +1 -1
  4. package/dist/assets/{index-BU-aDh5r.js → index-DgJrvYGf.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 +240 -163
  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/components/storyboard/FramePoster.test.tsx +61 -0
  26. package/src/components/storyboard/FramePoster.tsx +14 -5
  27. package/src/components/storyboard/StoryboardFrameFocus.tsx +1 -1
  28. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +93 -0
  29. package/src/hooks/useAppHotkeys.test.ts +85 -4
  30. package/src/hooks/useAppHotkeys.textEditing.test.tsx +8 -6
  31. package/src/hooks/useAppHotkeys.ts +50 -52
  32. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +9 -6
  33. package/src/hooks/useDomEditCommits.ts +16 -14
  34. package/src/hooks/useDomEditPreviewSync.ts +5 -5
  35. package/src/hooks/useDomEditSession.membersForDelete.test.ts +23 -0
  36. package/src/hooks/useDomEditSession.test.tsx +1 -1
  37. package/src/hooks/useDomEditSession.ts +38 -4
  38. package/src/hooks/useDomEditWiring.ts +3 -3
  39. package/src/hooks/useDomSelection.ts +13 -10
  40. package/src/hooks/useDomSelectionSelectionGuards.test.ts +49 -0
  41. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +100 -0
  42. package/src/hooks/useElementLifecycleOps.ts +74 -15
  43. package/src/hooks/useStudioUrlState.hydration.test.ts +39 -0
  44. package/src/hooks/useStudioUrlState.ts +9 -3
  45. package/src/hooks/useTimelineEditing.ts +55 -28
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +32 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +31 -4
  48. package/src/player/components/CompositionThumbnail.test.ts +15 -0
  49. package/src/player/components/CompositionThumbnail.tsx +9 -0
  50. package/src/player/components/TimelineAutomationLane.test.tsx +31 -2
  51. package/src/player/components/automationClipboard.test.ts +10 -7
  52. package/src/player/components/automationLaneGeometry.test.ts +10 -4
  53. package/dist/chunk-AZYHQC6V.js.map +0 -1
  54. /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
- syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
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
- syncPreviewHistoryHotkey,
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
- handleDomEditElementDelete,
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
- syncPreviewHistoryHotkey,
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
- syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
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
- syncPreviewHistoryHotkey,
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
- syncPreviewHistoryHotkey,
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) applyDomSelection(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 handleDomEditElementDelete = useCallback(
88
+ const handleDomEditElementsDelete = useCallback(
89
89
  // fallow-ignore-next-line complexity
90
- async (selection: DomEditSelection) => {
90
+ async (selections: DomEditSelection[]) => {
91
91
  const pid = projectIdRef.current;
92
92
  if (!pid) return;
93
- const label = selection.label || selection.id || selection.selector || selection.tagName;
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 patchTarget = buildDomEditPatchTarget(selection);
100
- if (!patchTarget.id && !patchTarget.selector && !patchTarget.hfId) {
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
- if (onTrySdkDelete && selection.hfId) {
105
- const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath);
106
- if (cutoverCommittedOrThrow(handled)) {
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(`Deleted ${label}. Use Undo to restore it.`, "info");
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-element/${encodeURIComponent(targetPath)}`,
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({ target: patchTarget }),
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
- const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
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?.(selection);
159
- showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
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
- return buildDomSelection(element, { preferClipAncestor: false });
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 handleTimelineElementDelete = useCallback(
392
+ const handleTimelineElementsDelete = useCallback(
393
393
  // fallow-ignore-next-line complexity
394
- async (element: TimelineElement) => {
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 label = getTimelineElementLabel(element);
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
- const patchTarget = buildPatchTarget(element);
408
- if (!patchTarget) {
409
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
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
- const removeResponse = await fetch(
413
- `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
414
- {
415
- method: "POST",
416
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
417
- body: JSON.stringify({ target: patchTarget }),
418
- },
419
- );
420
- if (!removeResponse.ok) {
421
- throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
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
- const removeData = (await removeResponse.json()) as {
425
- changed?: boolean;
426
- content?: string;
427
- };
428
- const removedContent =
429
- typeof removeData.content === "string" ? removeData.content : originalContent;
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(`Deleted ${label}. Use Undo to restore it.`, "info");
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,