@hyperframes/studio 0.6.28 → 0.6.30
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-D790O3az.js +115 -0
- package/dist/assets/index-DSLrl2tB.js +531 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +13 -0
- package/src/components/StudioErrorBoundary.tsx +68 -0
- package/src/components/StudioHeader.tsx +15 -3
- package/src/components/editor/PropertyPanel.tsx +4 -1
- package/src/components/editor/domEditingLayers.ts +15 -4
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
- 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/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 +22 -1
- package/src/player/components/CompositionThumbnail.tsx +10 -44
- package/src/player/components/PlayerControls.tsx +16 -3
- package/src/player/components/TimelineCanvas.tsx +9 -23
- package/src/player/components/TimelineClip.tsx +63 -67
- package/src/player/components/timelineEditing.test.ts +16 -0
- package/src/player/components/timelineEditing.ts +2 -1
- 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/player/lib/timelineDOM.ts +4 -0
- package/src/player/store/playerStore.ts +2 -0
- 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-DVpLGNHi.css +0 -1
- package/dist/assets/index-EdfhuQ5T.js +0 -362
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-D790O3az.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.30",
|
|
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/core": "0.6.
|
|
35
|
-
"@hyperframes/player": "0.6.
|
|
34
|
+
"@hyperframes/core": "0.6.30",
|
|
35
|
+
"@hyperframes/player": "0.6.30"
|
|
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.30"
|
|
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,68 @@
|
|
|
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
|
+
component_stack: info.componentStack?.slice(0, 500) ?? null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
if (!this.state.error) return this.props.children;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
style={{
|
|
34
|
+
position: "fixed",
|
|
35
|
+
inset: 0,
|
|
36
|
+
display: "flex",
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
alignItems: "center",
|
|
39
|
+
justifyContent: "center",
|
|
40
|
+
background: "#0a0a0a",
|
|
41
|
+
color: "#e5e5e5",
|
|
42
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
43
|
+
gap: 16,
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
|
|
47
|
+
<div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
|
|
48
|
+
{this.state.error.message}
|
|
49
|
+
</div>
|
|
50
|
+
<button
|
|
51
|
+
onClick={() => this.setState({ error: null })}
|
|
52
|
+
style={{
|
|
53
|
+
marginTop: 8,
|
|
54
|
+
padding: "8px 20px",
|
|
55
|
+
background: "#2563eb",
|
|
56
|
+
color: "#fff",
|
|
57
|
+
border: "none",
|
|
58
|
+
borderRadius: 6,
|
|
59
|
+
fontSize: 14,
|
|
60
|
+
cursor: "pointer",
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
Try again
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -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) => {
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from "./domEditingTypes";
|
|
13
13
|
import {
|
|
14
14
|
buildStableSelector,
|
|
15
|
+
findClosestByAttribute,
|
|
15
16
|
getCuratedComputedStyles,
|
|
16
17
|
getDataAttributes,
|
|
17
18
|
getInlineStyles,
|
|
@@ -175,18 +176,21 @@ export function resolveDomEditCapabilities(args: {
|
|
|
175
176
|
inlineStyles: Record<string, string>;
|
|
176
177
|
computedStyles: Record<string, string>;
|
|
177
178
|
isCompositionHost: boolean;
|
|
179
|
+
isInsideLockedComposition: boolean;
|
|
178
180
|
isMasterView: boolean;
|
|
179
181
|
}): DomEditCapabilities {
|
|
180
|
-
if (!args.selector) {
|
|
182
|
+
if (!args.selector || args.isInsideLockedComposition) {
|
|
181
183
|
return {
|
|
182
|
-
canSelect:
|
|
184
|
+
canSelect: !args.isInsideLockedComposition,
|
|
183
185
|
canEditStyles: false,
|
|
184
186
|
canMove: false,
|
|
185
187
|
canResize: false,
|
|
186
188
|
canApplyManualOffset: false,
|
|
187
189
|
canApplyManualSize: false,
|
|
188
190
|
canApplyManualRotation: false,
|
|
189
|
-
reasonIfDisabled:
|
|
191
|
+
reasonIfDisabled: args.isInsideLockedComposition
|
|
192
|
+
? "This element belongs to a locked composition."
|
|
193
|
+
: "Studio could not resolve a stable patch target for this element.",
|
|
190
194
|
};
|
|
191
195
|
}
|
|
192
196
|
|
|
@@ -298,6 +302,7 @@ export function resolveDomEditSelection(
|
|
|
298
302
|
const inlineStyles = getInlineStyles(current);
|
|
299
303
|
const computedStyles = getCuratedComputedStyles(current);
|
|
300
304
|
const textFields = collectDomEditTextFields(current);
|
|
305
|
+
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
|
|
301
306
|
const capabilities = resolveDomEditCapabilities({
|
|
302
307
|
selector,
|
|
303
308
|
tagName: current.tagName.toLowerCase(),
|
|
@@ -305,6 +310,7 @@ export function resolveDomEditSelection(
|
|
|
305
310
|
inlineStyles,
|
|
306
311
|
computedStyles,
|
|
307
312
|
isCompositionHost: Boolean(compositionSrc),
|
|
313
|
+
isInsideLockedComposition: isInsideLocked,
|
|
308
314
|
isMasterView: options.isMasterView,
|
|
309
315
|
});
|
|
310
316
|
const rect = current.getBoundingClientRect();
|
|
@@ -318,6 +324,7 @@ export function resolveDomEditSelection(
|
|
|
318
324
|
compositionPath,
|
|
319
325
|
compositionSrc,
|
|
320
326
|
isCompositionHost: Boolean(compositionSrc),
|
|
327
|
+
isInsideLockedComposition: isInsideLocked,
|
|
321
328
|
label: buildElementLabel(current),
|
|
322
329
|
tagName: current.tagName.toLowerCase(),
|
|
323
330
|
boundingBox: {
|
|
@@ -488,7 +495,11 @@ export function getDomEditTargetKey(
|
|
|
488
495
|
}
|
|
489
496
|
|
|
490
497
|
export function isTextEditableSelection(selection: DomEditSelection): boolean {
|
|
491
|
-
return
|
|
498
|
+
return (
|
|
499
|
+
selection.textFields.length > 0 &&
|
|
500
|
+
!selection.isCompositionHost &&
|
|
501
|
+
!selection.isInsideLockedComposition
|
|
502
|
+
);
|
|
492
503
|
}
|
|
493
504
|
|
|
494
505
|
// buildElementAgentPrompt is in domEditingAgentPrompt.ts
|
|
@@ -78,6 +78,7 @@ export interface DomEditSelection extends PatchTarget {
|
|
|
78
78
|
compositionPath: string;
|
|
79
79
|
compositionSrc?: string;
|
|
80
80
|
isCompositionHost: boolean;
|
|
81
|
+
isInsideLockedComposition: boolean;
|
|
81
82
|
boundingBox: { x: number; y: number; width: number; height: number };
|
|
82
83
|
textContent: string | null;
|
|
83
84
|
dataAttributes: Record<string, 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}
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
3
3
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
4
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
5
|
|
|
5
6
|
export interface InitialPanelLayoutState {
|
|
6
7
|
rightCollapsed?: boolean | null;
|
|
@@ -26,6 +27,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
26
27
|
const toggleLeftSidebar = useCallback(() => {
|
|
27
28
|
setLeftCollapsed((collapsed) => {
|
|
28
29
|
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
|
30
|
+
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
|
|
29
31
|
return !collapsed;
|
|
30
32
|
});
|
|
31
33
|
}, []);
|
|
@@ -63,6 +65,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
63
65
|
panelDragRef.current = null;
|
|
64
66
|
}, []);
|
|
65
67
|
|
|
68
|
+
const trackedSetRightPanelTab = useCallback(
|
|
69
|
+
(tab: RightPanelTab) => {
|
|
70
|
+
setRightPanelTab(tab);
|
|
71
|
+
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
72
|
+
},
|
|
73
|
+
[setRightPanelTab],
|
|
74
|
+
);
|
|
75
|
+
|
|
66
76
|
return {
|
|
67
77
|
leftWidth,
|
|
68
78
|
setLeftWidth,
|
|
@@ -72,7 +82,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
72
82
|
rightCollapsed,
|
|
73
83
|
setRightCollapsed,
|
|
74
84
|
rightPanelTab,
|
|
75
|
-
setRightPanelTab,
|
|
85
|
+
setRightPanelTab: trackedSetRightPanelTab,
|
|
76
86
|
toggleLeftSidebar,
|
|
77
87
|
handlePanelResizeStart,
|
|
78
88
|
handlePanelResizeMove,
|