@hyperframes/studio 0.1.10 → 0.1.11
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/index-Bj0pPj_X.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +133 -0
- package/src/components/renders/useRenderQueue.ts +161 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- package/src/player/lib/useMountEffect.ts +0 -10
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
|
3
|
+
|
|
4
|
+
interface CompositionsTabProps {
|
|
5
|
+
projectId: string;
|
|
6
|
+
compositions: string[];
|
|
7
|
+
activeComposition: string | null;
|
|
8
|
+
onSelect: (comp: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ExpandedCompPreview({
|
|
12
|
+
previewUrl,
|
|
13
|
+
name,
|
|
14
|
+
comp,
|
|
15
|
+
onSelect,
|
|
16
|
+
}: {
|
|
17
|
+
previewUrl: string;
|
|
18
|
+
name: string;
|
|
19
|
+
comp: string;
|
|
20
|
+
onSelect: () => void;
|
|
21
|
+
}) {
|
|
22
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
24
|
+
const [dims, setDims] = useState({ w: 1920, h: 1080 });
|
|
25
|
+
const [scale, setScale] = useState(1);
|
|
26
|
+
|
|
27
|
+
const updateScale = useCallback(() => {
|
|
28
|
+
const el = containerRef.current;
|
|
29
|
+
if (!el) return;
|
|
30
|
+
const s = Math.min(el.clientWidth / dims.w, el.clientHeight / dims.h);
|
|
31
|
+
setScale(s);
|
|
32
|
+
}, [dims]);
|
|
33
|
+
|
|
34
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
updateScale();
|
|
37
|
+
const el = containerRef.current;
|
|
38
|
+
if (!el) return;
|
|
39
|
+
const ro = new ResizeObserver(updateScale);
|
|
40
|
+
ro.observe(el);
|
|
41
|
+
return () => ro.disconnect();
|
|
42
|
+
}, [updateScale]);
|
|
43
|
+
|
|
44
|
+
const handleLoad = useCallback(() => {
|
|
45
|
+
const iframe = iframeRef.current;
|
|
46
|
+
if (!iframe) return;
|
|
47
|
+
// Detect dimensions from composition
|
|
48
|
+
try {
|
|
49
|
+
const doc = iframe.contentDocument;
|
|
50
|
+
if (doc) {
|
|
51
|
+
const root = doc.querySelector("[data-composition-id]");
|
|
52
|
+
if (root) {
|
|
53
|
+
const w = parseInt(root.getAttribute("data-width") ?? "0", 10);
|
|
54
|
+
const h = parseInt(root.getAttribute("data-height") ?? "0", 10);
|
|
55
|
+
if (w > 0 && h > 0) setDims({ w, h });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* cross-origin */
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let attempts = 0;
|
|
63
|
+
const interval = setInterval(() => {
|
|
64
|
+
try {
|
|
65
|
+
const win = iframe.contentWindow as Window & {
|
|
66
|
+
__player?: { play: () => void; seek: (t: number) => void };
|
|
67
|
+
__timelines?: Record<string, { play: () => void; seek: (t: number) => void }>;
|
|
68
|
+
};
|
|
69
|
+
if (win?.__player) {
|
|
70
|
+
win.__player.seek(0.5);
|
|
71
|
+
win.__player.play();
|
|
72
|
+
clearInterval(interval);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (win?.__timelines) {
|
|
76
|
+
const keys = Object.keys(win.__timelines);
|
|
77
|
+
const tl = keys.length > 0 ? win.__timelines[keys[keys.length - 1]] : null;
|
|
78
|
+
if (tl) {
|
|
79
|
+
tl.seek(0.5);
|
|
80
|
+
tl.play();
|
|
81
|
+
clearInterval(interval);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* cross-origin */
|
|
86
|
+
}
|
|
87
|
+
if (++attempts > 15) clearInterval(interval);
|
|
88
|
+
}, 200);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const offsetX = containerRef.current
|
|
92
|
+
? (containerRef.current.clientWidth - dims.w * scale) / 2
|
|
93
|
+
: 0;
|
|
94
|
+
const offsetY = containerRef.current
|
|
95
|
+
? (containerRef.current.clientHeight - dims.h * scale) / 2
|
|
96
|
+
: 0;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
|
100
|
+
<div ref={containerRef} className="flex-1 min-h-0 relative overflow-hidden bg-black">
|
|
101
|
+
<iframe
|
|
102
|
+
ref={iframeRef}
|
|
103
|
+
src={previewUrl}
|
|
104
|
+
sandbox="allow-scripts allow-same-origin"
|
|
105
|
+
onLoad={handleLoad}
|
|
106
|
+
className="absolute border-none"
|
|
107
|
+
style={{
|
|
108
|
+
left: Math.max(0, offsetX),
|
|
109
|
+
top: Math.max(0, offsetY),
|
|
110
|
+
width: dims.w,
|
|
111
|
+
height: dims.h,
|
|
112
|
+
transformOrigin: "0 0",
|
|
113
|
+
transform: `scale(${scale})`,
|
|
114
|
+
}}
|
|
115
|
+
tabIndex={-1}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
|
119
|
+
<div>
|
|
120
|
+
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
|
121
|
+
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{comp}</div>
|
|
122
|
+
</div>
|
|
123
|
+
<button
|
|
124
|
+
onClick={(e) => {
|
|
125
|
+
e.stopPropagation();
|
|
126
|
+
onSelect();
|
|
127
|
+
}}
|
|
128
|
+
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
|
129
|
+
>
|
|
130
|
+
Open
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function CompCard({
|
|
138
|
+
projectId,
|
|
139
|
+
comp,
|
|
140
|
+
isActive,
|
|
141
|
+
onSelect,
|
|
142
|
+
}: {
|
|
143
|
+
projectId: string;
|
|
144
|
+
comp: string;
|
|
145
|
+
isActive: boolean;
|
|
146
|
+
onSelect: () => void;
|
|
147
|
+
}) {
|
|
148
|
+
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
149
|
+
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;
|
|
150
|
+
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
151
|
+
|
|
152
|
+
const card = (
|
|
153
|
+
<div
|
|
154
|
+
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
|
155
|
+
isActive
|
|
156
|
+
? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
|
|
157
|
+
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
158
|
+
}`}
|
|
159
|
+
>
|
|
160
|
+
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0">
|
|
161
|
+
<img
|
|
162
|
+
src={thumbnailUrl}
|
|
163
|
+
alt={name}
|
|
164
|
+
loading="lazy"
|
|
165
|
+
className="w-full h-full object-cover"
|
|
166
|
+
onError={(e) => {
|
|
167
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
<div className="min-w-0 flex-1">
|
|
172
|
+
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
|
173
|
+
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<ExpandOnHover
|
|
180
|
+
expandedContent={(closeExpand) => (
|
|
181
|
+
<ExpandedCompPreview
|
|
182
|
+
previewUrl={previewUrl}
|
|
183
|
+
name={name}
|
|
184
|
+
comp={comp}
|
|
185
|
+
onSelect={() => {
|
|
186
|
+
closeExpand();
|
|
187
|
+
onSelect();
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
191
|
+
onClick={onSelect}
|
|
192
|
+
expandScale={0.5}
|
|
193
|
+
delay={500}
|
|
194
|
+
>
|
|
195
|
+
{card}
|
|
196
|
+
</ExpandOnHover>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const CompositionsTab = memo(function CompositionsTab({
|
|
201
|
+
projectId,
|
|
202
|
+
compositions,
|
|
203
|
+
activeComposition,
|
|
204
|
+
onSelect,
|
|
205
|
+
}: CompositionsTabProps) {
|
|
206
|
+
if (compositions.length === 0) {
|
|
207
|
+
return (
|
|
208
|
+
<div className="flex-1 flex items-center justify-center px-4">
|
|
209
|
+
<p className="text-xs text-neutral-600 text-center">No compositions found</p>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="flex-1 overflow-y-auto">
|
|
216
|
+
{compositions.map((comp) => (
|
|
217
|
+
<CompCard
|
|
218
|
+
key={comp}
|
|
219
|
+
projectId={projectId}
|
|
220
|
+
comp={comp}
|
|
221
|
+
isActive={activeComposition === comp}
|
|
222
|
+
onSelect={() => onSelect(comp)}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { memo, useState, useCallback } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import { CompositionsTab } from "./CompositionsTab";
|
|
4
|
+
import { AssetsTab } from "./AssetsTab";
|
|
5
|
+
|
|
6
|
+
type SidebarTab = "compositions" | "assets";
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
|
9
|
+
|
|
10
|
+
function getPersistedTab(): SidebarTab {
|
|
11
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
12
|
+
return stored === "assets" ? "assets" : "compositions";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface LeftSidebarProps {
|
|
16
|
+
width?: number;
|
|
17
|
+
projectId: string;
|
|
18
|
+
compositions: string[];
|
|
19
|
+
assets: string[];
|
|
20
|
+
activeComposition: string | null;
|
|
21
|
+
onSelectComposition: (comp: string) => void;
|
|
22
|
+
onImportFiles?: (files: FileList) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const LeftSidebar = memo(function LeftSidebar({
|
|
26
|
+
width = 240,
|
|
27
|
+
projectId,
|
|
28
|
+
compositions,
|
|
29
|
+
assets,
|
|
30
|
+
activeComposition,
|
|
31
|
+
onSelectComposition,
|
|
32
|
+
onImportFiles,
|
|
33
|
+
}: LeftSidebarProps) {
|
|
34
|
+
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
35
|
+
|
|
36
|
+
const selectTab = useCallback((t: SidebarTab) => {
|
|
37
|
+
setTab(t);
|
|
38
|
+
localStorage.setItem(STORAGE_KEY, t);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
// Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets
|
|
42
|
+
useMountEffect(() => {
|
|
43
|
+
const handler = (e: KeyboardEvent) => {
|
|
44
|
+
if (!e.metaKey && !e.ctrlKey) return;
|
|
45
|
+
if (e.key === "1") {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
selectTab("compositions");
|
|
48
|
+
}
|
|
49
|
+
if (e.key === "2") {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
selectTab("assets");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
window.addEventListener("keydown", handler);
|
|
55
|
+
return () => window.removeEventListener("keydown", handler);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
|
|
61
|
+
style={{ width }}
|
|
62
|
+
>
|
|
63
|
+
{/* Tabs */}
|
|
64
|
+
<div className="flex border-b border-neutral-800/50 flex-shrink-0">
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={() => selectTab("compositions")}
|
|
68
|
+
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
69
|
+
tab === "compositions"
|
|
70
|
+
? "text-neutral-200 border-b-2 border-blue-500"
|
|
71
|
+
: "text-neutral-500 hover:text-neutral-400"
|
|
72
|
+
}`}
|
|
73
|
+
>
|
|
74
|
+
Compositions
|
|
75
|
+
</button>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => selectTab("assets")}
|
|
79
|
+
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
80
|
+
tab === "assets"
|
|
81
|
+
? "text-neutral-200 border-b-2 border-blue-500"
|
|
82
|
+
: "text-neutral-500 hover:text-neutral-400"
|
|
83
|
+
}`}
|
|
84
|
+
>
|
|
85
|
+
Assets
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Tab content */}
|
|
90
|
+
{tab === "compositions" ? (
|
|
91
|
+
<CompositionsTab
|
|
92
|
+
projectId={projectId}
|
|
93
|
+
compositions={compositions}
|
|
94
|
+
activeComposition={activeComposition}
|
|
95
|
+
onSelect={onSelectComposition}
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { motion, AnimatePresence } from "motion/react";
|
|
3
|
+
|
|
4
|
+
interface ExpandOnHoverProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
expandedContent?: ReactNode | ((close: () => void) => ReactNode);
|
|
7
|
+
expandScale?: number;
|
|
8
|
+
delay?: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ExpandOnHover({
|
|
14
|
+
children,
|
|
15
|
+
expandedContent,
|
|
16
|
+
expandScale = 0.75,
|
|
17
|
+
delay = 300,
|
|
18
|
+
className = "",
|
|
19
|
+
onClick,
|
|
20
|
+
}: ExpandOnHoverProps) {
|
|
21
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
22
|
+
const [origin, setOrigin] = useState({ x: 0, y: 0, w: 0, h: 0 });
|
|
23
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const expandedRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
26
|
+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
27
|
+
|
|
28
|
+
const close = useCallback(() => {
|
|
29
|
+
if (timerRef.current) {
|
|
30
|
+
clearTimeout(timerRef.current);
|
|
31
|
+
timerRef.current = null;
|
|
32
|
+
}
|
|
33
|
+
if (closeTimerRef.current) {
|
|
34
|
+
clearTimeout(closeTimerRef.current);
|
|
35
|
+
closeTimerRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
setIsExpanded(false);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const open = useCallback(() => {
|
|
41
|
+
if (!containerRef.current) return;
|
|
42
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
43
|
+
setOrigin({ x: rect.left, y: rect.top, w: rect.width, h: rect.height });
|
|
44
|
+
setIsExpanded(true);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const handleCardEnter = useCallback(() => {
|
|
48
|
+
if (isExpanded) return;
|
|
49
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
50
|
+
timerRef.current = setTimeout(open, delay);
|
|
51
|
+
}, [delay, open, isExpanded]);
|
|
52
|
+
|
|
53
|
+
const handleCardLeave = useCallback(() => {
|
|
54
|
+
if (isExpanded) return;
|
|
55
|
+
if (timerRef.current) {
|
|
56
|
+
clearTimeout(timerRef.current);
|
|
57
|
+
timerRef.current = null;
|
|
58
|
+
}
|
|
59
|
+
}, [isExpanded]);
|
|
60
|
+
|
|
61
|
+
// When expanded: track mouse position. If mouse stays outside the expanded
|
|
62
|
+
// card for 600ms continuously, close. Any re-entry resets the timer.
|
|
63
|
+
// Note: useEffect with [isExpanded] is acceptable — subscribes to window mousemove
|
|
64
|
+
// only while expanded, with cleanup on collapse. Can't be a mount effect.
|
|
65
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!isExpanded) return;
|
|
68
|
+
|
|
69
|
+
const CLOSE_DELAY = 600; // ms mouse must be outside to close
|
|
70
|
+
const START_DELAY = 400; // ms before we start checking (let animation settle)
|
|
71
|
+
let tracking = false;
|
|
72
|
+
|
|
73
|
+
const startTracking = setTimeout(() => {
|
|
74
|
+
tracking = true;
|
|
75
|
+
}, START_DELAY);
|
|
76
|
+
|
|
77
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
78
|
+
if (!tracking) return;
|
|
79
|
+
const el = expandedRef.current;
|
|
80
|
+
if (!el) return;
|
|
81
|
+
|
|
82
|
+
const rect = el.getBoundingClientRect();
|
|
83
|
+
// Add generous padding so edge movements don't trigger close
|
|
84
|
+
const pad = 20;
|
|
85
|
+
const inside =
|
|
86
|
+
e.clientX >= rect.left - pad &&
|
|
87
|
+
e.clientX <= rect.right + pad &&
|
|
88
|
+
e.clientY >= rect.top - pad &&
|
|
89
|
+
e.clientY <= rect.bottom + pad;
|
|
90
|
+
|
|
91
|
+
if (inside) {
|
|
92
|
+
// Mouse is inside — cancel any pending close
|
|
93
|
+
if (closeTimerRef.current) {
|
|
94
|
+
clearTimeout(closeTimerRef.current);
|
|
95
|
+
closeTimerRef.current = null;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// Mouse is outside — start close countdown if not already started
|
|
99
|
+
if (!closeTimerRef.current) {
|
|
100
|
+
closeTimerRef.current = setTimeout(() => {
|
|
101
|
+
setIsExpanded(false);
|
|
102
|
+
}, CLOSE_DELAY);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
clearTimeout(startTracking);
|
|
111
|
+
if (closeTimerRef.current) {
|
|
112
|
+
clearTimeout(closeTimerRef.current);
|
|
113
|
+
closeTimerRef.current = null;
|
|
114
|
+
}
|
|
115
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
116
|
+
};
|
|
117
|
+
}, [isExpanded]);
|
|
118
|
+
|
|
119
|
+
const vw = typeof window !== "undefined" ? window.innerWidth : 1440;
|
|
120
|
+
const vh = typeof window !== "undefined" ? window.innerHeight : 900;
|
|
121
|
+
const targetW = vw * expandScale;
|
|
122
|
+
const targetH = vh * expandScale;
|
|
123
|
+
const targetX = (vw - targetW) / 2;
|
|
124
|
+
const targetY = (vh - targetH) / 2;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
<div
|
|
129
|
+
ref={containerRef}
|
|
130
|
+
className={className}
|
|
131
|
+
onMouseEnter={handleCardEnter}
|
|
132
|
+
onMouseLeave={handleCardLeave}
|
|
133
|
+
onClick={onClick}
|
|
134
|
+
style={{ opacity: isExpanded ? 0 : 1, transition: "opacity 100ms ease-out" }}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<AnimatePresence>
|
|
140
|
+
{isExpanded && (
|
|
141
|
+
<>
|
|
142
|
+
{/* Backdrop */}
|
|
143
|
+
<motion.div
|
|
144
|
+
initial={{ opacity: 0 }}
|
|
145
|
+
animate={{ opacity: 1 }}
|
|
146
|
+
exit={{ opacity: 0 }}
|
|
147
|
+
transition={{ duration: 0.15 }}
|
|
148
|
+
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
|
149
|
+
onClick={close}
|
|
150
|
+
/>
|
|
151
|
+
{/* Expanded card */}
|
|
152
|
+
<motion.div
|
|
153
|
+
ref={expandedRef}
|
|
154
|
+
initial={{
|
|
155
|
+
left: origin.x,
|
|
156
|
+
top: origin.y,
|
|
157
|
+
width: origin.w,
|
|
158
|
+
height: origin.h,
|
|
159
|
+
}}
|
|
160
|
+
animate={{
|
|
161
|
+
left: targetX,
|
|
162
|
+
top: targetY,
|
|
163
|
+
width: targetW,
|
|
164
|
+
height: targetH,
|
|
165
|
+
}}
|
|
166
|
+
exit={{
|
|
167
|
+
left: origin.x,
|
|
168
|
+
top: origin.y,
|
|
169
|
+
width: origin.w,
|
|
170
|
+
height: origin.h,
|
|
171
|
+
}}
|
|
172
|
+
transition={{
|
|
173
|
+
type: "spring",
|
|
174
|
+
stiffness: 280,
|
|
175
|
+
damping: 28,
|
|
176
|
+
mass: 0.8,
|
|
177
|
+
}}
|
|
178
|
+
className="fixed z-50 overflow-hidden rounded-[16px] shadow-dialog"
|
|
179
|
+
onClick={(e: React.MouseEvent) => {
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
close();
|
|
182
|
+
onClick?.();
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{typeof expandedContent === "function"
|
|
186
|
+
? expandedContent(close)
|
|
187
|
+
: (expandedContent ?? children)}
|
|
188
|
+
</motion.div>
|
|
189
|
+
</>
|
|
190
|
+
)}
|
|
191
|
+
</AnimatePresence>
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -16,7 +16,7 @@ export interface UseCodeEditorReturn {
|
|
|
16
16
|
setActiveFile: (path: string) => void;
|
|
17
17
|
updateContent: (content: string) => void;
|
|
18
18
|
markSaved: (path: string) => void;
|
|
19
|
-
/** External update
|
|
19
|
+
/** External update — updates saved content, shows reload indicator */
|
|
20
20
|
externalUpdate: (path: string, content: string) => void;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -156,7 +156,11 @@ export function useElementPicker(
|
|
|
156
156
|
(
|
|
157
157
|
elementId: string,
|
|
158
158
|
selector: string,
|
|
159
|
-
op: {
|
|
159
|
+
op: {
|
|
160
|
+
type: "inline-style" | "attribute" | "text-content";
|
|
161
|
+
property: string;
|
|
162
|
+
value: string;
|
|
163
|
+
},
|
|
160
164
|
) => {
|
|
161
165
|
const opts = optionsRef.current;
|
|
162
166
|
if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
|
package/src/index.ts
CHANGED
|
@@ -10,13 +10,14 @@ export {
|
|
|
10
10
|
PlayerControls,
|
|
11
11
|
Timeline,
|
|
12
12
|
PreviewPanel,
|
|
13
|
-
|
|
13
|
+
VideoThumbnail,
|
|
14
|
+
CompositionThumbnail,
|
|
14
15
|
useTimelinePlayer,
|
|
15
16
|
usePlayerStore,
|
|
16
17
|
liveTime,
|
|
17
18
|
formatTime,
|
|
18
19
|
} from "./player";
|
|
19
|
-
export type {
|
|
20
|
+
export type { TimelineElement } from "./player";
|
|
20
21
|
|
|
21
22
|
// Editor
|
|
22
23
|
export { SourceEditor } from "./components/editor/SourceEditor";
|
|
@@ -28,4 +29,11 @@ export { StudioApp } from "./App";
|
|
|
28
29
|
|
|
29
30
|
// Hooks
|
|
30
31
|
export { useCodeEditor } from "./hooks/useCodeEditor";
|
|
32
|
+
export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
|
|
31
33
|
export { useElementPicker } from "./hooks/useElementPicker";
|
|
34
|
+
export type { PickedElement } from "./hooks/useElementPicker";
|
|
35
|
+
|
|
36
|
+
// Utilities
|
|
37
|
+
export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
|
|
38
|
+
export type { PatchOperation } from "./utils/sourcePatcher";
|
|
39
|
+
export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
|