@hyperframes/studio 0.8.96 → 0.8.98

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 (89) hide show
  1. package/dist/assets/{hyperframes-player-ClfxdXeC.js → hyperframes-player-C6muFtfk.js} +1 -1
  2. package/dist/assets/{index-D8ztHPd3.js → index-B6vU39NM.js} +1 -1
  3. package/dist/assets/{index-BNQLP5_v.js → index-BxnoLgxG.js} +1 -1
  4. package/dist/assets/{index-CSHEHeOo.js → index-itc1XziP.js} +198 -192
  5. package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
  6. package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
  7. package/dist/chunk-6GAO75IU.js.map +1 -0
  8. package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
  9. package/dist/chunk-MTAVNAGW.js.map +1 -0
  10. package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
  11. package/dist/chunk-U3EE4ACX.js.map +1 -0
  12. package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
  13. package/dist/index.d.ts +23 -6
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +2153 -1969
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/dist/ui.d.ts +3 -1
  20. package/dist/ui.js +1 -1
  21. package/package.json +9 -8
  22. package/src/components/EditorShell.tsx +7 -0
  23. package/src/components/TimelineToolPicker.tsx +19 -15
  24. package/src/components/TimelineToolbar.test.tsx +62 -0
  25. package/src/components/TimelineToolbar.tsx +3 -1
  26. package/src/components/editor/DomEditOverlay.tsx +0 -27
  27. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
  28. package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
  29. package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
  30. package/src/components/editor/domEditing.test.ts +9 -1
  31. package/src/components/editor/domEditingDom.ts +2 -1
  32. package/src/components/editor/fontAssets.test.ts +69 -0
  33. package/src/components/editor/fontAssets.ts +11 -2
  34. package/src/components/editor/propertyPanelFont.test.tsx +48 -5
  35. package/src/components/editor/propertyPanelFont.tsx +2 -1
  36. package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
  37. package/src/components/nle/TimelinePane.tsx +4 -0
  38. package/src/components/ui/Menu.test.tsx +21 -0
  39. package/src/components/ui/Popover.tsx +21 -1
  40. package/src/hooks/domSelectionTimelineMirror.ts +5 -4
  41. package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
  42. package/src/hooks/useAppHotkeys.test.ts +7 -5
  43. package/src/hooks/useClipboard.ts +25 -7
  44. package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
  45. package/src/hooks/useDomSelection.test.ts +56 -32
  46. package/src/hooks/useDomSelection.ts +4 -3
  47. package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
  48. package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
  49. package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
  50. package/src/hooks/useGsapTweenCache.ts +0 -24
  51. package/src/index.ts +4 -1
  52. package/src/player/components/BeatStrip.tsx +6 -3
  53. package/src/player/components/ClipContextMenu.test.tsx +182 -0
  54. package/src/player/components/ClipContextMenu.tsx +52 -10
  55. package/src/player/components/Timeline.readOnly.test.tsx +670 -0
  56. package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
  57. package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
  58. package/src/player/components/TimelineClipDiamonds.tsx +7 -2
  59. package/src/player/components/TimelineFxButton.tsx +3 -0
  60. package/src/player/components/TimelineLanes.tsx +3 -2
  61. package/src/player/components/TimelineOverlays.test.ts +30 -7
  62. package/src/player/components/TimelineOverlays.tsx +7 -1
  63. package/src/player/components/TimelineProvider.tsx +24 -7
  64. package/src/player/components/TimelineTypes.ts +12 -0
  65. package/src/player/components/menuKeyboardNav.ts +12 -5
  66. package/src/player/components/studioShortcuts.ts +1 -1
  67. package/src/player/components/timelineClipGestureHandlers.ts +3 -1
  68. package/src/player/components/timelineDragDrop.ts +11 -1
  69. package/src/player/components/timelineEditCapabilities.ts +1 -0
  70. package/src/player/components/timelineMountTestEnv.ts +24 -0
  71. package/src/player/components/timelineReadOnly.ts +64 -0
  72. package/src/player/components/useAutomationLaneGestures.ts +13 -2
  73. package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
  74. package/src/player/components/useTimelineProviderState.tsx +44 -14
  75. package/src/player/hooks/useShadowPreviewReload.ts +2 -2
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  78. package/src/player/lib/timelineDOM.test.ts +25 -0
  79. package/src/player/lib/timelineDOM.ts +4 -2
  80. package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
  81. package/src/player/store/playerStore.ts +4 -4
  82. package/src/utils/clipboardLook.test.ts +130 -0
  83. package/src/utils/clipboardLook.ts +82 -0
  84. package/src/utils/clipboardPayload.ts +8 -2
  85. package/src/utils/studioFontHelpers.ts +6 -3
  86. package/dist/chunk-PQFEGZWM.js.map +0 -1
  87. package/dist/chunk-QPSOTK6M.js.map +0 -1
  88. package/dist/chunk-QZ6HNNFG.js.map +0 -1
  89. /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
@@ -2,6 +2,8 @@ export interface ImportedFontAsset {
2
2
  family: string;
3
3
  path: string;
4
4
  url: string;
5
+ weight?: string;
6
+ style?: "normal" | "italic";
5
7
  }
6
8
 
7
9
  const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i;
@@ -9,7 +11,7 @@ const FONT_STYLE_SUFFIX_RE =
9
11
  /\s+(thin|extralight|extra light|light|regular|roman|medium|semibold|semi bold|bold|extrabold|extra bold|black|italic|oblique|variable)$/i;
10
12
 
11
13
  function cssString(value: string): string {
12
- return JSON.stringify(value);
14
+ return JSON.stringify(value).replace(/</g, "\\3c ");
13
15
  }
14
16
 
15
17
  export function fontFamilyFromAssetPath(path: string): string {
@@ -28,5 +30,12 @@ export function fontFamilyFromAssetPath(path: string): string {
28
30
  }
29
31
 
30
32
  export function importedFontFaceCss(asset: ImportedFontAsset, url: string = asset.url): string {
31
- return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)}); font-display: swap; }`;
33
+ const weight =
34
+ asset.weight && /^(?:[1-9]\d{0,2}|1000)(?: (?:[1-9]\d{0,2}|1000))?$/.test(asset.weight)
35
+ ? ` font-weight: ${asset.weight};`
36
+ : "";
37
+ const style = asset.style
38
+ ? ` font-style: ${asset.style === "italic" ? "italic" : "normal"};`
39
+ : "";
40
+ return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)});${weight}${style} font-display: swap; }`;
32
41
  }
@@ -2,13 +2,32 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { FontFamilyField } from "./propertyPanelFont";
7
+ import { sortFontOptions } from "./propertyPanelHelpers";
8
+
9
+ vi.mock("./propertyPanelHelpers", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
11
+ return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
12
+ });
7
13
 
8
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
15
 
16
+ // The font APIs are stubbed and every family is off the Google lists, so no test reaches the network.
17
+ beforeEach(() => {
18
+ vi.stubGlobal(
19
+ "fetch",
20
+ vi.fn(async (url: string) => ({
21
+ ok: true,
22
+ json: async () => ({ fonts: url.includes("google") ? ["Roboto Slab"] : ["Arial"] }),
23
+ })),
24
+ );
25
+ });
26
+
10
27
  afterEach(() => {
11
28
  document.body.innerHTML = "";
29
+ document.head.innerHTML = "";
30
+ vi.unstubAllGlobals();
12
31
  });
13
32
 
14
33
  describe("FontFamilyField flat trigger", () => {
@@ -17,14 +36,38 @@ describe("FontFamilyField flat trigger", () => {
17
36
  document.body.append(host);
18
37
  const root = createRoot(host);
19
38
  act(() => {
20
- root.render(
21
- <FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
22
- );
39
+ root.render(<FontFamilyField flat value="Georgia" importedFonts={[]} onCommit={vi.fn()} />);
23
40
  });
24
41
  const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
25
42
  expect(trigger).not.toBeNull();
26
43
  expect(trigger?.className).not.toContain("border-neutral-800");
27
- expect(host.textContent).toContain("JetBrains Mono");
44
+ expect(host.textContent).toContain("Georgia");
28
45
  act(() => root.unmount());
29
46
  });
30
47
  });
48
+
49
+ describe("FontFamilyField font list", () => {
50
+ it("builds the list only while the dropdown is open", async () => {
51
+ vi.mocked(sortFontOptions).mockClear();
52
+ const host = document.createElement("div");
53
+ document.body.append(host);
54
+ const root = createRoot(host);
55
+ const render = (value: string) =>
56
+ root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
57
+ try {
58
+ await act(async () => render("Arial"));
59
+ // The font lists arrive and the value changes while the dropdown is closed, as during a drag.
60
+ await act(async () => render("Georgia"));
61
+ expect(sortFontOptions).not.toHaveBeenCalled();
62
+
63
+ const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
64
+ await act(async () => trigger?.click());
65
+ expect(sortFontOptions).toHaveBeenCalledTimes(1);
66
+ expect(host.textContent).toContain("Roboto Slab");
67
+ expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
68
+ } finally {
69
+ act(() => root.unmount());
70
+ host.remove();
71
+ }
72
+ });
73
+ });
@@ -276,6 +276,7 @@ export function FontFamilyField({
276
276
  );
277
277
 
278
278
  const options = useMemo(() => {
279
+ if (!open) return [];
279
280
  const documentFonts = collectDocumentFontFamilies();
280
281
  const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
281
282
  const taggedLocal = localFonts.map(
@@ -294,7 +295,7 @@ export function FontFamilyField({
294
295
  ...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
295
296
  ]),
296
297
  );
297
- }, [currentFamily, googleFonts, localFonts, projectFontAssets]);
298
+ }, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
298
299
 
299
300
  const filteredOptions = useMemo(() => {
300
301
  const matches = options.filter((o) => fontMatchesQuery(o.family, query));
@@ -193,7 +193,6 @@ describe("a shift press on a selected box starts the drag", () => {
193
193
  selectionKey="box"
194
194
  groupSelectionCount={0}
195
195
  gestures={spies as never}
196
- onBoxMouseDown={vi.fn()}
197
196
  onBoxClick={vi.fn()}
198
197
  />
199
198
  ));
@@ -207,7 +206,6 @@ describe("a shift press on a selected box starts the drag", () => {
207
206
  allowBodyDrag
208
207
  groupCanMove={groupCanMove}
209
208
  gestures={spies as never}
210
- onBoxMouseDown={vi.fn()}
211
209
  onBoxClick={vi.fn()}
212
210
  />
213
211
  ));
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
3
  import type { TimelineElement, TimelineTimeRange } from "../../player";
4
+ import type { TimelineProps } from "../../player/components/TimelineTypes";
4
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
6
  import { AudioMeterStrip } from "./AudioMeterStrip";
6
7
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
@@ -45,6 +46,7 @@ export interface TimelinePaneProps {
45
46
  onPasteClip?: () => Promise<void>;
46
47
  onDuplicateClip?: () => Promise<boolean>;
47
48
  canPasteClip?: () => boolean;
49
+ clipMenuItems?: TimelineProps["clipMenuItems"];
48
50
  }
49
51
 
50
52
  export function TimelinePane({
@@ -64,6 +66,7 @@ export function TimelinePane({
64
66
  onPasteClip,
65
67
  onDuplicateClip,
66
68
  canPasteClip,
69
+ clipMenuItems,
67
70
  }: TimelinePaneProps) {
68
71
  const {
69
72
  seek,
@@ -119,6 +122,7 @@ export function TimelinePane({
119
122
  onPasteClip={onPasteClip}
120
123
  onDuplicateClip={onDuplicateClip}
121
124
  canPasteClip={canPasteClip}
125
+ clipMenuItems={clipMenuItems}
122
126
  />
123
127
  </div>
124
128
  <AudioMeterStrip />
@@ -298,6 +298,27 @@ describe("Popover", () => {
298
298
  expect(document.activeElement).toBe(input);
299
299
  expect(document.querySelector('[role="menu"]')).toBeNull();
300
300
  });
301
+
302
+ it("points at its trigger only when asked", async () => {
303
+ const renderOpen = (arrow: boolean) => {
304
+ render(
305
+ <Popover trigger={<button data-testid="trigger">Speed</button>} arrow={arrow} defaultOpen>
306
+ <span>1x</span>
307
+ </Popover>,
308
+ );
309
+ };
310
+ renderOpen(false);
311
+ await settle();
312
+ expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
313
+ act(() => mounted?.root.unmount());
314
+ mounted?.host.remove();
315
+ mounted = null;
316
+
317
+ renderOpen(true);
318
+ await settle();
319
+ const arrow = one("[role=dialog] svg", "arrow").parentElement!;
320
+ expect(arrow.getAttribute("data-side")).toBe("bottom");
321
+ });
301
322
  });
302
323
 
303
324
  describe("hotkey classification", () => {
@@ -14,6 +14,15 @@ type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["cont
14
14
  const SIDE_OFFSET = 6;
15
15
  const VIEWPORT_MARGIN = 8;
16
16
 
17
+ /** The arrow's 14x8 box overlaps the popup's border by 1px, so its fill hides the border where it meets the popup. */
18
+ const ARROW_PX = 7;
19
+ const ARROW_CORNER_CLEARANCE = 10;
20
+ const arrowPlace = cn(
21
+ "data-[side=bottom]:-top-[7px] data-[side=top]:-bottom-[7px] data-[side=top]:rotate-180",
22
+ "data-[side=left]:-right-[10px] data-[side=left]:rotate-90",
23
+ "data-[side=right]:-left-[10px] data-[side=right]:-rotate-90",
24
+ );
25
+
17
26
  interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.Root>, "children"> {
18
27
  /** A single element. It becomes the trigger; no wrapper is added around it. */
19
28
  trigger: ReactElement;
@@ -22,6 +31,8 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.
22
31
  side?: "top" | "bottom" | "left" | "right";
23
32
  align?: "start" | "center" | "end";
24
33
  sideOffset?: number;
34
+ /** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
35
+ arrow?: boolean;
25
36
  /** Portal target. Pass the shadow root when the trigger lives in one. */
26
37
  container?: PortalContainer;
27
38
  /** Names the popup for assistive tech. */
@@ -40,7 +51,8 @@ export function Popover({
40
51
  children,
41
52
  side = "bottom",
42
53
  align = "center",
43
- sideOffset = SIDE_OFFSET,
54
+ arrow = false,
55
+ sideOffset = arrow ? SIDE_OFFSET + ARROW_PX : SIDE_OFFSET,
44
56
  container,
45
57
  className,
46
58
  initialFocus,
@@ -57,6 +69,7 @@ export function Popover({
57
69
  align={align}
58
70
  sideOffset={sideOffset}
59
71
  collisionPadding={VIEWPORT_MARGIN}
72
+ arrowPadding={ARROW_CORNER_CLEARANCE}
60
73
  className="z-200"
61
74
  >
62
75
  <BasePopover.Popup
@@ -65,6 +78,13 @@ export function Popover({
65
78
  data-preview-state={previewState}
66
79
  className={cn(popupSurface, "p-3 text-step-11 text-text-1 shadow-popover", className)}
67
80
  >
81
+ {arrow && (
82
+ <BasePopover.Arrow className={arrowPlace}>
83
+ <svg width="14" height="8" viewBox="0 0 14 8" aria-hidden="true" className="block">
84
+ <path d="M0 8 L7 1 L14 8" className="fill-surface stroke-border-input" />
85
+ </svg>
86
+ </BasePopover.Arrow>
87
+ )}
68
88
  {children}
69
89
  </BasePopover.Popup>
70
90
  </BasePopover.Positioner>
@@ -28,6 +28,7 @@ export function announceTimelineSelection(
28
28
  deps: TimelineMirrorDeps,
29
29
  group: DomEditSelection[],
30
30
  primary: DomEditSelection | null,
31
+ replaceSet = false,
31
32
  ): void {
32
33
  const {
33
34
  timelineElements,
@@ -72,10 +73,10 @@ export function announceTimelineSelection(
72
73
  }
73
74
  return;
74
75
  }
75
- // A late async primary that already belongs to the live set must preserve the
76
- // group. A fresh single click does not belong to it, so publish the singleton
77
- // first; otherwise `preserveSet` clears the set and sync wipes the canvas.
78
- if (group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
76
+ // A late async primary inside the live set keeps the group. A click outside it, or a
77
+ // group the caller states in full (`replaceSet`: shift toggle, marquee, timeline sync), is published
78
+ // first; otherwise `preserveSet` clears or keeps a stale set and sync undoes the canvas.
79
+ if (replaceSet || group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
79
80
  setTimelineSelectionSet(publishedMembers);
80
81
  }
81
82
  setSelectedTimelineElementId(timelineAnchor, { preserveSet: true });
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ import { expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+ import type { ImportedFontAsset } from "../components/editor/fontAssets";
5
+ import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
6
+
7
+ const selection = (id: string): DomEditSelection =>
8
+ ({
9
+ id,
10
+ selector: `#${id}`,
11
+ label: id,
12
+ tagName: "p",
13
+ sourceFile: "index.html",
14
+ compositionPath: "index.html",
15
+ textFields: [],
16
+ inlineStyles: {},
17
+ computedStyles: {},
18
+ dataAttributes: {},
19
+ capabilities: { canSelect: true, canEditStyles: true },
20
+ }) as unknown as DomEditSelection;
21
+
22
+ const poppins = (file: string, weight: string): ImportedFontAsset => ({
23
+ family: "Poppins",
24
+ path: `assets/fonts/Poppins/${file}`,
25
+ url: `/preview/assets/fonts/Poppins/${file}`,
26
+ weight,
27
+ style: "normal",
28
+ });
29
+
30
+ it("each font a host hands over is saved as its own face, with the weight and style it draws", async () => {
31
+ let html = '<html><head></head><body><h1 id="title"></h1><p id="subtitle"></p></body></html>';
32
+ let listed: ImportedFontAsset | null = null;
33
+ const context: DomStyleCommitContext = {
34
+ activeCompPath: "index.html",
35
+ previewIframeRef: { current: null },
36
+ showToast: () => undefined,
37
+ versions: new Map(),
38
+ resolveImportedFontAsset: () => listed,
39
+ persistDomEditOperations: (async (
40
+ _selection: DomEditSelection,
41
+ _operations: unknown,
42
+ options?: { prepareContent?: (html: string, sourceFile: string) => string },
43
+ ) => {
44
+ html = options?.prepareContent?.(html, "index.html") ?? html;
45
+ }) as unknown as DomStyleCommitContext["persistDomEditOperations"],
46
+ };
47
+ listed = poppins("Poppins-SemiBold.ttf", "600");
48
+ await commitDomStyles(context, selection("title"), { "font-family": "Poppins" });
49
+ listed = poppins("Poppins-Regular.ttf", "400");
50
+ await commitDomStyles(context, selection("subtitle"), { "font-family": "Poppins" });
51
+ const faces = html.match(/@font-face[^}]*}/g) ?? [];
52
+ expect(faces).toHaveLength(2);
53
+ expect(faces[0]).toContain('Poppins-SemiBold.ttf"); font-weight: 600; font-style: normal;');
54
+ expect(faces[1]).toContain('Poppins-Regular.ttf"); font-weight: 400; font-style: normal;');
55
+ });
@@ -65,13 +65,14 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
65
65
  { ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
66
66
  { ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
67
67
  { ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
68
+ { ...bgmElement, id: "gone", key: "gone", start: 0, duration: 2, track: 3 },
68
69
  ];
69
70
  beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
70
71
 
71
- it("[ selects every clip starting before the playhead, on every track", () => {
72
+ it("[ selects every clip that started before the playhead, crossing clips included", () => {
72
73
  const event = press("[");
73
74
  dispatchPlainKey(event, "[", callbacks());
74
- expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
75
+ expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual(["early", "gone"]);
75
76
  expect(usePlayerStore.getState().selectedElementId).toBe("early");
76
77
  expect(event.defaultPrevented).toBe(true);
77
78
  });
@@ -84,6 +85,7 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
84
85
  expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
85
86
  "at",
86
87
  "early",
88
+ "gone",
87
89
  "late",
88
90
  ]);
89
91
  } finally {
@@ -98,11 +100,11 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
98
100
  expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
99
101
  });
100
102
 
101
- it("] selects every clip starting at or after the playhead, on every track", () => {
103
+ it("] selects every clip still running at or after the playhead, crossing clips included", () => {
102
104
  dispatchPlainKey(press("]"), "]", callbacks());
103
105
  const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
104
- expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
105
- expect(selectedElementId).toBe("at");
106
+ expect([...selectedElementIds].sort()).toEqual(["at", "early", "late"]);
107
+ expect(selectedElementId).toBe("early");
106
108
  });
107
109
 
108
110
  it("selects nothing when no clip is on that side", () => {
@@ -11,6 +11,7 @@ import {
11
11
  deduplicateIds,
12
12
  insertAsSibling,
13
13
  } from "../utils/clipboardPayload";
14
+ import { carryLook, renamedIds } from "../utils/clipboardLook";
14
15
  import { collectHtmlIds } from "../utils/studioHelpers";
15
16
  import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
16
17
  import { extendRootDurationInSource } from "../utils/rootDuration";
@@ -165,15 +166,16 @@ export function pasteElementHtml(
165
166
  return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
166
167
  }
167
168
 
168
- /** Shared insertion path for paste and duplicate, anchored at the playhead or
169
- * the selection's end respectively. Returns the final ids so the caller can
170
- * select what it just placed, and the furthest end any clip lands at so the
171
- * caller can grow the root composition's duration to cover it. */
169
+ /** Shared insertion path for paste and duplicate, anchored at the playhead or the selection's end. Returns the
170
+ * final ids so the caller can select what it placed, and the furthest end any clip lands at so it can grow the
171
+ * root duration to cover it. `fromThisFile`: the clips came from `content`, so a renamed copy takes its
172
+ * original's look. */
172
173
  export function pasteTimelineClips(
173
174
  content: string,
174
175
  clips: readonly TimelineClipboardClip[],
175
176
  anchorTime: number,
176
177
  liveElements: readonly TimelineElement[],
178
+ fromThisFile = false,
177
179
  ): { content: string; ids: string[]; requiredEnd: number } {
178
180
  const groupMinStart = Math.min(...clips.map((c) => c.start));
179
181
  let existingIds = collectHtmlIds(content);
@@ -209,6 +211,11 @@ export function pasteTimelineClips(
209
211
  .replace(/data-track-index="[^"]*"/, `data-track-index="${newTrack}"`);
210
212
  const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1);
211
213
  result = insertTimelineAssetIntoSource(result, withPatched);
214
+ if (fromThisFile) {
215
+ const authored = Number(rootTag.match(/data-start="([^"]*)"/)?.[1]);
216
+ const authoredStart = Number.isFinite(authored) ? authored : clip.start;
217
+ result = carryLook(result, renamedIds(reminted, deduped), newStart - authoredStart);
218
+ }
212
219
 
213
220
  const id = patchedRootTag.match(ID_ATTR_RE)?.[1];
214
221
  if (id) ids.push(id);
@@ -294,7 +301,12 @@ export function useClipboard({
294
301
  const sourceFile = targets.elements[0]?.sourceFile || activeCompPath || "index.html";
295
302
  return readClips(targets).then((clips) => {
296
303
  showToast(clips.length > 1 ? `Copied ${clips.length} clips` : "Copied clip", "info");
297
- return { kind: "timeline-clip", clips, sourceFile };
304
+ return {
305
+ kind: "timeline-clip",
306
+ clips,
307
+ sourceFile,
308
+ projectId: projectIdRef.current ?? undefined,
309
+ };
298
310
  });
299
311
  }, [activeCompPath, findSelectedClips, readClips, showToast]);
300
312
 
@@ -364,7 +376,13 @@ export function useClipboard({
364
376
  const paste = (originalContent: string) => {
365
377
  if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
366
378
  const { currentTime, elements } = usePlayerStore.getState();
367
- const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
379
+ const pasted = pasteTimelineClips(
380
+ originalContent,
381
+ payload.clips,
382
+ currentTime,
383
+ elements,
384
+ payload.sourceFile === targetPath && payload.projectId === pid,
385
+ );
368
386
  pastedIds = pasted.ids;
369
387
  // A clip pasted past the current composition end would exist in the
370
388
  // file but never appear on the timeline or in playback/export (the
@@ -433,7 +451,7 @@ export function useClipboard({
433
451
  .getState()
434
452
  .elements.filter((el) => pathOf(el) === targetPath)
435
453
  .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
436
- const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
454
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
437
455
  ids = pasted.ids;
438
456
  return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
439
457
  };
@@ -5,10 +5,8 @@ import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBr
5
5
  import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
6
6
  import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
7
7
  import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
8
- import {
9
- gsapSourceFileForSelection,
10
- selectElementAnimationsOrRetry,
11
- } from "./useGsapAnimationFetchFallback";
8
+ import { getAnimationsForElement } from "./gsapElementMatch";
9
+ import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback";
12
10
 
13
11
  interface CommitPreflight {
14
12
  offset: GsapEditOutcome;
@@ -23,13 +21,7 @@ function runCommitPreflights(
23
21
  group: boolean,
24
22
  ): CommitPreflight {
25
23
  const target = { id: selection.id ?? null, selector: selection.selector ?? null };
26
- const matched = selectElementAnimationsOrRetry(
27
- { animations: fileAnimations },
28
- target,
29
- selection.element,
30
- );
31
- // A file the server parsed with no tweens at all is a definitive answer here.
32
- const animations = matched.kind === "resolved" ? matched.animations : [];
24
+ const animations = getAnimationsForElement(fileAnimations, target, selection.element);
33
25
  return {
34
26
  offset: dragEditOutcome(selection, animations, iframe, [], group),
35
27
  size: preflightGsapResizeIntercept(selection, animations, iframe),
@@ -29,9 +29,13 @@ function renderHarness(
29
29
  cleanup: () => void;
30
30
  timeline: TimelineSpies;
31
31
  } {
32
+ // Reads back what was last published, as the timeline store does.
33
+ let publishedSet: ReadonlySet<string> = new Set();
32
34
  const timeline: TimelineSpies = {
33
35
  setSelectedTimelineElementId: vi.fn(),
34
- setTimelineSelectionSet: vi.fn(),
36
+ setTimelineSelectionSet: vi.fn((ids: Set<string>) => {
37
+ publishedSet = ids;
38
+ }),
35
39
  };
36
40
  const host = document.createElement("div");
37
41
  document.body.append(host);
@@ -47,7 +51,7 @@ function renderHarness(
47
51
  captionEditMode: false,
48
52
  previewIframeRef: { current: null },
49
53
  timelineElements: options.timelineElements ?? [],
50
- getTimelineSelectionSet: () => new Set(),
54
+ getTimelineSelectionSet: () => publishedSet,
51
55
  setSelectedTimelineElementId: timeline.setSelectedTimelineElementId,
52
56
  setTimelineSelectionSet: timeline.setTimelineSelectionSet,
53
57
  setRightCollapsed: vi.fn(),
@@ -107,6 +111,17 @@ function timelineElement(domId: string): TimelineElement {
107
111
  } as TimelineElement;
108
112
  }
109
113
 
114
+ function renderCardAndChip() {
115
+ const card = makeSelection("Card", Object.assign(document.createElement("div"), { id: "card" }));
116
+ const chip = makeSelection("Chip", Object.assign(document.createElement("div"), { id: "chip" }));
117
+ document.body.append(card.element, chip.element);
118
+ const harness = renderHarness(
119
+ { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
120
+ { timelineElements: [timelineElement("card"), timelineElement("chip")] },
121
+ );
122
+ return { harness, card, chip };
123
+ }
124
+
110
125
  /**
111
126
  * A marquee builds the group correctly and then used to lose it: it announced only
112
127
  * the primary to the timeline, the timeline is the source of truth for what is
@@ -116,24 +131,9 @@ function timelineElement(domId: string): TimelineElement {
116
131
  */
117
132
  describe("useDomSelection marquee", () => {
118
133
  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
- );
134
+ const { harness, card, chip } = renderCardAndChip();
128
135
 
129
- act(() =>
130
- harness
131
- .current()
132
- .applyMarqueeSelection(
133
- [makeSelection("Card", first), makeSelection("Chip", second)],
134
- false,
135
- ),
136
- );
136
+ act(() => harness.current().applyMarqueeSelection([card, chip], false));
137
137
 
138
138
  expect(harness.current().domEditGroupSelections).toHaveLength(2);
139
139
  expect(harness.timeline.setTimelineSelectionSet).toHaveBeenCalledWith(
@@ -181,20 +181,10 @@ describe("useDomSelection marquee", () => {
181
181
  */
182
182
  describe("useDomSelection additive", () => {
183
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
- );
184
+ const { harness, card, chip } = renderCardAndChip();
193
185
 
194
- act(() => harness.current().applyDomSelection(makeSelection("Card", first)));
195
- act(() =>
196
- harness.current().applyDomSelection(makeSelection("Chip", second), { additive: true }),
197
- );
186
+ act(() => harness.current().applyDomSelection(card));
187
+ act(() => harness.current().applyDomSelection(chip, { additive: true }));
198
188
 
199
189
  expect(harness.current().domEditGroupSelections).toHaveLength(2);
200
190
  expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
@@ -205,6 +195,40 @@ describe("useDomSelection additive", () => {
205
195
  });
206
196
  harness.cleanup();
207
197
  });
198
+
199
+ // The timeline syncs its set back onto the canvas, so a set left at two re-adds the member.
200
+ it.each([
201
+ ["shift+click removes a member", "toggle"],
202
+ ["a marquee catches one member of a live group", "marquee"],
203
+ ] as const)("publishes the smaller set when %s", (_, shrink) => {
204
+ const { harness, card, chip } = renderCardAndChip();
205
+
206
+ act(() => harness.current().applyMarqueeSelection([card, chip], false));
207
+ act(() =>
208
+ shrink === "toggle"
209
+ ? harness.current().applyDomSelection(chip, { additive: true })
210
+ : harness.current().applyMarqueeSelection([card], false),
211
+ );
212
+
213
+ expect(harness.current().domEditGroupSelections).toHaveLength(1);
214
+ expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(new Set(["card"]));
215
+ expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenLastCalledWith("card", {
216
+ preserveSet: true,
217
+ });
218
+ harness.cleanup();
219
+ });
220
+
221
+ it("keeps the live set when a single member is re-announced", () => {
222
+ const { harness, card, chip } = renderCardAndChip();
223
+
224
+ act(() => harness.current().applyMarqueeSelection([card, chip], false));
225
+ act(() => harness.current().applyDomSelection(chip));
226
+
227
+ expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
228
+ new Set(["card", "chip"]),
229
+ );
230
+ harness.cleanup();
231
+ });
208
232
  });
209
233
 
210
234
  describe("useDomSelection", () => {
@@ -83,7 +83,7 @@ export function useDomSelection({
83
83
  // ── Callbacks ──
84
84
 
85
85
  const announceTimelineSelection = useCallback(
86
- (group: DomEditSelection[], primary: DomEditSelection | null) =>
86
+ (group: DomEditSelection[], primary: DomEditSelection | null, replaceSet?: boolean) =>
87
87
  announceSelectionToTimeline(
88
88
  {
89
89
  timelineElements,
@@ -93,6 +93,7 @@ export function useDomSelection({
93
93
  },
94
94
  group,
95
95
  primary,
96
+ replaceSet,
96
97
  ),
97
98
  [
98
99
  getTimelineSelectionSet,
@@ -179,7 +180,7 @@ export function useDomSelection({
179
180
  setRightPanelTab("design");
180
181
  }
181
182
  }
182
- announceTimelineSelection(nextGroup, nextSelection);
183
+ announceTimelineSelection(nextGroup, nextSelection, isAdditiveSelection);
183
184
  return;
184
185
  }
185
186
 
@@ -510,7 +511,7 @@ export function useDomSelection({
510
511
  domEditGroupSelectionsRef.current = nextGroup;
511
512
  setDomEditSelection(nextSelection);
512
513
  setDomEditGroupSelections(nextGroup);
513
- announceTimelineSelection(nextGroup, nextSelection);
514
+ announceTimelineSelection(nextGroup, nextSelection, true);
514
515
  },
515
516
  [applyDomSelection, announceTimelineSelection],
516
517
  );