@hyperframes/studio 0.7.56 → 0.7.57

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 (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Z-order "show your work" flash: after a bring-forward / send-backward, the
3
+ * sibling that was stepped over gets a brief (600ms) highlight so the action
4
+ * is legible even when the visual change is subtle.
5
+ *
6
+ * Drawn in the STUDIO's own overlay layer above the preview iframe — nothing
7
+ * is written into the iframe DOM or the composition, so a concurrent preview
8
+ * reload can never leave a stuck highlight; the timeout merely clears
9
+ * studio-local state. The crossed element is resolved by the context menu
10
+ * (resolveCrossedNeighbor) from the same pre-mutation render order as the
11
+ * z patches; z-index writes don't move layout, so measuring its rect after
12
+ * the commit applied live styles is still accurate.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
15
+ import { toVisibleOverlayRect, type OverlayRect } from "./domEditOverlayGeometry";
16
+
17
+ const Z_ORDER_CROSSED_FLASH_MS = 600;
18
+
19
+ interface UseZOrderCrossedFlashParams {
20
+ overlayRef: RefObject<HTMLDivElement | null>;
21
+ iframeRef: RefObject<HTMLIFrameElement | null>;
22
+ }
23
+
24
+ export function useZOrderCrossedFlash({ overlayRef, iframeRef }: UseZOrderCrossedFlashParams): {
25
+ zOrderFlashRect: OverlayRect | null;
26
+ handleZOrderCrossed: (crossed: HTMLElement) => void;
27
+ } {
28
+ const [zOrderFlashRect, setZOrderFlashRect] = useState<OverlayRect | null>(null);
29
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30
+ useEffect(
31
+ () => () => {
32
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
33
+ },
34
+ [],
35
+ );
36
+
37
+ const handleZOrderCrossed = useCallback(
38
+ (crossed: HTMLElement) => {
39
+ const overlayEl = overlayRef.current;
40
+ const iframe = iframeRef.current;
41
+ if (!overlayEl || !iframe) return;
42
+ const rect = toVisibleOverlayRect(overlayEl, iframe, crossed);
43
+ if (!rect || rect.width <= 0 || rect.height <= 0) return;
44
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
45
+ setZOrderFlashRect(rect);
46
+ timeoutRef.current = setTimeout(() => {
47
+ timeoutRef.current = null;
48
+ setZOrderFlashRect(null);
49
+ }, Z_ORDER_CROSSED_FLASH_MS);
50
+ },
51
+ [overlayRef, iframeRef],
52
+ );
53
+
54
+ return { zOrderFlashRect, handleZOrderCrossed };
55
+ }
56
+
57
+ /** The flash chrome itself — a pulsing accent outline over the crossed sibling. */
58
+ export function ZOrderCrossedFlash({ rect }: { rect: OverlayRect | null }) {
59
+ if (!rect) return null;
60
+ return (
61
+ <div
62
+ aria-hidden="true"
63
+ data-dom-edit-z-flash="true"
64
+ className="pointer-events-none absolute rounded-md border-2 border-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.35)] animate-pulse"
65
+ style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
66
+ />
67
+ );
68
+ }
@@ -0,0 +1,106 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
7
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
8
+ import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ let root: Root | null = null;
13
+
14
+ afterEach(() => {
15
+ if (root) {
16
+ act(() => root?.unmount());
17
+ root = null;
18
+ }
19
+ document.body.innerHTML = "";
20
+ usePlayerStore.getState().reset();
21
+ // reset() does not cover the out-of-loop seek request; clear it explicitly
22
+ // so a pending-seek test can't leak into the next one.
23
+ usePlayerStore.getState().clearSeekRequest();
24
+ useAssetPreviewStore.getState().clearPreviewAsset();
25
+ });
26
+
27
+ function mountOverlay(): void {
28
+ const host = document.createElement("div");
29
+ document.body.append(host);
30
+ root = createRoot(host);
31
+ act(() => {
32
+ root?.render(<AssetPreviewOverlay />);
33
+ });
34
+ }
35
+
36
+ describe("AssetPreviewOverlay playback dismissal", () => {
37
+ it("dismisses immediately when opened while playback is ALREADY running", () => {
38
+ // The RAF playback loop bypasses the store, so no post-open store change
39
+ // will arrive — the dismiss check must be level-triggered, not edge-triggered.
40
+ usePlayerStore.setState({ isPlaying: true });
41
+ mountOverlay();
42
+
43
+ act(() => {
44
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
45
+ });
46
+
47
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
48
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
49
+ });
50
+
51
+ it("dismisses immediately when opened while a seek is ALREADY in flight", () => {
52
+ // A pending out-of-loop seek (requestedSeekTime set, not yet consumed) may
53
+ // produce no further store change before currentTime settles — the on-open
54
+ // check must evaluate the full shared dismiss predicate, not just isPlaying.
55
+ usePlayerStore.setState({ isPlaying: false, requestedSeekTime: 3.2 });
56
+ mountOverlay();
57
+
58
+ act(() => {
59
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
60
+ });
61
+
62
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
63
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
64
+ });
65
+
66
+ it("stays open when the playhead is idle", () => {
67
+ mountOverlay();
68
+
69
+ act(() => {
70
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
71
+ });
72
+
73
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
74
+ expect(document.querySelector('[role="dialog"]')).not.toBeNull();
75
+ });
76
+
77
+ it("still dismisses when playback starts AFTER the preview opened", () => {
78
+ mountOverlay();
79
+
80
+ act(() => {
81
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
82
+ });
83
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
84
+
85
+ act(() => {
86
+ usePlayerStore.setState({ isPlaying: true });
87
+ });
88
+
89
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
90
+ });
91
+
92
+ it("still dismisses when the playhead is scrubbed after opening", () => {
93
+ usePlayerStore.setState({ currentTime: 2 });
94
+ mountOverlay();
95
+
96
+ act(() => {
97
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
98
+ });
99
+
100
+ act(() => {
101
+ usePlayerStore.setState({ currentTime: 4.5 });
102
+ });
103
+
104
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
105
+ });
106
+ });
@@ -2,15 +2,20 @@
2
2
  * CapCut-style asset preview overlay rendered inside PreviewPane.
3
3
  *
4
4
  * Shown when the user clicks an asset card that has NOT yet been added to the
5
- * timeline. Displays the media (image / video / audio) without modifying the
6
- * compositionno undo entry, no file mutation.
5
+ * timeline. Displays the media (image / video / audio) as a compact floating
6
+ * card over the canvas the canvas stays visible behind a barely-tinted
7
+ * click-catcher — without modifying the composition (no undo entry, no file
8
+ * mutation).
7
9
  *
8
- * Dismiss: X button, Escape key, or click on the scrim.
10
+ * Dismiss: X button, Escape key, click outside the card, or any playhead
11
+ * activity (starting playback / seeking) — the canvas refocuses.
9
12
  * Switching to another not-added asset replaces the current preview.
10
13
  */
11
14
  import { useEffect, useCallback } from "react";
12
15
  import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
13
16
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
17
+ import { usePlayerStore } from "../../player/store/playerStore";
18
+ import { shouldDismissAssetPreview } from "../../utils/assetPreviewDismiss";
14
19
  import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
15
20
 
16
21
  function basename(path: string): string {
@@ -37,11 +42,7 @@ function AssetPreviewMedia({
37
42
  }) {
38
43
  if (kind === "image") {
39
44
  return (
40
- <img
41
- src={serveUrl}
42
- alt={name}
43
- className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
44
- />
45
+ <img src={serveUrl} alt={name} className="max-w-full max-h-[40vh] rounded object-contain" />
45
46
  );
46
47
  }
47
48
  if (kind === "video") {
@@ -52,12 +53,12 @@ function AssetPreviewMedia({
52
53
  autoPlay
53
54
  muted
54
55
  playsInline
55
- className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
56
+ className="max-w-full max-h-[40vh] rounded"
56
57
  />
57
58
  );
58
59
  }
59
60
  return (
60
- <div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
61
+ <div className="flex flex-col items-center gap-3 px-6 py-4">
61
62
  <svg
62
63
  width="40"
63
64
  height="40"
@@ -94,6 +95,30 @@ export function AssetPreviewOverlay() {
94
95
  return () => window.removeEventListener("keydown", handleKeyDown);
95
96
  }, [previewAsset, handleKeyDown]);
96
97
 
98
+ // The canvas refocuses on any playhead activity: starting playback or a
99
+ // seek/scrub away from where the playhead sat when the preview opened
100
+ // dismisses it. openedTime is captured per preview open (previewAsset dep),
101
+ // so a stale render can never dismiss against the wrong reference time.
102
+ useEffect(() => {
103
+ if (!previewAsset) return;
104
+ const opened = usePlayerStore.getState();
105
+ const openedTime = opened.currentTime;
106
+ // Level-triggered, not edge-triggered: a preview opened while playback is
107
+ // ALREADY running (the RAF loop bypasses the store) or while a seek is
108
+ // already in flight gets no store change to react to, so evaluate the
109
+ // current state once, through the same shared predicate the subscription
110
+ // uses. openedTime is this snapshot's own currentTime, so the
111
+ // time-diverged branch can't false-positive at open — only the
112
+ // isPlaying / requestedSeekTime branches can fire here.
113
+ if (shouldDismissAssetPreview(openedTime, opened)) {
114
+ clearPreviewAsset();
115
+ return;
116
+ }
117
+ return usePlayerStore.subscribe((state) => {
118
+ if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
119
+ });
120
+ }, [previewAsset, clearPreviewAsset]);
121
+
97
122
  if (!previewAsset || !previewProjectId) return null;
98
123
 
99
124
  const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
@@ -101,40 +126,39 @@ export function AssetPreviewOverlay() {
101
126
 
102
127
  return (
103
128
  <div
104
- className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
129
+ className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/20"
105
130
  onClick={clearPreviewAsset}
106
131
  role="dialog"
107
132
  aria-label={`Preview: ${name}`}
108
- aria-modal="true"
109
133
  >
110
- {/* Close button */}
111
- <button
112
- className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
113
- onClick={(e) => {
114
- e.stopPropagation();
115
- clearPreviewAsset();
116
- }}
117
- aria-label="Close preview"
118
- >
119
- <svg
120
- width="12"
121
- height="12"
122
- viewBox="0 0 24 24"
123
- stroke="currentColor"
124
- strokeWidth="2.5"
125
- fill="none"
126
- strokeLinecap="round"
127
- >
128
- <line x1="18" y1="6" x2="6" y2="18" />
129
- <line x1="6" y1="6" x2="18" y2="18" />
130
- </svg>
131
- </button>
132
-
133
- {/* Media */}
134
+ {/* Floating preview card — compact, canvas stays visible around it */}
134
135
  <div
135
- className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
136
+ className="relative flex flex-col items-center gap-2 max-w-[58%] rounded-lg border border-neutral-800 bg-neutral-950/95 p-2 pt-8 shadow-2xl"
136
137
  onClick={(e) => e.stopPropagation()}
137
138
  >
139
+ {/* Close button */}
140
+ <button
141
+ className="absolute top-2 right-2 w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
142
+ onClick={(e) => {
143
+ e.stopPropagation();
144
+ clearPreviewAsset();
145
+ }}
146
+ aria-label="Close preview"
147
+ >
148
+ <svg
149
+ width="12"
150
+ height="12"
151
+ viewBox="0 0 24 24"
152
+ stroke="currentColor"
153
+ strokeWidth="2.5"
154
+ fill="none"
155
+ strokeLinecap="round"
156
+ >
157
+ <line x1="18" y1="6" x2="6" y2="18" />
158
+ <line x1="6" y1="6" x2="18" y2="18" />
159
+ </svg>
160
+ </button>
161
+
138
162
  <AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />
139
163
 
140
164
  {/* Filename label */}
@@ -18,6 +18,7 @@ import {
18
18
  import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
19
19
  import { readHfId, type DomEditSelection } from "../editor/domEditing";
20
20
  import { buildStableSelector } from "../editor/domEditingDom";
21
+ import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
21
22
  import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
22
23
  import type { GestureRecordingState } from "../editor/GestureRecordControl";
23
24
  import type { ReactNode } from "react";
@@ -38,6 +39,8 @@ type ZIndexReorderEntry = {
38
39
  selector?: string;
39
40
  selectorIndex?: number;
40
41
  sourceFile: string;
42
+ /** Timeline store key — lets the commit update the store zIndex synchronously. */
43
+ key?: string;
41
44
  };
42
45
 
43
46
  /** Can this element be robustly re-targeted for a persisted z change? */
@@ -58,6 +61,12 @@ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReord
58
61
  selector: sel.selector,
59
62
  selectorIndex: sel.selectorIndex,
60
63
  sourceFile: sel.sourceFile,
64
+ key: deriveTimelineStoreKey({
65
+ domId: sel.id ?? undefined,
66
+ selector: sel.selector,
67
+ selectorIndex: sel.selectorIndex,
68
+ sourceFile: sel.sourceFile,
69
+ }),
61
70
  };
62
71
  }
63
72
 
@@ -75,7 +84,15 @@ function siblingZIndexEntry(
75
84
  const id = element.id || undefined;
76
85
  const selector = buildStableSelector(element);
77
86
  if (!canTargetZIndexElement(element, id, selector)) return null;
78
- return { element, zIndex, id, selector, selectorIndex: undefined, sourceFile };
87
+ return {
88
+ element,
89
+ zIndex,
90
+ id,
91
+ selector,
92
+ selectorIndex: undefined,
93
+ sourceFile,
94
+ key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
95
+ };
79
96
  }
80
97
 
81
98
  /** Short human-readable label for a dropped sibling, for the console warning below. */
@@ -88,14 +105,16 @@ function describeZIndexElement(element: HTMLElement): string {
88
105
  }
89
106
 
90
107
  // Resolve z-index patches into commit entries; a sibling with no stable
91
- // id/selector can't be written to source, so it is collected as a label for the
92
- // revert-on-reload warning instead.
93
- function resolveZIndexEntries(
108
+ // id/selector can't be written to source, so it is returned as `dropped` for
109
+ // the revert-on-reload warning (and a live-only style write, so the resolved
110
+ // stacking order still renders coherently). Exported so tests can drive the
111
+ // menu → commit path through the same wiring the app uses.
112
+ export function resolveZIndexEntries(
94
113
  sel: DomEditSelection,
95
114
  patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
96
- ): { entries: ZIndexReorderEntry[]; droppedLabels: string[] } {
115
+ ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
97
116
  const entries: ZIndexReorderEntry[] = [];
98
- const droppedLabels: string[] = [];
117
+ const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
99
118
  for (const patch of patches) {
100
119
  if (patch.element === sel.element) {
101
120
  entries.push(selectedZIndexEntry(sel, patch.zIndex));
@@ -103,9 +122,9 @@ function resolveZIndexEntries(
103
122
  }
104
123
  const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
105
124
  if (entry) entries.push(entry);
106
- else droppedLabels.push(describeZIndexElement(patch.element));
125
+ else dropped.push(patch);
107
126
  }
108
- return { entries, droppedLabels };
127
+ return { entries, dropped };
109
128
  }
110
129
 
111
130
  // fallow-ignore-next-line complexity
@@ -205,19 +224,20 @@ export function PreviewOverlays({
205
224
  onRotationCommit={handleDomRotationCommit}
206
225
  onStyleCommit={handleDomStyleCommit}
207
226
  onDeleteSelection={handleDomEditElementDelete}
208
- onApplyZIndex={(sel, patches) => {
209
- const { entries, droppedLabels } = resolveZIndexEntries(sel, patches);
210
- if (droppedLabels.length > 0) {
211
- // The optimistic z-index has already been applied live at this point —
212
- // these siblings just won't be written to source, so they'll revert to
213
- // their prior stacking order on the next reload with no other signal.
227
+ onApplyZIndex={(sel, patches, action) => {
228
+ const { entries, dropped } = resolveZIndexEntries(sel, patches);
229
+ if (dropped.length > 0) {
230
+ // These siblings can't be written to source. Apply their live z
231
+ // anyway so the resolved stacking order renders coherently it
232
+ // just reverts to the prior order on the next reload.
233
+ for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
214
234
  console.warn(
215
235
  "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
216
236
  "(will revert on reload):",
217
- droppedLabels.join(", "),
237
+ dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
218
238
  );
219
239
  }
220
- if (entries.length > 0) handleDomZIndexReorderCommit(entries);
240
+ if (entries.length > 0) handleDomZIndexReorderCommit(entries, undefined, action);
221
241
  }}
222
242
  gridVisible={snapPrefs.gridVisible}
223
243
  gridSpacing={snapPrefs.gridSpacing}
@@ -0,0 +1,106 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
7
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
8
+ import { AssetCard } from "./AssetCard";
9
+ import { AudioRow } from "./AudioRow";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ let root: Root | null = null;
14
+
15
+ afterEach(() => {
16
+ if (root) {
17
+ act(() => root?.unmount());
18
+ root = null;
19
+ }
20
+ document.body.innerHTML = "";
21
+ usePlayerStore.getState().reset();
22
+ useAssetPreviewStore.getState().clearPreviewAsset();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ function mount(node: React.ReactElement): HTMLElement {
27
+ const host = document.createElement("div");
28
+ document.body.append(host);
29
+ root = createRoot(host);
30
+ act(() => {
31
+ root?.render(node);
32
+ });
33
+ return host;
34
+ }
35
+
36
+ function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
37
+ return { tag: "div", start: 0, duration: 5, track: 0, ...input };
38
+ }
39
+
40
+ /** Simulate a drag-free click: pointerdown + pointerup at the same point. */
41
+ function clickCard(host: HTMLElement): void {
42
+ const card = host.querySelector('[draggable="true"]');
43
+ if (!card) throw new Error("Expected a draggable card root");
44
+ const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
45
+ act(() => {
46
+ card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
47
+ card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
48
+ });
49
+ }
50
+
51
+ describe("AssetCard click behavior", () => {
52
+ const cardProps = {
53
+ projectId: "p1",
54
+ onCopy: vi.fn(),
55
+ isCopied: false,
56
+ };
57
+
58
+ it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
59
+ usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
60
+ // Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
61
+ // or it stays stuck over the canvas while the timeline reveals the clip.
62
+ useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
63
+
64
+ const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
65
+ clickCard(host);
66
+
67
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
68
+ expect(usePlayerStore.getState().selectedElementId).toBe("img1");
69
+ });
70
+
71
+ it("opens the preview overlay for a not-yet-added asset", () => {
72
+ const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
73
+ clickCard(host);
74
+
75
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
76
+ expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
77
+ });
78
+ });
79
+
80
+ describe("AudioRow click behavior", () => {
81
+ const rowProps = {
82
+ projectId: "p1",
83
+ onCopy: vi.fn(),
84
+ isCopied: false,
85
+ };
86
+
87
+ it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
88
+ usePlayerStore
89
+ .getState()
90
+ .setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
91
+ useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
92
+
93
+ const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
94
+ clickCard(host);
95
+
96
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
97
+ expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
98
+ });
99
+
100
+ it("opens the preview overlay for a not-yet-added audio asset", () => {
101
+ const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
102
+ clickCard(host);
103
+
104
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
105
+ });
106
+ });
@@ -133,8 +133,10 @@ export function AssetCard({
133
133
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
134
134
 
135
135
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
136
+ const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
136
137
  const elements = usePlayerStore((s) => s.elements);
137
138
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
139
+ const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
138
140
 
139
141
  const handlePointerDown = useCallback((e: React.PointerEvent) => {
140
142
  pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -150,14 +152,29 @@ export function AssetCard({
150
152
  if (used) {
151
153
  const clip = findClipForAsset(elements, asset);
152
154
  if (clip) {
153
- setSelectedElementId(clip.key ?? clip.id);
155
+ // Dismiss any open preview overlay (from another asset) — the reveal
156
+ // must not leave a stale preview card floating over the canvas.
157
+ clearPreviewAsset();
158
+ const clipKey = clip.key ?? clip.id;
159
+ setSelectedElementId(clipKey);
160
+ // Scroll the timeline so the selected clip is actually visible.
161
+ requestClipReveal(clipKey);
154
162
  return;
155
163
  }
156
164
  }
157
165
  // Not added (or no matching clip found) → preview overlay
158
166
  setPreviewAsset(asset, projectId);
159
167
  },
160
- [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
168
+ [
169
+ used,
170
+ elements,
171
+ asset,
172
+ projectId,
173
+ setSelectedElementId,
174
+ requestClipReveal,
175
+ setPreviewAsset,
176
+ clearPreviewAsset,
177
+ ],
161
178
  );
162
179
 
163
180
  return (
@@ -43,8 +43,10 @@ export function AudioRow({
43
43
  // CapCut-style click behavior: drag-threshold gate.
44
44
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
45
45
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
46
+ const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
46
47
  const elements = usePlayerStore((s) => s.elements);
47
48
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
49
+ const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
48
50
 
49
51
  const handlePointerDown = useCallback((e: React.PointerEvent) => {
50
52
  pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -59,14 +61,29 @@ export function AudioRow({
59
61
  if (used) {
60
62
  const clip = findClipForAsset(elements, asset);
61
63
  if (clip) {
62
- setSelectedElementId(clip.key ?? clip.id);
64
+ // Dismiss any open preview overlay (from another asset) — the reveal
65
+ // must not leave a stale preview card floating over the canvas.
66
+ clearPreviewAsset();
67
+ const clipKey = clip.key ?? clip.id;
68
+ setSelectedElementId(clipKey);
69
+ // Scroll the timeline so the selected clip is actually visible.
70
+ requestClipReveal(clipKey);
63
71
  return;
64
72
  }
65
73
  }
66
74
  // Not added → preview overlay (audio player)
67
75
  setPreviewAsset(asset, projectId);
68
76
  },
69
- [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
77
+ [
78
+ used,
79
+ elements,
80
+ asset,
81
+ projectId,
82
+ setSelectedElementId,
83
+ requestClipReveal,
84
+ setPreviewAsset,
85
+ clearPreviewAsset,
86
+ ],
70
87
  );
71
88
 
72
89
  useEffect(() => {
@@ -8,7 +8,18 @@ export interface DomEditPatchBatch {
8
8
 
9
9
  export type CommitDomEditPatchBatches = (
10
10
  batches: DomEditPatchBatch[],
11
- options: { label: string; coalesceKey: string },
11
+ options: {
12
+ label: string;
13
+ coalesceKey: string;
14
+ /**
15
+ * Request skipping the preview iframe reload after a successful persist.
16
+ * Only honored when the persist is provably in sync with the live DOM:
17
+ * every patch operation is inline-style-only AND the server matched every
18
+ * patch target. Any unmatched target (or a non-style op) falls back to the
19
+ * reload so the preview reconverges with disk. Default: always reload.
20
+ */
21
+ skipReload?: boolean;
22
+ },
12
23
  ) => Promise<void>;
13
24
 
14
25
  export type PersistDomEditOperations = (