@hyperframes/studio 0.7.102 → 0.7.104

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-BTnkvhf3.js → hyperframes-player-Bj2HAymq.js} +1 -1
  2. package/dist/assets/{index-CnrSn9Ua.js → index-8UzO6PkK.js} +1 -1
  3. package/dist/assets/{index-DN614u1l.js → index-DzfDa1ci.js} +212 -212
  4. package/dist/assets/{index-B4MocVKM.js → index-TDfRaO1Y.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +779 -294
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +6 -2
  10. package/src/captions/hooks/useCaptionSync.ts +2 -1
  11. package/src/components/editor/DomEditOverlay.test.ts +89 -2
  12. package/src/components/editor/DomEditOverlay.tsx +40 -23
  13. package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
  14. package/src/components/editor/domEditOverlayGeometry.ts +59 -2
  15. package/src/components/editor/domEditOverlayGestures.ts +42 -0
  16. package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
  17. package/src/components/editor/groupDragMove.ts +110 -0
  18. package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
  19. package/src/components/editor/manualEditsDom.ts +15 -13
  20. package/src/components/editor/manualOffsetDrag.test.ts +92 -5
  21. package/src/components/editor/manualOffsetDrag.ts +22 -17
  22. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
  23. package/src/components/editor/snapEngageTravel.test.ts +65 -0
  24. package/src/components/editor/snapEngine.ts +21 -1
  25. package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
  26. package/src/components/editor/useDomEditOverlayRects.ts +4 -2
  27. package/src/hooks/domSelectionTimelineMirror.ts +73 -0
  28. package/src/hooks/gestureTransaction.test.ts +7 -12
  29. package/src/hooks/gestureTransaction.ts +7 -20
  30. package/src/hooks/gsapRuntimePatch.test.ts +43 -0
  31. package/src/hooks/gsapRuntimePatch.ts +11 -1
  32. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  33. package/src/hooks/gsapScriptCommitTypes.ts +20 -0
  34. package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
  35. package/src/hooks/keyframeCacheAstLoad.ts +29 -1
  36. package/src/hooks/timelineTimingSync.ts +4 -1
  37. package/src/hooks/useDomEditCommits.ts +2 -1
  38. package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
  39. package/src/hooks/useDomEditPreviewSync.ts +23 -0
  40. package/src/hooks/useDomEditSession.test.tsx +43 -62
  41. package/src/hooks/useDomEditSession.ts +10 -2
  42. package/src/hooks/useDomEditWiring.ts +6 -0
  43. package/src/hooks/useDomSelection.test.ts +133 -3
  44. package/src/hooks/useDomSelection.ts +67 -40
  45. package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
  46. package/src/hooks/useElementLifecycleOps.ts +2 -1
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
  48. package/src/hooks/useFileManager.ts +2 -8
  49. package/src/hooks/useGroupCommits.ts +2 -1
  50. package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
  52. package/src/hooks/useGsapAwareEditing.ts +88 -15
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
  54. package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
  55. package/src/hooks/useGsapScriptCommits.ts +52 -15
  56. package/src/hooks/useStudioUrlState.ts +130 -7
  57. package/src/hooks/useTimelineEditing.ts +2 -1
  58. package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
  59. package/src/player/hooks/useTimelinePlayer.ts +2 -0
  60. package/src/utils/dragDebug.ts +98 -0
  61. package/src/utils/razorSplitTransaction.test.ts +15 -3
  62. package/src/utils/razorSplitTransaction.ts +2 -0
  63. package/src/utils/reloadDebug.ts +10 -0
  64. package/src/utils/resizeDebug.ts +19 -29
  65. package/src/utils/selectDebug.ts +9 -0
  66. package/src/utils/studioDebug.test.ts +32 -0
  67. package/src/utils/studioDebug.ts +30 -0
  68. package/src/utils/studioFileVersion.ts +16 -1
  69. package/src/utils/studioUrlState.test.ts +163 -1
  70. package/src/utils/studioUrlState.ts +60 -3
  71. package/src/utils/timelineCompositionInsert.ts +2 -1
@@ -33,6 +33,7 @@ import {
33
33
  readErrorResponseBody,
34
34
  } from "./useDomEditCommitsHelpers";
35
35
  import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
36
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
36
37
  interface RecordEditInput {
37
38
  label: string;
38
39
  kind: EditHistoryKind;
@@ -201,7 +202,7 @@ export function useDomEditCommits({
201
202
  `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
202
203
  {
203
204
  method: "POST",
204
- headers: { "Content-Type": "application/json" },
205
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
205
206
  body: JSON.stringify(patchBody),
206
207
  },
207
208
  );
@@ -1,6 +1,7 @@
1
1
  import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
2
2
  import type { DomEditPatchBatch } from "./domEditCommitTypes";
3
3
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
4
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
4
5
 
5
6
  export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
6
7
  return fields.map((field) => field.path).join(", ");
@@ -99,7 +100,7 @@ export async function patchElementBatches(projectId: string, batches: DomEditPat
99
100
  `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
100
101
  {
101
102
  method: "POST",
102
- headers: { "Content-Type": "application/json" },
103
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
103
104
  body,
104
105
  },
105
106
  );
@@ -8,13 +8,17 @@ import { findElementForSelection, type DomEditSelection } from "../components/ed
8
8
  import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
9
9
  import type { SidebarTab } from "../components/sidebar/LeftSidebar";
10
10
  import type { PatchTarget } from "../utils/sourcePatcher";
11
+ import { logSelect } from "../utils/selectDebug";
11
12
 
12
13
  interface UseDomEditPreviewSyncParams {
13
14
  previewIframe: HTMLIFrameElement | null;
14
15
  activeCompPath: string | null;
15
16
  captionEditMode: boolean;
16
17
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
18
+ domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
17
19
  domEditSelection: DomEditSelection | null;
20
+ /** Re-resolves a whole multi-selection against the current preview document. */
21
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
18
22
  applyDomSelection: (
19
23
  selection: DomEditSelection | null,
20
24
  options?: { revealPanel?: boolean; preserveGroup?: boolean },
@@ -35,8 +39,10 @@ export function useDomEditPreviewSync({
35
39
  activeCompPath,
36
40
  captionEditMode,
37
41
  domEditSelectionRef,
42
+ domEditGroupSelectionsRef,
38
43
  domEditSelection,
39
44
  applyDomSelection,
45
+ refreshDomEditGroupSelectionsFromPreview,
40
46
  buildDomSelectionFromTarget,
41
47
  refreshPreviewDocumentVersion,
42
48
  syncPreviewHistoryHotkey,
@@ -72,6 +78,21 @@ export function useDomEditPreviewSync({
72
78
  // Clear so overlay geometry isn't computed on a stale, detached node.
73
79
  // (Drag-release-in-gray-zone is handled separately by
74
80
  // suppressNextBoxClickRef; the dragged element still resolves here.)
81
+ //
82
+ // One lost member is not the whole selection though. A multi-select that
83
+ // loses its primary here used to be wiped entirely, so moving a group and
84
+ // having any one of its elements fail to re-resolve deselected all of
85
+ // them. Re-resolve the group instead and keep whoever survived; it only
86
+ // clears when nobody did.
87
+ const group = domEditGroupSelectionsRef.current;
88
+ logSelect("preview-sync-lost", {
89
+ target: currentSelection.selector ?? currentSelection.id ?? null,
90
+ group: group.length,
91
+ });
92
+ if (group.length > 1) {
93
+ await refreshDomEditGroupSelectionsFromPreview(group);
94
+ return;
95
+ }
75
96
  applyDomSelection(null, { revealPanel: false });
76
97
  return;
77
98
  }
@@ -103,8 +124,10 @@ export function useDomEditPreviewSync({
103
124
  applyDomSelection,
104
125
  buildDomSelectionFromTarget,
105
126
  captionEditMode,
127
+ domEditGroupSelectionsRef,
106
128
  domEditSelectionRef,
107
129
  previewIframe,
130
+ refreshDomEditGroupSelectionsFromPreview,
108
131
  refreshPreviewDocumentVersion,
109
132
  syncPreviewHistoryHotkey,
110
133
  applyStudioManualEditsToPreviewRef,
@@ -60,6 +60,46 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
60
60
  const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
61
61
  const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
62
62
 
63
+ function createSessionParams(
64
+ overrides: Partial<UseDomEditSessionParams> = {},
65
+ ): UseDomEditSessionParams {
66
+ return {
67
+ projectId: "proj-1",
68
+ activeCompPath: "index.html",
69
+ compIdToSrc: new Map(),
70
+ captionEditMode: false,
71
+ compositionLoading: false,
72
+ previewIframeRef: { current: null },
73
+ timelineElements: [],
74
+ getTimelineSelectionSet: () => new Set(),
75
+ setSelectedTimelineElementId: vi.fn(),
76
+ setTimelineSelectionSet: vi.fn(),
77
+ setRightCollapsed: vi.fn(),
78
+ setRightPanelTab: vi.fn(),
79
+ showToast: vi.fn(),
80
+ refreshPreviewDocumentVersion: vi.fn(),
81
+ queueDomEditSave: async <T,>(save: () => Promise<T>) => save(),
82
+ readProjectFile: async () => "",
83
+ writeProjectFile: async () => {},
84
+ updateEditingFileContent: vi.fn(),
85
+ domEditSaveTimestampRef: { current: 0 },
86
+ editHistory: { recordEdit: async () => {} },
87
+ fileTree: [],
88
+ importedFontAssetsRef: { current: [] },
89
+ projectDir: null,
90
+ projectIdRef: { current: "proj-1" },
91
+ previewIframe: null,
92
+ refreshKey: 0,
93
+ previewDocumentVersion: 0,
94
+ rightPanelTab: "design",
95
+ applyStudioManualEditsToPreviewRef: { current: async () => {} },
96
+ syncPreviewHistoryHotkey: vi.fn(),
97
+ reloadPreview: vi.fn(),
98
+ setRefreshKey: vi.fn(),
99
+ ...overrides,
100
+ };
101
+ }
102
+
63
103
  vi.mock("../utils/sdkResolverShadow", () => ({
64
104
  runResolverShadow: vi.fn(),
65
105
  recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
@@ -220,43 +260,16 @@ describe("onReorderShadow source filter", () => {
220
260
  const sdkSession = {} as unknown as Composition;
221
261
 
222
262
  function Probe() {
223
- const params: UseDomEditSessionParams = {
224
- projectId: "proj-1",
225
- activeCompPath: "index.html",
226
- isMasterView: false,
227
- compIdToSrc: new Map(),
228
- captionEditMode: false,
229
- compositionLoading: false,
230
- previewIframeRef: { current: null },
231
- timelineElements: [],
232
- setSelectedTimelineElementId: vi.fn(),
233
- setRightCollapsed: vi.fn(),
234
- setRightPanelTab: vi.fn(),
235
- showToast: vi.fn(),
236
- refreshPreviewDocumentVersion: vi.fn(),
263
+ const params = createSessionParams({
237
264
  queueDomEditSave: vi.fn(async <T,>(save: () => Promise<T>) => save()) as <T>(
238
265
  save: () => Promise<T>,
239
266
  ) => Promise<T>,
240
267
  readProjectFile,
241
268
  writeProjectFile: vi.fn(async () => {}),
242
- updateEditingFileContent: vi.fn(),
243
- domEditSaveTimestampRef: { current: 0 },
244
269
  editHistory: { recordEdit: vi.fn(async () => {}) },
245
- fileTree: [],
246
- importedFontAssetsRef: { current: [] },
247
- projectDir: null,
248
- projectIdRef: { current: "proj-1" },
249
- previewIframe: null,
250
- refreshKey: 0,
251
- previewDocumentVersion: 0,
252
- rightPanelTab: "design",
253
- applyStudioManualEditsToPreviewRef: { current: async () => {} },
254
- syncPreviewHistoryHotkey: vi.fn(),
255
- reloadPreview: vi.fn(),
256
- setRefreshKey: vi.fn(),
257
270
  sdkSession,
258
271
  forceReloadSdkSession: vi.fn(),
259
- };
272
+ });
260
273
  useDomEditSession(params);
261
274
  return null;
262
275
  }
@@ -318,39 +331,7 @@ describe("bulk segment ease commits", () => {
318
331
  | undefined;
319
332
 
320
333
  function Probe() {
321
- const params: UseDomEditSessionParams = {
322
- projectId: "proj-1",
323
- activeCompPath: "index.html",
324
- isMasterView: false,
325
- compIdToSrc: new Map(),
326
- captionEditMode: false,
327
- compositionLoading: false,
328
- previewIframeRef: { current: null },
329
- timelineElements: [],
330
- setSelectedTimelineElementId: vi.fn(),
331
- setRightCollapsed: vi.fn(),
332
- setRightPanelTab: vi.fn(),
333
- showToast: vi.fn(),
334
- refreshPreviewDocumentVersion: vi.fn(),
335
- queueDomEditSave: async <T,>(save: () => Promise<T>) => save(),
336
- readProjectFile: async () => "",
337
- writeProjectFile: async () => {},
338
- updateEditingFileContent: vi.fn(),
339
- domEditSaveTimestampRef: { current: 0 },
340
- editHistory: { recordEdit: async () => {} },
341
- fileTree: [],
342
- importedFontAssetsRef: { current: [] },
343
- projectDir: null,
344
- projectIdRef: { current: "proj-1" },
345
- previewIframe: null,
346
- refreshKey: 0,
347
- previewDocumentVersion: 0,
348
- rightPanelTab: "design",
349
- applyStudioManualEditsToPreviewRef: { current: async () => {} },
350
- syncPreviewHistoryHotkey: vi.fn(),
351
- reloadPreview: vi.fn(),
352
- setRefreshKey: vi.fn(),
353
- };
334
+ const params = createSessionParams();
354
335
  updateSegmentEase = useDomEditSession(params).handleUpdateSegmentEase;
355
336
  return null;
356
337
  }
@@ -31,13 +31,14 @@ interface RecordEditInput {
31
31
  export interface UseDomEditSessionParams {
32
32
  projectId: string | null;
33
33
  activeCompPath: string | null;
34
- isMasterView: boolean;
35
34
  compIdToSrc: Map<string, string>;
36
35
  captionEditMode: boolean;
37
36
  compositionLoading: boolean;
38
37
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
39
38
  timelineElements: TimelineElement[];
39
+ getTimelineSelectionSet: () => ReadonlySet<string>;
40
40
  setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
41
+ setTimelineSelectionSet: (ids: Set<string>) => void;
41
42
  setRightCollapsed: (collapsed: boolean) => void;
42
43
  setRightPanelTab: (tab: RightPanelTab) => void;
43
44
  showToast: (message: string, tone?: "error" | "info") => void;
@@ -73,13 +74,14 @@ export interface UseDomEditSessionParams {
73
74
  export function useDomEditSession({
74
75
  projectId,
75
76
  activeCompPath,
76
- isMasterView,
77
77
  compIdToSrc,
78
78
  captionEditMode,
79
79
  compositionLoading,
80
80
  previewIframeRef,
81
81
  timelineElements,
82
+ getTimelineSelectionSet,
82
83
  setSelectedTimelineElementId,
84
+ setTimelineSelectionSet,
83
85
  setRightCollapsed,
84
86
  setRightPanelTab,
85
87
  showToast,
@@ -109,6 +111,7 @@ export function useDomEditSession({
109
111
  publishSdkSession,
110
112
  forceReloadSdkSession,
111
113
  }: UseDomEditSessionParams) {
114
+ const isMasterView = !activeCompPath || activeCompPath === "index.html";
112
115
  void _setRefreshKey;
113
116
  const {
114
117
  domEditSelection,
@@ -127,6 +130,7 @@ export function useDomEditSession({
127
130
  buildDomSelectionForTimelineElement,
128
131
  handleTimelineElementSelect,
129
132
  refreshDomEditSelectionFromPreview,
133
+ refreshDomEditGroupSelectionsFromPreview,
130
134
  applyMarqueeSelection,
131
135
  } = useDomSelection({
132
136
  projectId,
@@ -136,7 +140,9 @@ export function useDomEditSession({
136
140
  captionEditMode,
137
141
  previewIframeRef,
138
142
  timelineElements,
143
+ getTimelineSelectionSet,
139
144
  setSelectedTimelineElementId,
145
+ setTimelineSelectionSet,
140
146
  setRightCollapsed,
141
147
  setRightPanelTab,
142
148
  previewIframe,
@@ -382,6 +388,8 @@ export function useDomEditSession({
382
388
  activeCompPath,
383
389
  domEditSelection,
384
390
  domEditSelectionRef,
391
+ domEditGroupSelectionsRef,
392
+ refreshDomEditGroupSelectionsFromPreview,
385
393
  previewIframeRef,
386
394
  previewIframe,
387
395
  captionEditMode,
@@ -23,6 +23,8 @@ export interface UseDomEditWiringParams {
23
23
  activeCompPath: string | null;
24
24
  domEditSelection: DomEditSelection | null;
25
25
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
26
+ domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
27
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
26
28
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
27
29
  previewIframe: HTMLIFrameElement | null;
28
30
  captionEditMode: boolean;
@@ -115,6 +117,8 @@ export function useDomEditWiring({
115
117
  activeCompPath,
116
118
  domEditSelection,
117
119
  domEditSelectionRef,
120
+ domEditGroupSelectionsRef,
121
+ refreshDomEditGroupSelectionsFromPreview,
118
122
  previewIframeRef,
119
123
  previewIframe,
120
124
  captionEditMode,
@@ -254,8 +258,10 @@ export function useDomEditWiring({
254
258
  activeCompPath,
255
259
  captionEditMode,
256
260
  domEditSelectionRef,
261
+ domEditGroupSelectionsRef,
257
262
  domEditSelection,
258
263
  applyDomSelection,
264
+ refreshDomEditGroupSelectionsFromPreview,
259
265
  buildDomSelectionFromTarget,
260
266
  refreshPreviewDocumentVersion,
261
267
  syncPreviewHistoryHotkey,
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
7
7
  import { useDomSelection } from "./useDomSelection";
8
+ import type { TimelineElement } from "../player";
8
9
 
9
10
  installReactActEnvironment();
10
11
 
@@ -14,11 +15,24 @@ interface HarnessProps {
14
15
  refreshKey: number;
15
16
  }
16
17
 
17
- function renderHarness(initialProps: HarnessProps): {
18
+ interface TimelineSpies {
19
+ setSelectedTimelineElementId: ReturnType<typeof vi.fn>;
20
+ setTimelineSelectionSet: ReturnType<typeof vi.fn>;
21
+ }
22
+
23
+ function renderHarness(
24
+ initialProps: HarnessProps,
25
+ options: { timelineElements?: TimelineElement[] } = {},
26
+ ): {
18
27
  current: () => ReturnType<typeof useDomSelection>;
19
28
  rerender: (props: HarnessProps) => void;
20
29
  cleanup: () => void;
30
+ timeline: TimelineSpies;
21
31
  } {
32
+ const timeline: TimelineSpies = {
33
+ setSelectedTimelineElementId: vi.fn(),
34
+ setTimelineSelectionSet: vi.fn(),
35
+ };
22
36
  const host = document.createElement("div");
23
37
  document.body.append(host);
24
38
  const root = createRoot(host);
@@ -32,8 +46,10 @@ function renderHarness(initialProps: HarnessProps): {
32
46
  compIdToSrc: new Map(),
33
47
  captionEditMode: false,
34
48
  previewIframeRef: { current: null },
35
- timelineElements: [],
36
- setSelectedTimelineElementId: vi.fn(),
49
+ timelineElements: options.timelineElements ?? [],
50
+ getTimelineSelectionSet: () => new Set(),
51
+ setSelectedTimelineElementId: timeline.setSelectedTimelineElementId,
52
+ setTimelineSelectionSet: timeline.setTimelineSelectionSet,
37
53
  setRightCollapsed: vi.fn(),
38
54
  setRightPanelTab: vi.fn(),
39
55
  previewIframe: null,
@@ -61,6 +77,7 @@ function renderHarness(initialProps: HarnessProps): {
61
77
  act(() => root.unmount());
62
78
  host.remove();
63
79
  },
80
+ timeline,
64
81
  };
65
82
  }
66
83
 
@@ -77,6 +94,119 @@ function setupSelectedHarness() {
77
94
  return { selection, harness };
78
95
  }
79
96
 
97
+ function timelineElement(domId: string): TimelineElement {
98
+ return {
99
+ id: domId,
100
+ key: domId,
101
+ domId,
102
+ tag: "div",
103
+ start: 0,
104
+ duration: 1,
105
+ track: 0,
106
+ sourceFile: "index.html",
107
+ } as TimelineElement;
108
+ }
109
+
110
+ /**
111
+ * A marquee builds the group correctly and then used to lose it: it announced only
112
+ * the primary to the timeline, the timeline is the source of truth for what is
113
+ * selected, and the sync back to the canvas replaced the group with that one
114
+ * element a moment after the drop. The whole set has to be announced, with the
115
+ * primary as its anchor rather than as a new single selection.
116
+ */
117
+ describe("useDomSelection marquee", () => {
118
+ it("announces every marquee'd element to the timeline, anchored on the primary", () => {
119
+ const first = document.createElement("div");
120
+ first.id = "card";
121
+ const second = document.createElement("div");
122
+ second.id = "chip";
123
+ document.body.append(first, second);
124
+ const harness = renderHarness(
125
+ { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
126
+ { timelineElements: [timelineElement("card"), timelineElement("chip")] },
127
+ );
128
+
129
+ act(() =>
130
+ harness
131
+ .current()
132
+ .applyMarqueeSelection(
133
+ [makeSelection("Card", first), makeSelection("Chip", second)],
134
+ false,
135
+ ),
136
+ );
137
+
138
+ expect(harness.current().domEditGroupSelections).toHaveLength(2);
139
+ expect(harness.timeline.setTimelineSelectionSet).toHaveBeenCalledWith(
140
+ new Set(["card", "chip"]),
141
+ );
142
+ expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenCalledWith("card", {
143
+ preserveSet: true,
144
+ });
145
+ harness.cleanup();
146
+ });
147
+
148
+ it("uses a surviving group member as the timeline anchor when the canvas primary has no row", () => {
149
+ const canvasOnly = document.createElement("div");
150
+ canvasOnly.id = "canvas-only";
151
+ const card = document.createElement("div");
152
+ card.id = "card";
153
+ document.body.append(canvasOnly, card);
154
+ const harness = renderHarness(
155
+ { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
156
+ { timelineElements: [timelineElement("card")] },
157
+ );
158
+
159
+ act(() =>
160
+ harness
161
+ .current()
162
+ .applyMarqueeSelection(
163
+ [makeSelection("Canvas only", canvasOnly), makeSelection("Card", card)],
164
+ false,
165
+ ),
166
+ );
167
+
168
+ expect(harness.timeline.setTimelineSelectionSet).toHaveBeenCalledWith(new Set(["card"]));
169
+ expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenCalledWith("card", {
170
+ preserveSet: true,
171
+ });
172
+ harness.cleanup();
173
+ });
174
+ });
175
+
176
+ /**
177
+ * Adding a second element announced only that element, with preserveSet — and
178
+ * preserving a set that does not contain the id empties it. An empty timeline
179
+ * selection syncs back as "nothing is selected", so growing a group could wipe
180
+ * it instead, and so could re-resolving one after a move.
181
+ */
182
+ describe("useDomSelection additive", () => {
183
+ it("announces both members when a second element joins the selection", () => {
184
+ const first = document.createElement("div");
185
+ first.id = "card";
186
+ const second = document.createElement("div");
187
+ second.id = "chip";
188
+ document.body.append(first, second);
189
+ const harness = renderHarness(
190
+ { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
191
+ { timelineElements: [timelineElement("card"), timelineElement("chip")] },
192
+ );
193
+
194
+ act(() => harness.current().applyDomSelection(makeSelection("Card", first)));
195
+ act(() =>
196
+ harness.current().applyDomSelection(makeSelection("Chip", second), { additive: true }),
197
+ );
198
+
199
+ expect(harness.current().domEditGroupSelections).toHaveLength(2);
200
+ expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
201
+ new Set(["card", "chip"]),
202
+ );
203
+ expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenLastCalledWith("chip", {
204
+ preserveSet: true,
205
+ });
206
+ harness.cleanup();
207
+ });
208
+ });
209
+
80
210
  describe("useDomSelection", () => {
81
211
  it("clears a committed selection when the active composition path changes", () => {
82
212
  const { selection, harness } = setupSelectedHarness();