@hyperframes/studio 0.6.29 → 0.6.31
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-BWBj8I6Q.css +1 -0
- package/dist/assets/index-DSLrl2tB.js +531 -0
- package/dist/assets/index-Do0kAMcy.js +115 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +13 -0
- package/src/components/StudioErrorBoundary.tsx +69 -0
- package/src/components/StudioHeader.tsx +15 -3
- package/src/components/editor/PropertyPanel.tsx +4 -1
- package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
- package/src/components/nle/NLELayout.tsx +41 -6
- package/src/components/renders/RenderQueue.tsx +2 -0
- package/src/components/renders/useRenderQueue.ts +9 -0
- package/src/components/sidebar/LeftSidebar.tsx +2 -0
- package/src/contexts/FileManagerContext.tsx +3 -3
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useDomEditCommits.ts +52 -24
- package/src/hooks/useFileManager.ts +15 -13
- package/src/hooks/usePanelLayout.ts +11 -1
- package/src/hooks/useRenderClipContent.test.ts +50 -0
- package/src/hooks/useRenderClipContent.ts +23 -4
- package/src/hooks/useServerConnection.ts +11 -1
- package/src/main.tsx +36 -1
- package/src/player/components/CompositionThumbnail.tsx +10 -44
- package/src/player/components/PlayerControls.tsx +75 -3
- package/src/player/components/TimelineCanvas.tsx +9 -23
- package/src/player/components/TimelineClip.tsx +63 -67
- package/src/player/components/timelineTheme.ts +18 -48
- package/src/player/hooks/usePlaybackKeyboard.test.ts +55 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +15 -0
- package/src/player/lib/mediaProbe.ts +20 -5
- package/src/styles/studio.css +9 -0
- package/src/telemetry/client.test.ts +100 -0
- package/src/telemetry/client.ts +145 -0
- package/src/telemetry/config.ts +78 -0
- package/src/telemetry/events.test.ts +57 -0
- package/src/telemetry/events.ts +27 -0
- package/src/telemetry/system.ts +48 -0
- package/src/utils/studioTelemetry.ts +128 -0
- package/dist/assets/index-C-kAqQVb.js +0 -362
- package/dist/assets/index-DVpLGNHi.css +0 -1
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-Do0kAMcy.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BWBj8I6Q.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.31",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@codemirror/view": "6.40.0",
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"mediabunny": "^1.45.3",
|
|
34
|
-
"@hyperframes/
|
|
35
|
-
"@hyperframes/
|
|
34
|
+
"@hyperframes/player": "0.6.31",
|
|
35
|
+
"@hyperframes/core": "0.6.31"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "19",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"vite": "^6.4.2",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
48
|
"zustand": "^5.0.0",
|
|
49
|
-
"@hyperframes/producer": "0.6.
|
|
49
|
+
"@hyperframes/producer": "0.6.31"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -47,11 +47,24 @@ import {
|
|
|
47
47
|
normalizeStudioCompositionPath,
|
|
48
48
|
readStudioUrlStateFromWindow,
|
|
49
49
|
} from "./utils/studioUrlState";
|
|
50
|
+
import { trackStudioSessionStart } from "./telemetry/events";
|
|
51
|
+
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
|
50
52
|
|
|
51
53
|
export function StudioApp() {
|
|
52
54
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
|
53
55
|
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
54
56
|
|
|
57
|
+
// Fire once per browser tab session — sessionStorage-backed so HMR
|
|
58
|
+
// remounts, route changes, and any future StudioApp remount within the
|
|
59
|
+
// same tab don't refire `studio_session_start`. `has_project` lets us
|
|
60
|
+
// tell scratch-open from project-context-open.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (resolving || waitingForServer) return;
|
|
63
|
+
if (hasFiredSessionStart()) return;
|
|
64
|
+
markSessionStartFired();
|
|
65
|
+
trackStudioSessionStart({ has_project: projectId != null });
|
|
66
|
+
}, [projectId, resolving, waitingForServer]);
|
|
67
|
+
|
|
55
68
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
56
69
|
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
|
57
70
|
() => initialUrlStateRef.current.activeCompPath == null,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface State {
|
|
9
|
+
error: Error | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class StudioErrorBoundary extends Component<Props, State> {
|
|
13
|
+
state: State = { error: null };
|
|
14
|
+
|
|
15
|
+
static getDerivedStateFromError(error: Error): State {
|
|
16
|
+
return { error };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
20
|
+
console.error("[Studio] Uncaught error:", error, info.componentStack);
|
|
21
|
+
trackStudioEvent("crash", {
|
|
22
|
+
error_message: error.message,
|
|
23
|
+
error_name: error.name,
|
|
24
|
+
stack_trace: error.stack?.slice(0, 4000) ?? null,
|
|
25
|
+
component_stack: info.componentStack?.slice(0, 2000) ?? null,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
render() {
|
|
30
|
+
if (!this.state.error) return this.props.children;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
style={{
|
|
35
|
+
position: "fixed",
|
|
36
|
+
inset: 0,
|
|
37
|
+
display: "flex",
|
|
38
|
+
flexDirection: "column",
|
|
39
|
+
alignItems: "center",
|
|
40
|
+
justifyContent: "center",
|
|
41
|
+
background: "#0a0a0a",
|
|
42
|
+
color: "#e5e5e5",
|
|
43
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
44
|
+
gap: 16,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
|
|
48
|
+
<div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
|
|
49
|
+
{this.state.error.message}
|
|
50
|
+
</div>
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => this.setState({ error: null })}
|
|
53
|
+
style={{
|
|
54
|
+
marginTop: 8,
|
|
55
|
+
padding: "8px 20px",
|
|
56
|
+
background: "#2563eb",
|
|
57
|
+
color: "#fff",
|
|
58
|
+
border: "none",
|
|
59
|
+
borderRadius: 6,
|
|
60
|
+
fontSize: 14,
|
|
61
|
+
cursor: "pointer",
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
Try again
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -8,6 +8,7 @@ import { getHistoryShortcutLabel } from "../utils/studioHelpers";
|
|
|
8
8
|
import { useStudioContext } from "../contexts/StudioContext";
|
|
9
9
|
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
10
10
|
import { useDomEditContext } from "../contexts/DomEditContext";
|
|
11
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
12
|
|
|
12
13
|
export interface StudioHeaderProps {
|
|
13
14
|
captureFrameHref: string;
|
|
@@ -165,7 +166,10 @@ export function StudioHeader({
|
|
|
165
166
|
<div className="flex items-center gap-1.5">
|
|
166
167
|
<button
|
|
167
168
|
type="button"
|
|
168
|
-
onClick={() =>
|
|
169
|
+
onClick={() => {
|
|
170
|
+
trackStudioEvent("toolbar_action", { action: "undo" });
|
|
171
|
+
void handleUndo();
|
|
172
|
+
}}
|
|
169
173
|
disabled={!editHistory.canUndo}
|
|
170
174
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
171
175
|
editHistory.canUndo
|
|
@@ -183,7 +187,10 @@ export function StudioHeader({
|
|
|
183
187
|
</button>
|
|
184
188
|
<button
|
|
185
189
|
type="button"
|
|
186
|
-
onClick={() =>
|
|
190
|
+
onClick={() => {
|
|
191
|
+
trackStudioEvent("toolbar_action", { action: "redo" });
|
|
192
|
+
void handleRedo();
|
|
193
|
+
}}
|
|
187
194
|
disabled={!editHistory.canRedo}
|
|
188
195
|
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
189
196
|
editHistory.canRedo
|
|
@@ -202,7 +209,10 @@ export function StudioHeader({
|
|
|
202
209
|
<a
|
|
203
210
|
href={captureFrameHref}
|
|
204
211
|
download={captureFrameFilename}
|
|
205
|
-
onClick={
|
|
212
|
+
onClick={(e) => {
|
|
213
|
+
trackStudioEvent("toolbar_action", { action: "capture_frame" });
|
|
214
|
+
handleCaptureFrameClick(e);
|
|
215
|
+
}}
|
|
206
216
|
onFocus={refreshCaptureFrameTime}
|
|
207
217
|
onPointerDown={refreshCaptureFrameTime}
|
|
208
218
|
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
|
@@ -217,10 +227,12 @@ export function StudioHeader({
|
|
|
217
227
|
onClick={() => {
|
|
218
228
|
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
219
229
|
if (rightCollapsed || !inspectorPanelActive) {
|
|
230
|
+
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
|
220
231
|
setRightPanelTab("design");
|
|
221
232
|
setRightCollapsed(false);
|
|
222
233
|
return;
|
|
223
234
|
}
|
|
235
|
+
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
|
|
224
236
|
clearDomSelection();
|
|
225
237
|
setRightCollapsed(true);
|
|
226
238
|
}}
|
|
@@ -74,7 +74,10 @@ function TimingSection({
|
|
|
74
74
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
75
75
|
}) {
|
|
76
76
|
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
|
77
|
-
const duration =
|
|
77
|
+
const duration =
|
|
78
|
+
Number.parseFloat(
|
|
79
|
+
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
|
80
|
+
) || 0;
|
|
78
81
|
const end = start + duration;
|
|
79
82
|
|
|
80
83
|
const commitStart = (nextValue: string) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ArrowLeft, CaretRight } from "@phosphor-icons/react";
|
|
2
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
2
3
|
|
|
3
4
|
export interface CompositionLevel {
|
|
4
5
|
/** Unique id — "master" or composition file path */
|
|
@@ -25,7 +26,13 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
25
26
|
{/* Back button — always goes to parent */}
|
|
26
27
|
<button
|
|
27
28
|
type="button"
|
|
28
|
-
onClick={() =>
|
|
29
|
+
onClick={() => {
|
|
30
|
+
trackStudioEvent("navigation", {
|
|
31
|
+
action: "back",
|
|
32
|
+
target: stack[stack.length - 2]?.label,
|
|
33
|
+
});
|
|
34
|
+
onNavigate(stack.length - 2);
|
|
35
|
+
}}
|
|
29
36
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
|
|
30
37
|
title="Back (Esc)"
|
|
31
38
|
>
|
|
@@ -43,7 +50,10 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
43
50
|
) : (
|
|
44
51
|
<button
|
|
45
52
|
type="button"
|
|
46
|
-
onClick={() =>
|
|
53
|
+
onClick={() => {
|
|
54
|
+
trackStudioEvent("navigation", { action: "breadcrumb", target: level.label });
|
|
55
|
+
onNavigate(i);
|
|
56
|
+
}}
|
|
47
57
|
className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
48
58
|
>
|
|
49
59
|
{level.label}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useRef,
|
|
5
|
+
useEffect,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
memo,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
2
10
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
11
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
4
12
|
import type { TimelineElement } from "../../player";
|
|
@@ -71,6 +79,15 @@ const MIN_TIMELINE_H = 100;
|
|
|
71
79
|
const DEFAULT_TIMELINE_H = 220;
|
|
72
80
|
const MIN_PREVIEW_H = 120;
|
|
73
81
|
|
|
82
|
+
function subscribeFullscreen(cb: () => void) {
|
|
83
|
+
document.addEventListener("fullscreenchange", cb);
|
|
84
|
+
return () => document.removeEventListener("fullscreenchange", cb);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getFullscreenElement() {
|
|
88
|
+
return document.fullscreenElement;
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
|
75
92
|
return compositionLoading;
|
|
76
93
|
}
|
|
@@ -248,9 +265,20 @@ export const NLELayout = memo(function NLELayout({
|
|
|
248
265
|
onCompositionLoadingChangeParent?.(compositionLoading);
|
|
249
266
|
}, [compositionLoading, onCompositionLoadingChangeParent]);
|
|
250
267
|
|
|
268
|
+
const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
|
|
251
269
|
const isTimelineVisible = timelineVisible ?? true;
|
|
252
270
|
const isDragging = useRef(false);
|
|
253
271
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
272
|
+
const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null;
|
|
273
|
+
|
|
274
|
+
const toggleFullscreen = useCallback(() => {
|
|
275
|
+
if (!containerRef.current) return;
|
|
276
|
+
if (document.fullscreenElement) {
|
|
277
|
+
void document.exitFullscreen();
|
|
278
|
+
} else {
|
|
279
|
+
void containerRef.current.requestFullscreen();
|
|
280
|
+
}
|
|
281
|
+
}, []);
|
|
254
282
|
|
|
255
283
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
256
284
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
@@ -312,6 +340,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
312
340
|
className="flex flex-col h-full min-h-0 bg-neutral-950"
|
|
313
341
|
onKeyDown={handleKeyDown}
|
|
314
342
|
tabIndex={-1}
|
|
343
|
+
data-studio-fullscreen-target=""
|
|
315
344
|
>
|
|
316
345
|
{/* Preview + player controls */}
|
|
317
346
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
@@ -326,20 +355,26 @@ export const NLELayout = memo(function NLELayout({
|
|
|
326
355
|
refreshKey={refreshKey}
|
|
327
356
|
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
|
328
357
|
/>
|
|
329
|
-
{previewOverlay}
|
|
358
|
+
{!isFullscreen && previewOverlay}
|
|
330
359
|
</div>
|
|
331
360
|
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
|
332
|
-
{compositionStack.length > 1 && (
|
|
361
|
+
{!isFullscreen && compositionStack.length > 1 && (
|
|
333
362
|
<CompositionBreadcrumb
|
|
334
363
|
stack={compositionStack}
|
|
335
364
|
onNavigate={handleNavigateComposition}
|
|
336
365
|
/>
|
|
337
366
|
)}
|
|
338
|
-
<PlayerControls
|
|
367
|
+
<PlayerControls
|
|
368
|
+
onTogglePlay={togglePlay}
|
|
369
|
+
onSeek={seek}
|
|
370
|
+
disabled={timelineDisabled}
|
|
371
|
+
isFullscreen={isFullscreen}
|
|
372
|
+
onToggleFullscreen={toggleFullscreen}
|
|
373
|
+
/>
|
|
339
374
|
</div>
|
|
340
375
|
</div>
|
|
341
376
|
|
|
342
|
-
{isTimelineVisible ? (
|
|
377
|
+
{!isFullscreen && isTimelineVisible ? (
|
|
343
378
|
<>
|
|
344
379
|
{/* Resize divider */}
|
|
345
380
|
<div
|
|
@@ -396,7 +431,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
396
431
|
)}
|
|
397
432
|
</div>
|
|
398
433
|
</>
|
|
399
|
-
) : onToggleTimeline ? (
|
|
434
|
+
) : !isFullscreen && onToggleTimeline ? (
|
|
400
435
|
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
|
|
401
436
|
<div className="flex h-10 items-center justify-between px-3">
|
|
402
437
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
@@ -2,6 +2,7 @@ import { memo, useState, useRef, useEffect } from "react";
|
|
|
2
2
|
import { RenderQueueItem } from "./RenderQueueItem";
|
|
3
3
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
|
4
4
|
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
|
5
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
5
6
|
|
|
6
7
|
export interface CompositionDimensions {
|
|
7
8
|
width: number;
|
|
@@ -277,6 +278,7 @@ function FormatExportButton({
|
|
|
277
278
|
</select>
|
|
278
279
|
<button
|
|
279
280
|
onClick={() => {
|
|
281
|
+
trackStudioEvent("render_start", { format, quality, resolution, fps });
|
|
280
282
|
void onStartRender(format, quality, resolution, fps);
|
|
281
283
|
}}
|
|
282
284
|
disabled={isRendering}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { trackStudioRenderStart } from "../../telemetry/events";
|
|
2
3
|
|
|
3
4
|
export interface RenderJob {
|
|
4
5
|
id: string;
|
|
@@ -90,6 +91,14 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
90
91
|
const resolution = opts.resolution;
|
|
91
92
|
const composition = opts.composition;
|
|
92
93
|
|
|
94
|
+
trackStudioRenderStart({
|
|
95
|
+
fps,
|
|
96
|
+
quality,
|
|
97
|
+
format,
|
|
98
|
+
resolution,
|
|
99
|
+
composition,
|
|
100
|
+
});
|
|
101
|
+
|
|
93
102
|
const startTime = Date.now();
|
|
94
103
|
// "auto" / undefined means "render at the composition's authored size".
|
|
95
104
|
// Omit the field entirely — sending "auto" would trip the route's
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "react";
|
|
9
9
|
import { CompositionsTab } from "./CompositionsTab";
|
|
10
10
|
import { AssetsTab } from "./AssetsTab";
|
|
11
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
11
12
|
import { BlocksTab } from "./BlocksTab";
|
|
12
13
|
import { FileTree } from "../editor/FileTree";
|
|
13
14
|
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
|
@@ -90,6 +91,7 @@ export const LeftSidebar = memo(
|
|
|
90
91
|
const selectTab = useCallback((t: SidebarTab) => {
|
|
91
92
|
setTab(t);
|
|
92
93
|
localStorage.setItem(STORAGE_KEY, t);
|
|
94
|
+
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
|
|
93
95
|
}, []);
|
|
94
96
|
|
|
95
97
|
useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
|
|
@@ -21,7 +21,7 @@ export function FileManagerProvider({
|
|
|
21
21
|
setFileTree,
|
|
22
22
|
editingPathRef,
|
|
23
23
|
projectIdRef,
|
|
24
|
-
|
|
24
|
+
saveRafRef,
|
|
25
25
|
importedFontAssetsRef,
|
|
26
26
|
readProjectFile,
|
|
27
27
|
writeProjectFile,
|
|
@@ -59,7 +59,7 @@ export function FileManagerProvider({
|
|
|
59
59
|
setFileTree,
|
|
60
60
|
editingPathRef,
|
|
61
61
|
projectIdRef,
|
|
62
|
-
|
|
62
|
+
saveRafRef,
|
|
63
63
|
importedFontAssetsRef,
|
|
64
64
|
readProjectFile,
|
|
65
65
|
writeProjectFile,
|
|
@@ -91,7 +91,7 @@ export function FileManagerProvider({
|
|
|
91
91
|
setFileTree,
|
|
92
92
|
editingPathRef,
|
|
93
93
|
projectIdRef,
|
|
94
|
-
|
|
94
|
+
saveRafRef,
|
|
95
95
|
importedFontAssetsRef,
|
|
96
96
|
readProjectFile,
|
|
97
97
|
writeProjectFile,
|
|
@@ -248,6 +248,24 @@ export function useAppHotkeys({
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
// F — toggle fullscreen preview
|
|
252
|
+
if (
|
|
253
|
+
event.key.toLowerCase() === "f" &&
|
|
254
|
+
!event.metaKey &&
|
|
255
|
+
!event.ctrlKey &&
|
|
256
|
+
!event.altKey &&
|
|
257
|
+
!event.shiftKey &&
|
|
258
|
+
!isEditableTarget(event.target)
|
|
259
|
+
) {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
if (document.fullscreenElement) {
|
|
262
|
+
void document.exitFullscreen();
|
|
263
|
+
} else {
|
|
264
|
+
document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
// Delete / Backspace — remove selected element (timeline clip or preview selection)
|
|
252
270
|
if (
|
|
253
271
|
(event.key === "Delete" || event.key === "Backspace") &&
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
4
|
-
import {
|
|
4
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
6
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
6
7
|
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
|
7
8
|
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
|
@@ -45,7 +46,7 @@ interface RecordEditInput {
|
|
|
45
46
|
|
|
46
47
|
export type PersistDomEditOperations = (
|
|
47
48
|
selection: DomEditSelection,
|
|
48
|
-
operations:
|
|
49
|
+
operations: PatchOperation[],
|
|
49
50
|
options?: {
|
|
50
51
|
label?: string;
|
|
51
52
|
coalesceKey?: string;
|
|
@@ -134,39 +135,61 @@ export function useDomEditCommits({
|
|
|
134
135
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
135
136
|
|
|
136
137
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
137
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
138
|
-
if (!response.ok) {
|
|
139
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
140
|
-
}
|
|
141
138
|
|
|
142
|
-
const
|
|
143
|
-
|
|
139
|
+
const readResponse = await fetch(
|
|
140
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
141
|
+
);
|
|
142
|
+
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
|
|
143
|
+
const readData = (await readResponse.json()) as { content?: string };
|
|
144
|
+
const originalContent = readData.content;
|
|
144
145
|
if (typeof originalContent !== "string") {
|
|
145
146
|
throw new Error(`Missing file contents for ${targetPath}`);
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
let patchedContent = originalContent;
|
|
149
|
-
for (const operation of operations) {
|
|
150
|
-
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
|
151
|
-
}
|
|
152
|
-
if (options?.prepareContent) {
|
|
153
|
-
patchedContent = options.prepareContent(patchedContent, targetPath);
|
|
154
|
-
}
|
|
155
149
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
156
150
|
|
|
157
|
-
|
|
151
|
+
const patchTarget: { id?: string | null; selector?: string; selectorIndex?: number } = {
|
|
152
|
+
id: selection.id,
|
|
153
|
+
selector: selection.selector,
|
|
154
|
+
selectorIndex: selection.selectorIndex,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const patchResponse = await fetch(
|
|
158
|
+
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
159
|
+
{
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ target: patchTarget, operations }),
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
|
|
166
|
+
|
|
167
|
+
const patchData = (await patchResponse.json()) as {
|
|
168
|
+
ok?: boolean;
|
|
169
|
+
changed?: boolean;
|
|
170
|
+
content?: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (!patchData.changed) {
|
|
158
174
|
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
159
175
|
}
|
|
160
176
|
|
|
161
|
-
|
|
162
|
-
|
|
177
|
+
const patchedContent =
|
|
178
|
+
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
179
|
+
|
|
180
|
+
let finalContent = patchedContent;
|
|
181
|
+
if (options?.prepareContent) {
|
|
182
|
+
finalContent = options.prepareContent(patchedContent, targetPath);
|
|
183
|
+
if (finalContent !== patchedContent) {
|
|
184
|
+
await writeProjectFile(targetPath, finalContent);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await editHistory.recordEdit({
|
|
163
189
|
label: options?.label ?? "Edit layer",
|
|
164
190
|
kind: "manual",
|
|
165
191
|
coalesceKey: options?.coalesceKey,
|
|
166
|
-
files: { [targetPath]:
|
|
167
|
-
readFile: async () => originalContent,
|
|
168
|
-
writeFile: writeProjectFile,
|
|
169
|
-
recordEdit: editHistory.recordEdit,
|
|
192
|
+
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
170
193
|
});
|
|
171
194
|
|
|
172
195
|
if (options?.skipRefresh) {
|
|
@@ -177,7 +200,7 @@ export function useDomEditCommits({
|
|
|
177
200
|
},
|
|
178
201
|
[
|
|
179
202
|
activeCompPath,
|
|
180
|
-
editHistory
|
|
203
|
+
editHistory,
|
|
181
204
|
writeProjectFile,
|
|
182
205
|
projectIdRef,
|
|
183
206
|
domEditSaveTimestampRef,
|
|
@@ -212,7 +235,7 @@ export function useDomEditCommits({
|
|
|
212
235
|
const commitPositionPatchToHtml = useCallback(
|
|
213
236
|
(
|
|
214
237
|
selection: DomEditSelection,
|
|
215
|
-
patches:
|
|
238
|
+
patches: PatchOperation[],
|
|
216
239
|
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
|
217
240
|
) => {
|
|
218
241
|
void queueDomEditSave(async () => {
|
|
@@ -224,6 +247,11 @@ export function useDomEditCommits({
|
|
|
224
247
|
}).catch((error) => {
|
|
225
248
|
const message = error instanceof Error ? error.message : "Failed to save position";
|
|
226
249
|
showToast(message);
|
|
250
|
+
trackStudioEvent("save_failure", {
|
|
251
|
+
source: "dom_edit",
|
|
252
|
+
label: options.label,
|
|
253
|
+
error_message: message,
|
|
254
|
+
});
|
|
227
255
|
});
|
|
228
256
|
},
|
|
229
257
|
[persistDomEditOperations, queueDomEditSave, showToast],
|
|
@@ -5,6 +5,7 @@ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/e
|
|
|
5
5
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
6
6
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
7
7
|
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
|
8
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
9
|
|
|
9
10
|
// ── Types ──
|
|
10
11
|
|
|
@@ -48,8 +49,8 @@ export function useFileManager({
|
|
|
48
49
|
const projectIdRef = useRef(projectId);
|
|
49
50
|
projectIdRef.current = projectId;
|
|
50
51
|
|
|
51
|
-
const
|
|
52
|
-
const
|
|
52
|
+
const saveRafRef = useRef<number | null>(null);
|
|
53
|
+
const refreshRafRef = useRef<number | null>(null);
|
|
53
54
|
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
54
55
|
|
|
55
56
|
// ── Load file tree when projectId changes ──
|
|
@@ -145,12 +146,8 @@ export function useFileManager({
|
|
|
145
146
|
const path = editingPathRef.current;
|
|
146
147
|
if (!path) return;
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
saveTimerRef.current = setTimeout(() => {
|
|
151
|
-
// Suppress the file-change watcher echo — the save callback triggers
|
|
152
|
-
// its own refresh, so a second one from the watcher causes a double-reload
|
|
153
|
-
// race that can leave the player in a non-playable state.
|
|
149
|
+
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
|
150
|
+
saveRafRef.current = requestAnimationFrame(() => {
|
|
154
151
|
domEditSaveTimestampRef.current = Date.now();
|
|
155
152
|
saveProjectFilesWithHistory({
|
|
156
153
|
projectId: pid,
|
|
@@ -163,11 +160,16 @@ export function useFileManager({
|
|
|
163
160
|
recordEdit,
|
|
164
161
|
})
|
|
165
162
|
.then(() => {
|
|
166
|
-
if (
|
|
167
|
-
|
|
163
|
+
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
164
|
+
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
168
165
|
})
|
|
169
|
-
.catch(() => {
|
|
170
|
-
|
|
166
|
+
.catch((error) => {
|
|
167
|
+
trackStudioEvent("save_failure", {
|
|
168
|
+
source: "code_editor",
|
|
169
|
+
error_message: error instanceof Error ? error.message : "unknown",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
171
173
|
},
|
|
172
174
|
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
|
|
173
175
|
);
|
|
@@ -449,7 +451,7 @@ export function useFileManager({
|
|
|
449
451
|
// Refs
|
|
450
452
|
editingPathRef,
|
|
451
453
|
projectIdRef,
|
|
452
|
-
|
|
454
|
+
saveRafRef,
|
|
453
455
|
importedFontAssetsRef,
|
|
454
456
|
|
|
455
457
|
// Core I/O
|