@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.
- package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
- package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4345 -3812
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditOverlay.tsx +31 -62
- package/src/components/editor/LayersPanel.tsx +10 -1
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +36 -16
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/timelineEditingHelpers.test.ts +91 -0
- package/src/hooks/timelineEditingHelpers.ts +31 -244
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +5 -3
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +92 -1
- package/src/hooks/useDomEditCommits.ts +63 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
- package/src/hooks/useElementLifecycleOps.ts +20 -7
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +378 -212
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineGroupEditing.ts +153 -132
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +85 -8
- package/src/player/components/Timeline.tsx +9 -9
- package/src/player/components/TimelineCanvas.tsx +11 -2
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/timelineClipDragCommit.test.ts +91 -8
- package/src/player/components/timelineClipDragCommit.ts +106 -9
- package/src/player/components/timelineLayout.ts +99 -26
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
- package/src/player/components/useTimelineStackingSync.ts +13 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.ts +9 -0
- package/src/player/lib/timelineElementHelpers.ts +19 -0
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +37 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- 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)
|
|
6
|
-
*
|
|
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,
|
|
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-[
|
|
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-
|
|
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/
|
|
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
|
-
{/*
|
|
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-
|
|
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 {
|
|
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
|
|
92
|
-
// revert-on-reload warning
|
|
93
|
-
|
|
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[];
|
|
115
|
+
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
|
|
97
116
|
const entries: ZIndexReorderEntry[] = [];
|
|
98
|
-
const
|
|
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
|
|
125
|
+
else dropped.push(patch);
|
|
107
126
|
}
|
|
108
|
-
return { entries,
|
|
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,
|
|
210
|
-
if (
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
[
|
|
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: {
|
|
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 = (
|