@hyperframes/studio 0.6.0-alpha.9 → 0.6.0
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-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-DUqUmaoH.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +428 -4299
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/nle/NLELayout.tsx +8 -11
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +10 -103
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
package/src/App.tsx
CHANGED
|
@@ -1,3891 +1,360 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useState,
|
|
3
|
-
useCallback,
|
|
4
|
-
useRef,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
type CSSProperties,
|
|
8
|
-
type MouseEvent,
|
|
9
|
-
type ReactNode,
|
|
10
|
-
} from "react";
|
|
1
|
+
import { useState, useCallback, useRef, useMemo } from "react";
|
|
11
2
|
import { useMountEffect } from "./hooks/useMountEffect";
|
|
12
|
-
import {
|
|
13
|
-
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
14
|
-
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
15
|
-
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
3
|
+
import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
|
|
16
4
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} from "./utils/
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
} from "./
|
|
49
|
-
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} from "./
|
|
53
|
-
import {
|
|
54
|
-
getNextTimelineZoomPercent,
|
|
55
|
-
getTimelineZoomPercent,
|
|
56
|
-
} from "./player/components/timelineZoom";
|
|
57
|
-
import {
|
|
58
|
-
getTimelineToggleTitle,
|
|
59
|
-
shouldHandleTimelineToggleHotkey,
|
|
60
|
-
} from "./utils/timelineDiscovery";
|
|
61
|
-
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
|
62
|
-
import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
|
|
63
|
-
import { Camera } from "./icons/SystemIcons";
|
|
64
|
-
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
65
|
-
import { MotionPanel } from "./components/editor/MotionPanel";
|
|
66
|
-
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
67
|
-
import {
|
|
68
|
-
fontFamilyFromAssetPath,
|
|
69
|
-
importedFontFaceCss,
|
|
70
|
-
type ImportedFontAsset,
|
|
71
|
-
} from "./components/editor/fontAssets";
|
|
72
|
-
import {
|
|
73
|
-
DomEditOverlay,
|
|
74
|
-
type DomEditGroupPathOffsetCommit,
|
|
75
|
-
} from "./components/editor/DomEditOverlay";
|
|
76
|
-
import { TimelineLayerPanel } from "./components/editor/TimelineLayerPanel";
|
|
77
|
-
import {
|
|
78
|
-
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
79
|
-
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
|
|
80
|
-
STUDIO_MOTION_PANEL_ENABLED,
|
|
81
|
-
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
|
|
82
|
-
STUDIO_PREVIEW_SELECTION_ENABLED,
|
|
83
|
-
STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
|
|
84
|
-
} from "./components/editor/manualEditingAvailability";
|
|
85
|
-
import {
|
|
86
|
-
buildDomEditStylePatchOperation,
|
|
87
|
-
buildDomEditTextPatchOperation,
|
|
88
|
-
buildElementAgentPrompt,
|
|
89
|
-
collectDomEditLayerItems,
|
|
90
|
-
countDomEditChildLayers,
|
|
91
|
-
findElementForSelection,
|
|
92
|
-
findElementForTimelineElement,
|
|
93
|
-
getDomEditLayerKey,
|
|
94
|
-
getDomEditTargetKey,
|
|
95
|
-
isLargeRasterDomEditSelection,
|
|
96
|
-
isTextEditableSelection,
|
|
97
|
-
resolveVisualDomEditSelectionTarget,
|
|
98
|
-
serializeDomEditTextFields,
|
|
99
|
-
resolveDomEditSelection,
|
|
100
|
-
type DomEditViewport,
|
|
101
|
-
type DomEditLayerItem,
|
|
102
|
-
type DomEditTextField,
|
|
103
|
-
type DomEditSelection,
|
|
104
|
-
buildDefaultDomEditTextField,
|
|
105
|
-
} from "./components/editor/domEditing";
|
|
106
|
-
import {
|
|
107
|
-
STUDIO_MANUAL_EDITS_PATH,
|
|
108
|
-
applyStudioManualEditManifest,
|
|
109
|
-
emptyStudioManualEditManifest,
|
|
110
|
-
installStudioManualEditSeekReapply,
|
|
111
|
-
isStudioManualEditManifestPath,
|
|
112
|
-
parseStudioManualEditManifest,
|
|
113
|
-
readStudioFileChangePath,
|
|
114
|
-
removeStudioManualEditsForSelection,
|
|
115
|
-
serializeStudioManualEditManifest,
|
|
116
|
-
type StudioManualEditManifest,
|
|
117
|
-
upsertStudioBoxSizeEdit,
|
|
118
|
-
upsertStudioPathOffsetEdit,
|
|
119
|
-
upsertStudioRotationEdit,
|
|
120
|
-
} from "./components/editor/manualEdits";
|
|
121
|
-
import {
|
|
122
|
-
STUDIO_MOTION_PATH,
|
|
123
|
-
applyStudioMotionManifest,
|
|
124
|
-
emptyStudioMotionManifest,
|
|
125
|
-
getStudioMotionForSelection,
|
|
126
|
-
installStudioMotionSeekReapply,
|
|
127
|
-
isStudioMotionManifestPath,
|
|
128
|
-
parseStudioMotionManifest,
|
|
129
|
-
removeStudioMotionForSelection,
|
|
130
|
-
serializeStudioMotionManifest,
|
|
131
|
-
type StudioGsapMotion,
|
|
132
|
-
type StudioMotionManifest,
|
|
133
|
-
upsertStudioGsapMotion,
|
|
134
|
-
} from "./components/editor/studioMotion";
|
|
135
|
-
import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
|
|
136
|
-
import {
|
|
137
|
-
canInspectTimelineElement,
|
|
138
|
-
getTimelineElementKey,
|
|
139
|
-
getTimelineLayerVisibilityInPreview,
|
|
140
|
-
isTimelineElementActiveAtTime,
|
|
141
|
-
isTimelineLayerVisibleInPreview,
|
|
142
|
-
shouldShowTimelineInspectorBounds,
|
|
143
|
-
} from "./utils/timelineInspector";
|
|
144
|
-
|
|
145
|
-
interface EditingFile {
|
|
146
|
-
path: string;
|
|
147
|
-
content: string | null;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
interface AppToast {
|
|
151
|
-
message: string;
|
|
152
|
-
tone: "error" | "info";
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function getTimelineElementLabel(element: TimelineElement): string {
|
|
156
|
-
return element.label || element.id || element.tag;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
type RightPanelTab = "design" | "motion" | "renders";
|
|
160
|
-
|
|
161
|
-
const GENERIC_FONT_FAMILIES = new Set([
|
|
162
|
-
"inherit",
|
|
163
|
-
"initial",
|
|
164
|
-
"revert",
|
|
165
|
-
"revert-layer",
|
|
166
|
-
"serif",
|
|
167
|
-
"sans-serif",
|
|
168
|
-
"monospace",
|
|
169
|
-
"cursive",
|
|
170
|
-
"fantasy",
|
|
171
|
-
"system-ui",
|
|
172
|
-
"ui-sans-serif",
|
|
173
|
-
"ui-serif",
|
|
174
|
-
"ui-monospace",
|
|
175
|
-
"ui-rounded",
|
|
176
|
-
"emoji",
|
|
177
|
-
"math",
|
|
178
|
-
"fangsong",
|
|
179
|
-
]);
|
|
180
|
-
|
|
181
|
-
function primaryFontFamilyFromCss(value: string): string {
|
|
182
|
-
const first = value.split(",")[0] ?? "";
|
|
183
|
-
return first.trim().replace(/^["']|["']$/g, "");
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
|
187
|
-
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
|
188
|
-
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
|
189
|
-
|
|
190
|
-
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
191
|
-
if (doc.getElementById(id)) return;
|
|
192
|
-
|
|
193
|
-
const link = doc.createElement("link");
|
|
194
|
-
link.id = id;
|
|
195
|
-
link.rel = "stylesheet";
|
|
196
|
-
link.href = googleFontStylesheetUrl(family);
|
|
197
|
-
doc.head.appendChild(link);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function primaryFontFamilyValue(value: string): string {
|
|
201
|
-
return (
|
|
202
|
-
value
|
|
203
|
-
.split(",")[0]
|
|
204
|
-
?.trim()
|
|
205
|
-
.replace(/^["']|["']$/g, "")
|
|
206
|
-
.trim() ?? ""
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
|
211
|
-
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
212
|
-
if (doc.getElementById(id)) return;
|
|
213
|
-
const style = doc.createElement("style");
|
|
214
|
-
style.id = id;
|
|
215
|
-
style.textContent = importedFontFaceCss(asset);
|
|
216
|
-
doc.head.appendChild(style);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function normalizeProjectAssetPath(value: string): string {
|
|
220
|
-
const trimmed = value.trim();
|
|
221
|
-
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
222
|
-
return decodeURIComponent(maybeUrl)
|
|
223
|
-
.replace(/\\/g, "/")
|
|
224
|
-
.replace(/^\.?\//, "");
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
228
|
-
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
|
229
|
-
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
|
230
|
-
|
|
231
|
-
fromParts.pop();
|
|
232
|
-
|
|
233
|
-
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
234
|
-
fromParts.shift();
|
|
235
|
-
targetParts.shift();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function isAbsoluteFilePath(value: string): boolean {
|
|
242
|
-
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
|
|
246
|
-
const trimmedSource = sourceFile.trim();
|
|
247
|
-
if (!trimmedSource) return undefined;
|
|
248
|
-
|
|
249
|
-
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
250
|
-
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
251
|
-
|
|
252
|
-
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
253
|
-
if (!normalizedRoot) return undefined;
|
|
254
|
-
|
|
255
|
-
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function ensureImportedFontFace(
|
|
259
|
-
html: string,
|
|
260
|
-
asset: ImportedFontAsset,
|
|
261
|
-
sourceFile: string,
|
|
262
|
-
): string {
|
|
263
|
-
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
264
|
-
if (html.includes(css)) return html;
|
|
265
|
-
|
|
266
|
-
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
267
|
-
const styleMatch = styleRe.exec(html);
|
|
268
|
-
if (styleMatch) {
|
|
269
|
-
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
270
|
-
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
274
|
-
if (/<\/head>/i.test(html)) {
|
|
275
|
-
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
|
276
|
-
}
|
|
277
|
-
return `${styleTag}\n${html}`;
|
|
278
|
-
}
|
|
279
|
-
function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
280
|
-
const trimmed = value.trim();
|
|
281
|
-
if (!trimmed) return trimmed;
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
|
|
285
|
-
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
286
|
-
) {
|
|
287
|
-
return `${trimmed}px`;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return trimmed;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function isImageBackgroundValue(value: string): boolean {
|
|
294
|
-
return /^url\(/i.test(value.trim());
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
298
|
-
if (!target || typeof target !== "object") return null;
|
|
299
|
-
const maybeNode = target as {
|
|
300
|
-
nodeType?: number;
|
|
301
|
-
parentElement?: Element | null;
|
|
302
|
-
};
|
|
303
|
-
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
|
304
|
-
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
|
305
|
-
return maybeNode.parentElement as HTMLElement;
|
|
306
|
-
}
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
|
311
|
-
const el = getEventTargetElement(target);
|
|
312
|
-
if (!el) return false;
|
|
313
|
-
return Boolean(
|
|
314
|
-
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
319
|
-
const isMac =
|
|
320
|
-
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
321
|
-
const modifier = isMac ? "Cmd" : "Ctrl";
|
|
322
|
-
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function findMatchingTimelineElementId(
|
|
326
|
-
selection: Pick<
|
|
327
|
-
DomEditSelection,
|
|
328
|
-
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
329
|
-
>,
|
|
330
|
-
elements: TimelineElement[],
|
|
331
|
-
): string | null {
|
|
332
|
-
const selectionSourceFile = selection.sourceFile || "index.html";
|
|
333
|
-
for (const element of elements) {
|
|
334
|
-
const elementSourceFile = element.sourceFile || "index.html";
|
|
335
|
-
if (
|
|
336
|
-
selection.id &&
|
|
337
|
-
element.domId === selection.id &&
|
|
338
|
-
elementSourceFile === selectionSourceFile
|
|
339
|
-
) {
|
|
340
|
-
return element.key ?? element.id;
|
|
341
|
-
}
|
|
342
|
-
if (
|
|
343
|
-
selection.isCompositionHost &&
|
|
344
|
-
selection.compositionSrc &&
|
|
345
|
-
element.compositionSrc === selection.compositionSrc
|
|
346
|
-
) {
|
|
347
|
-
return element.key ?? element.id;
|
|
348
|
-
}
|
|
349
|
-
if (
|
|
350
|
-
selection.selector &&
|
|
351
|
-
element.selector === selection.selector &&
|
|
352
|
-
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
353
|
-
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
354
|
-
) {
|
|
355
|
-
return element.key ?? element.id;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return null;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function isManualGeometryStyleProperty(property: string): boolean {
|
|
363
|
-
return property === "left" || property === "top" || property === "width" || property === "height";
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
interface PreviewLocalPointer {
|
|
367
|
-
x: number;
|
|
368
|
-
y: number;
|
|
369
|
-
viewport: DomEditViewport;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
interface AgentModalAnchorPoint {
|
|
373
|
-
x: number;
|
|
374
|
-
y: number;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function resolvePreviewLocalPointer(
|
|
378
|
-
iframe: HTMLIFrameElement,
|
|
379
|
-
doc: Document,
|
|
380
|
-
win: Window,
|
|
381
|
-
clientX: number,
|
|
382
|
-
clientY: number,
|
|
383
|
-
): PreviewLocalPointer | null {
|
|
384
|
-
const iframeRect = iframe.getBoundingClientRect();
|
|
385
|
-
const root =
|
|
386
|
-
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
|
387
|
-
const rootRect = root?.getBoundingClientRect();
|
|
388
|
-
const rootWidth = rootRect?.width || win.innerWidth;
|
|
389
|
-
const rootHeight = rootRect?.height || win.innerHeight;
|
|
390
|
-
if (!rootWidth || !rootHeight) return null;
|
|
391
|
-
|
|
392
|
-
const scaleX = iframeRect.width / rootWidth;
|
|
393
|
-
const scaleY = iframeRect.height / rootHeight;
|
|
394
|
-
return {
|
|
395
|
-
x: (clientX - iframeRect.left) / scaleX,
|
|
396
|
-
y: (clientY - iframeRect.top) / scaleY,
|
|
397
|
-
viewport: { width: rootWidth, height: rootHeight },
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function getPreviewLocalPointer(
|
|
402
|
-
iframe: HTMLIFrameElement,
|
|
403
|
-
clientX: number,
|
|
404
|
-
clientY: number,
|
|
405
|
-
): PreviewLocalPointer | null {
|
|
406
|
-
let doc: Document | null = null;
|
|
407
|
-
let win: Window | null = null;
|
|
408
|
-
try {
|
|
409
|
-
doc = iframe.contentDocument;
|
|
410
|
-
win = iframe.contentWindow;
|
|
411
|
-
} catch {
|
|
412
|
-
return null;
|
|
413
|
-
}
|
|
414
|
-
if (!doc || !win) return null;
|
|
415
|
-
|
|
416
|
-
return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function getPreviewTargetFromPointer(
|
|
420
|
-
iframe: HTMLIFrameElement,
|
|
421
|
-
clientX: number,
|
|
422
|
-
clientY: number,
|
|
423
|
-
activeCompositionPath: string | null,
|
|
424
|
-
): HTMLElement | null {
|
|
425
|
-
let doc: Document | null = null;
|
|
426
|
-
let win: Window | null = null;
|
|
427
|
-
try {
|
|
428
|
-
doc = iframe.contentDocument;
|
|
429
|
-
win = iframe.contentWindow;
|
|
430
|
-
} catch {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
if (!doc || !win) return null;
|
|
434
|
-
|
|
435
|
-
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
436
|
-
if (!localPointer) return null;
|
|
437
|
-
|
|
438
|
-
if (typeof doc.elementsFromPoint === "function") {
|
|
439
|
-
const visualTarget = resolveVisualDomEditSelectionTarget(
|
|
440
|
-
doc.elementsFromPoint(localPointer.x, localPointer.y),
|
|
441
|
-
{
|
|
442
|
-
activeCompositionPath,
|
|
443
|
-
},
|
|
444
|
-
);
|
|
445
|
-
if (visualTarget) return visualTarget;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function buildRasterClickSelectionContext(
|
|
452
|
-
selection: DomEditSelection,
|
|
453
|
-
localPointer: PreviewLocalPointer,
|
|
454
|
-
): string {
|
|
455
|
-
return [
|
|
456
|
-
"The user clicked a large raster/background element in the Studio preview.",
|
|
457
|
-
`Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
|
|
458
|
-
localPointer.viewport.width,
|
|
459
|
-
)}x${Math.round(localPointer.viewport.height)} composition.`,
|
|
460
|
-
`Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
|
|
461
|
-
"Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
|
|
462
|
-
"If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
|
|
463
|
-
].join("\n");
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function domEditSelectionsTargetSame(
|
|
467
|
-
a: DomEditSelection | null,
|
|
468
|
-
b: DomEditSelection | null,
|
|
469
|
-
): boolean {
|
|
470
|
-
if (a === b) return true;
|
|
471
|
-
if (!a || !b) return false;
|
|
472
|
-
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function domEditSelectionInGroup(
|
|
476
|
-
group: DomEditSelection[],
|
|
477
|
-
selection: DomEditSelection | null,
|
|
478
|
-
): boolean {
|
|
479
|
-
if (!selection) return false;
|
|
480
|
-
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function toggleDomEditGroupSelection(
|
|
484
|
-
group: DomEditSelection[],
|
|
485
|
-
selection: DomEditSelection,
|
|
486
|
-
): DomEditSelection[] {
|
|
487
|
-
if (domEditSelectionInGroup(group, selection)) {
|
|
488
|
-
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
|
|
489
|
-
}
|
|
490
|
-
return [...group, selection];
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function replaceDomEditGroupSelection(
|
|
494
|
-
group: DomEditSelection[],
|
|
495
|
-
selection: DomEditSelection,
|
|
496
|
-
): DomEditSelection[] {
|
|
497
|
-
let replaced = false;
|
|
498
|
-
const nextGroup = group.map((entry) => {
|
|
499
|
-
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
|
|
500
|
-
replaced = true;
|
|
501
|
-
return selection;
|
|
502
|
-
});
|
|
503
|
-
return replaced ? nextGroup : [...group, selection];
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function seedDomEditGroupWithSelection(
|
|
507
|
-
group: DomEditSelection[],
|
|
508
|
-
selection: DomEditSelection | null,
|
|
509
|
-
): DomEditSelection[] {
|
|
510
|
-
if (!selection || domEditSelectionInGroup(group, selection)) return group;
|
|
511
|
-
return [selection, ...group];
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function objectLike(value: unknown): object | null {
|
|
515
|
-
return value && (typeof value === "object" || typeof value === "function") ? value : null;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function callPlaybackMethod(target: object | null, key: string): void {
|
|
519
|
-
const method = target ? Reflect.get(target, key) : null;
|
|
520
|
-
if (typeof method !== "function") return;
|
|
521
|
-
try {
|
|
522
|
-
method.call(target);
|
|
523
|
-
} catch {
|
|
524
|
-
// Best-effort playback freeze; drag should still work if playback control is unavailable.
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function readPlaybackTime(target: object | null, key: string): number | null {
|
|
529
|
-
const method = target ? Reflect.get(target, key) : null;
|
|
530
|
-
if (typeof method !== "function") return null;
|
|
531
|
-
try {
|
|
532
|
-
const value = method.call(target);
|
|
533
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
534
|
-
} catch {
|
|
535
|
-
return null;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
interface PreviewPlayerCompat {
|
|
540
|
-
getTime: () => number;
|
|
541
|
-
renderSeek: (timeSeconds: number) => void;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
|
|
545
|
-
const player = objectLike(win ? Reflect.get(win, "__player") : null);
|
|
546
|
-
if (!player) return null;
|
|
547
|
-
const getTime = Reflect.get(player, "getTime");
|
|
548
|
-
const renderSeek = Reflect.get(player, "renderSeek");
|
|
549
|
-
if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
|
|
550
|
-
return {
|
|
551
|
-
getTime: () => {
|
|
552
|
-
const value = getTime.call(player);
|
|
553
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
554
|
-
},
|
|
555
|
-
renderSeek: (timeSeconds: number) => {
|
|
556
|
-
renderSeek.call(player, timeSeconds);
|
|
557
|
-
},
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
|
|
562
|
-
const player = getPreviewPlayer(iframe?.contentWindow);
|
|
563
|
-
if (!player) return false;
|
|
564
|
-
const nextTime = Math.max(0, timeSeconds);
|
|
565
|
-
player.renderSeek(nextTime);
|
|
566
|
-
usePlayerStore.getState().setCurrentTime(nextTime);
|
|
567
|
-
liveTime.notify(nextTime);
|
|
568
|
-
return true;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function parseFiniteSeconds(value: string | null): number | null {
|
|
572
|
-
if (value == null || value.trim() === "") return null;
|
|
573
|
-
const parsed = Number.parseFloat(value);
|
|
574
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function resolveLayerVisibleSeekTime(
|
|
578
|
-
layerElement: HTMLElement,
|
|
579
|
-
timelineElement: TimelineElement | null,
|
|
580
|
-
player: PreviewPlayerCompat | null,
|
|
581
|
-
): number | null {
|
|
582
|
-
if (!timelineElement || !player) return null;
|
|
583
|
-
const originalTime = player.getTime();
|
|
584
|
-
|
|
585
|
-
const clipStart = Math.max(0, timelineElement.start);
|
|
586
|
-
const clipEnd = Math.max(clipStart, clipStart + Math.max(0, timelineElement.duration));
|
|
587
|
-
const authoredStart = parseFiniteSeconds(
|
|
588
|
-
layerElement.getAttribute("data-start") ??
|
|
589
|
-
layerElement.closest<HTMLElement>("[data-start]")?.getAttribute("data-start") ??
|
|
590
|
-
null,
|
|
591
|
-
);
|
|
592
|
-
const preferredTime =
|
|
593
|
-
authoredStart == null
|
|
594
|
-
? clipStart
|
|
595
|
-
: Math.min(clipEnd, Math.max(clipStart, clipStart + authoredStart));
|
|
596
|
-
const candidates = [preferredTime, clipStart];
|
|
597
|
-
const duration = clipEnd - clipStart;
|
|
598
|
-
if (duration > 0) {
|
|
599
|
-
const maxSamples = 24;
|
|
600
|
-
const frameStep = 1 / 24;
|
|
601
|
-
const step = Math.max(frameStep, duration / maxSamples);
|
|
602
|
-
for (let time = clipStart; time <= clipEnd + 0.0001; time += step) {
|
|
603
|
-
candidates.push(Math.min(clipEnd, time));
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
candidates.push(clipEnd);
|
|
607
|
-
|
|
608
|
-
let lastTried = preferredTime;
|
|
609
|
-
let clearestVisibleTime: number | null = null;
|
|
610
|
-
let clearestVisibleOpacity = 0;
|
|
611
|
-
let resolvedTime: number | null = null;
|
|
612
|
-
const seen = new Set<string>();
|
|
613
|
-
try {
|
|
614
|
-
for (const candidate of candidates) {
|
|
615
|
-
const time = Math.min(clipEnd, Math.max(clipStart, candidate));
|
|
616
|
-
const key = time.toFixed(4);
|
|
617
|
-
if (seen.has(key)) continue;
|
|
618
|
-
seen.add(key);
|
|
619
|
-
lastTried = time;
|
|
620
|
-
player.renderSeek(time);
|
|
621
|
-
const visibility = getTimelineLayerVisibilityInPreview(layerElement);
|
|
622
|
-
if (visibility.visible && visibility.compositeOpacity > clearestVisibleOpacity) {
|
|
623
|
-
clearestVisibleTime = time;
|
|
624
|
-
clearestVisibleOpacity = visibility.compositeOpacity;
|
|
625
|
-
}
|
|
626
|
-
if (isTimelineLayerVisibleInPreview(layerElement, { minCompositeOpacity: 0.9 })) {
|
|
627
|
-
resolvedTime = time;
|
|
628
|
-
break;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
} finally {
|
|
632
|
-
player.renderSeek(originalTime);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
return resolvedTime ?? clearestVisibleTime ?? lastTried;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
|
|
639
|
-
const win = iframe?.contentWindow;
|
|
640
|
-
if (!win) return null;
|
|
641
|
-
|
|
642
|
-
try {
|
|
643
|
-
let pausedTime: number | null = null;
|
|
644
|
-
const player = objectLike(Reflect.get(win, "__player"));
|
|
645
|
-
pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
|
|
646
|
-
callPlaybackMethod(player, "pause");
|
|
647
|
-
|
|
648
|
-
const timeline = objectLike(Reflect.get(win, "__timeline"));
|
|
649
|
-
pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
|
|
650
|
-
callPlaybackMethod(timeline, "pause");
|
|
651
|
-
|
|
652
|
-
const timelines = objectLike(Reflect.get(win, "__timelines"));
|
|
653
|
-
if (timelines) {
|
|
654
|
-
for (const value of Object.values(timelines)) {
|
|
655
|
-
const timelineRecord = objectLike(value);
|
|
656
|
-
pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
|
|
657
|
-
callPlaybackMethod(timelineRecord, "pause");
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
return pausedTime;
|
|
662
|
-
} catch {
|
|
663
|
-
return null;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// ── Ask Agent Modal ──
|
|
668
|
-
|
|
669
|
-
function clampNumber(value: number, min: number, max: number): number {
|
|
670
|
-
if (max < min) return min;
|
|
671
|
-
return Math.min(Math.max(value, min), max);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function getAgentModalPositionStyle(
|
|
675
|
-
anchorPoint: AgentModalAnchorPoint | null,
|
|
676
|
-
): CSSProperties | undefined {
|
|
677
|
-
if (!anchorPoint || typeof window === "undefined") return undefined;
|
|
678
|
-
|
|
679
|
-
const modalWidth = 480;
|
|
680
|
-
const estimatedModalHeight = 270;
|
|
681
|
-
const margin = 16;
|
|
682
|
-
const left = clampNumber(
|
|
683
|
-
anchorPoint.x,
|
|
684
|
-
margin + modalWidth / 2,
|
|
685
|
-
window.innerWidth - margin - modalWidth / 2,
|
|
686
|
-
);
|
|
687
|
-
const top = clampNumber(
|
|
688
|
-
anchorPoint.y + 12,
|
|
689
|
-
margin,
|
|
690
|
-
window.innerHeight - margin - estimatedModalHeight,
|
|
691
|
-
);
|
|
692
|
-
|
|
693
|
-
return { left, top, transform: "translateX(-50%)" };
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function AskAgentModal({
|
|
697
|
-
selectionLabel,
|
|
698
|
-
anchorPoint = null,
|
|
699
|
-
onSubmit,
|
|
700
|
-
onClose,
|
|
701
|
-
}: {
|
|
702
|
-
selectionLabel: string;
|
|
703
|
-
anchorPoint?: AgentModalAnchorPoint | null;
|
|
704
|
-
onSubmit: (instruction: string) => void;
|
|
705
|
-
onClose: () => void;
|
|
706
|
-
}) {
|
|
707
|
-
const [value, setValue] = useState("");
|
|
708
|
-
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
709
|
-
const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
|
|
710
|
-
|
|
711
|
-
useMountEffect(() => {
|
|
712
|
-
requestAnimationFrame(() => inputRef.current?.focus());
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
const handleSubmit = () => {
|
|
716
|
-
if (!value.trim()) return;
|
|
717
|
-
onSubmit(value.trim());
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
return (
|
|
721
|
-
<div
|
|
722
|
-
className={
|
|
723
|
-
anchorPoint
|
|
724
|
-
? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
|
|
725
|
-
: "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
726
|
-
}
|
|
727
|
-
onClick={onClose}
|
|
728
|
-
>
|
|
729
|
-
<div
|
|
730
|
-
className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
|
|
731
|
-
anchorPoint ? "fixed" : ""
|
|
732
|
-
}`}
|
|
733
|
-
style={modalPositionStyle}
|
|
734
|
-
onClick={(e) => e.stopPropagation()}
|
|
735
|
-
>
|
|
736
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
|
737
|
-
<div>
|
|
738
|
-
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
|
739
|
-
<p className="text-xs text-neutral-500 mt-0.5">
|
|
740
|
-
{selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
|
|
741
|
-
</p>
|
|
742
|
-
</div>
|
|
743
|
-
<button
|
|
744
|
-
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
|
745
|
-
onClick={onClose}
|
|
746
|
-
>
|
|
747
|
-
<svg
|
|
748
|
-
width="14"
|
|
749
|
-
height="14"
|
|
750
|
-
viewBox="0 0 24 24"
|
|
751
|
-
fill="none"
|
|
752
|
-
stroke="currentColor"
|
|
753
|
-
strokeWidth="2"
|
|
754
|
-
strokeLinecap="round"
|
|
755
|
-
>
|
|
756
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
757
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
758
|
-
</svg>
|
|
759
|
-
</button>
|
|
760
|
-
</div>
|
|
761
|
-
<div className="px-5 py-4">
|
|
762
|
-
<textarea
|
|
763
|
-
ref={inputRef}
|
|
764
|
-
className="w-full h-24 px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900/60 text-sm text-neutral-200 placeholder-neutral-600 resize-none focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
|
|
765
|
-
placeholder="Describe what you want to change…"
|
|
766
|
-
value={value}
|
|
767
|
-
onChange={(e) => setValue(e.target.value)}
|
|
768
|
-
onKeyDown={(e) => {
|
|
769
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
|
|
770
|
-
if (e.key === "Escape") onClose();
|
|
771
|
-
}}
|
|
772
|
-
/>
|
|
773
|
-
</div>
|
|
774
|
-
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
|
775
|
-
<span className="text-[11px] text-neutral-600">
|
|
776
|
-
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
|
777
|
-
</span>
|
|
778
|
-
<button
|
|
779
|
-
className="px-4 py-1.5 rounded-lg bg-studio-accent/90 text-xs font-medium text-neutral-950 hover:bg-studio-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
|
780
|
-
disabled={!value.trim()}
|
|
781
|
-
onClick={handleSubmit}
|
|
782
|
-
>
|
|
783
|
-
Copy prompt
|
|
784
|
-
</button>
|
|
785
|
-
</div>
|
|
786
|
-
</div>
|
|
787
|
-
</div>
|
|
788
|
-
);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
792
|
-
image: 3,
|
|
793
|
-
video: 5,
|
|
794
|
-
audio: 5,
|
|
795
|
-
};
|
|
796
|
-
|
|
797
|
-
function collectHtmlIds(source: string): string[] {
|
|
798
|
-
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
async function resolveDroppedAssetDuration(
|
|
802
|
-
projectId: string,
|
|
803
|
-
assetPath: string,
|
|
804
|
-
kind: TimelineAssetKind,
|
|
805
|
-
): Promise<number> {
|
|
806
|
-
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
|
|
807
|
-
|
|
808
|
-
const media = document.createElement(kind === "video" ? "video" : "audio");
|
|
809
|
-
media.preload = "metadata";
|
|
810
|
-
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
|
|
811
|
-
|
|
812
|
-
const duration = await new Promise<number>((resolve) => {
|
|
813
|
-
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
|
814
|
-
const finalize = (value: number) => {
|
|
815
|
-
window.clearTimeout(timeout);
|
|
816
|
-
resolve(value);
|
|
817
|
-
};
|
|
818
|
-
|
|
819
|
-
media.addEventListener(
|
|
820
|
-
"loadedmetadata",
|
|
821
|
-
() => {
|
|
822
|
-
const raw = Number(media.duration);
|
|
823
|
-
finalize(
|
|
824
|
-
Number.isFinite(raw) && raw > 0
|
|
825
|
-
? Math.round(raw * 100) / 100
|
|
826
|
-
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
|
|
827
|
-
);
|
|
828
|
-
},
|
|
829
|
-
{ once: true },
|
|
830
|
-
);
|
|
831
|
-
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
|
|
832
|
-
once: true,
|
|
833
|
-
});
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
media.src = "";
|
|
837
|
-
media.load();
|
|
838
|
-
return duration;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// ── Main App ──
|
|
842
|
-
|
|
843
|
-
export function StudioApp() {
|
|
844
|
-
const [projectId, setProjectId] = useState<string | null>(null);
|
|
845
|
-
const [resolving, setResolving] = useState(true);
|
|
846
|
-
|
|
847
|
-
useMountEffect(() => {
|
|
848
|
-
const hashProjectId = parseProjectIdFromHash(window.location.hash);
|
|
849
|
-
if (hashProjectId) {
|
|
850
|
-
setProjectId(hashProjectId);
|
|
851
|
-
setResolving(false);
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
// No hash — auto-select first available project
|
|
855
|
-
fetch("/api/projects")
|
|
856
|
-
.then((r) => r.json())
|
|
857
|
-
.then((data) => {
|
|
858
|
-
const first = (data.projects ?? [])[0];
|
|
859
|
-
if (first) {
|
|
860
|
-
setProjectId(first.id);
|
|
861
|
-
window.location.hash = buildProjectHash(first.id);
|
|
862
|
-
}
|
|
863
|
-
})
|
|
864
|
-
.catch(() => {})
|
|
865
|
-
.finally(() => setResolving(false));
|
|
866
|
-
});
|
|
867
|
-
|
|
868
|
-
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
869
|
-
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
870
|
-
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
871
|
-
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
872
|
-
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
873
|
-
const renderQueue = useRenderQueue(projectId);
|
|
874
|
-
const captionEditMode = useCaptionStore((s) => s.isEditMode);
|
|
875
|
-
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
|
|
876
|
-
const captionSync = useCaptionSync(projectId);
|
|
877
|
-
|
|
878
|
-
// Resizable and collapsible panel widths
|
|
879
|
-
const [leftWidth, setLeftWidth] = useState(240);
|
|
880
|
-
const [rightWidth, setRightWidth] = useState(400);
|
|
881
|
-
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
882
|
-
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
883
|
-
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
884
|
-
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
885
|
-
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
|
886
|
-
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
|
887
|
-
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
888
|
-
const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
|
|
889
|
-
string | undefined
|
|
890
|
-
>();
|
|
891
|
-
const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
|
|
892
|
-
null,
|
|
893
|
-
);
|
|
894
|
-
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
895
|
-
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
896
|
-
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
897
|
-
const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
|
|
898
|
-
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
899
|
-
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
|
900
|
-
const refreshPreviewDocumentVersion = useCallback(() => {
|
|
901
|
-
setPreviewDocumentVersion((version) => version + 1);
|
|
902
|
-
window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 80);
|
|
903
|
-
window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 300);
|
|
904
|
-
}, []);
|
|
905
|
-
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
|
|
906
|
-
// This is a subscription to external events (postMessage from runtime) — useEffect
|
|
907
|
-
// is appropriate here. The runtime fires "state"/"timeline" messages after all
|
|
908
|
-
// compositions load, which triggers caption detection.
|
|
909
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
910
|
-
useEffect(() => {
|
|
911
|
-
if (!projectId) return;
|
|
912
|
-
|
|
913
|
-
let activating = false;
|
|
914
|
-
|
|
915
|
-
const tryActivateCaptions = () => {
|
|
916
|
-
if (useCaptionStore.getState().isEditMode || activating) {
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
const iframe = previewIframeRef.current;
|
|
921
|
-
let doc: Document | null = null;
|
|
922
|
-
let win: Window | null = null;
|
|
923
|
-
try {
|
|
924
|
-
doc = iframe?.contentDocument ?? null;
|
|
925
|
-
win = iframe?.contentWindow ?? null;
|
|
926
|
-
} catch {
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
|
-
if (!doc || !win) return;
|
|
930
|
-
|
|
931
|
-
const groups = doc.querySelectorAll(".caption-group");
|
|
932
|
-
if (groups.length === 0) return;
|
|
933
|
-
|
|
934
|
-
// Find the captions composition source path.
|
|
935
|
-
// The runtime strips data-composition-src after loading, so also check
|
|
936
|
-
// data-composition-file (set by the bundler) and the compIdToSrc map.
|
|
937
|
-
let captionSrcPath: string | null = null;
|
|
938
|
-
|
|
939
|
-
// Strategy 1: data-composition-src or data-composition-file attributes
|
|
940
|
-
const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
|
|
941
|
-
for (const host of compHosts) {
|
|
942
|
-
const src =
|
|
943
|
-
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
944
|
-
if (src && src.includes("captions")) {
|
|
945
|
-
captionSrcPath = src;
|
|
946
|
-
break;
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Strategy 2: compIdToSrc map (built from raw index.html before runtime strips attrs)
|
|
951
|
-
if (!captionSrcPath) {
|
|
952
|
-
for (const [id, src] of compIdToSrc) {
|
|
953
|
-
if (id.includes("caption") || src.includes("caption")) {
|
|
954
|
-
captionSrcPath = src;
|
|
955
|
-
break;
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Strategy 3: activeCompPath if viewing captions directly
|
|
961
|
-
if (!captionSrcPath && activeCompPath?.includes("captions")) {
|
|
962
|
-
captionSrcPath = activeCompPath;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// Strategy 4: find composition element with "caption" in its ID
|
|
966
|
-
if (!captionSrcPath) {
|
|
967
|
-
const captionComp = doc.querySelector('[data-composition-id*="caption"]');
|
|
968
|
-
if (captionComp) {
|
|
969
|
-
const compId = captionComp.getAttribute("data-composition-id") || "";
|
|
970
|
-
captionSrcPath = compIdToSrc.get(compId) || null;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
if (!captionSrcPath) return;
|
|
975
|
-
|
|
976
|
-
activating = true;
|
|
977
|
-
const srcPath = captionSrcPath;
|
|
978
|
-
fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
|
|
979
|
-
.then((r) => r.json())
|
|
980
|
-
.then((data: { content?: string }) => {
|
|
981
|
-
if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
|
|
982
|
-
const root = doc.querySelector("[data-composition-id]");
|
|
983
|
-
const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
|
|
984
|
-
const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
|
|
985
|
-
const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
|
|
986
|
-
const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
|
|
987
|
-
if (!model) return;
|
|
988
|
-
const store = useCaptionStore.getState();
|
|
989
|
-
store.setModel(model);
|
|
990
|
-
store.setSourceFilePath(srcPath);
|
|
991
|
-
store.setEditMode(true);
|
|
992
|
-
captionSync.loadOverrides();
|
|
993
|
-
})
|
|
994
|
-
.catch(() => {})
|
|
995
|
-
.finally(() => {
|
|
996
|
-
activating = false;
|
|
997
|
-
});
|
|
998
|
-
};
|
|
999
|
-
|
|
1000
|
-
// Listen for runtime messages that signal composition loading is complete
|
|
1001
|
-
const handleMessage = (e: MessageEvent) => {
|
|
1002
|
-
const data = e.data;
|
|
1003
|
-
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
|
1004
|
-
tryActivateCaptions();
|
|
1005
|
-
}
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
window.addEventListener("message", handleMessage);
|
|
1009
|
-
// Try immediately in case compositions are already loaded
|
|
1010
|
-
tryActivateCaptions();
|
|
1011
|
-
|
|
1012
|
-
return () => {
|
|
1013
|
-
window.removeEventListener("message", handleMessage);
|
|
1014
|
-
};
|
|
1015
|
-
}, [activeCompPath, projectId, compIdToSrc, captionSync]);
|
|
1016
|
-
|
|
1017
|
-
// Auto-expand right panel when a caption word is selected
|
|
1018
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
1019
|
-
useEffect(() => {
|
|
1020
|
-
if (captionEditMode) {
|
|
1021
|
-
setRightCollapsed(!captionHasSelection);
|
|
1022
|
-
}
|
|
1023
|
-
}, [captionHasSelection, captionEditMode]);
|
|
1024
|
-
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
1025
|
-
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
1026
|
-
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
1027
|
-
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
|
1028
|
-
const dragCounterRef = useRef(0);
|
|
1029
|
-
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1030
|
-
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
1031
|
-
const lastBlockedDomMoveToastAtRef = useRef(0);
|
|
1032
|
-
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
1033
|
-
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
1034
|
-
const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
|
|
1035
|
-
const panelDragRef = useRef<{
|
|
1036
|
-
side: "left" | "right";
|
|
1037
|
-
startX: number;
|
|
1038
|
-
startW: number;
|
|
1039
|
-
} | null>(null);
|
|
1040
|
-
|
|
1041
|
-
// Derive active preview URL from composition path (for drilled-down thumbnails)
|
|
1042
|
-
const activePreviewUrl = activeCompPath
|
|
1043
|
-
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
1044
|
-
: null;
|
|
1045
|
-
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
1046
|
-
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
1047
|
-
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
1048
|
-
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
1049
|
-
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
1050
|
-
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
1051
|
-
const timelineElements = usePlayerStore((s) => s.elements);
|
|
1052
|
-
const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
|
|
1053
|
-
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
1054
|
-
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
1055
|
-
const effectiveTimelineDuration = useMemo(() => {
|
|
1056
|
-
const maxEnd =
|
|
1057
|
-
timelineElements.length > 0
|
|
1058
|
-
? Math.max(...timelineElements.map((element) => element.start + element.duration))
|
|
1059
|
-
: 0;
|
|
1060
|
-
return Math.max(timelineDuration, maxEnd);
|
|
1061
|
-
}, [timelineDuration, timelineElements]);
|
|
1062
|
-
const displayedTimelineZoomPercent = useMemo(
|
|
1063
|
-
() => getTimelineZoomPercent(zoomMode, manualZoomPercent),
|
|
1064
|
-
[zoomMode, manualZoomPercent],
|
|
1065
|
-
);
|
|
1066
|
-
const toggleTimelineVisibility = useCallback(() => {
|
|
1067
|
-
setTimelineVisible((visible) => !visible);
|
|
1068
|
-
}, []);
|
|
1069
|
-
const toggleLeftSidebar = useCallback(() => {
|
|
1070
|
-
setLeftCollapsed((collapsed) => !collapsed);
|
|
1071
|
-
}, []);
|
|
1072
|
-
const refreshCaptureFrameTime = useCallback(() => {
|
|
1073
|
-
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
1074
|
-
}, []);
|
|
1075
|
-
|
|
1076
|
-
useMountEffect(() => {
|
|
1077
|
-
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
1078
|
-
return liveTime.subscribe(setCaptureFrameTime);
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
const captureFrameHref = projectId
|
|
1082
|
-
? buildFrameCaptureUrl({
|
|
1083
|
-
projectId,
|
|
1084
|
-
compositionPath: activeCompPath,
|
|
1085
|
-
currentTime: captureFrameTime,
|
|
1086
|
-
})
|
|
1087
|
-
: "#";
|
|
1088
|
-
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
1089
|
-
useMountEffect(() => () => {
|
|
1090
|
-
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
1091
|
-
});
|
|
1092
|
-
const handleTimelineToggleHotkey = useCallback(
|
|
1093
|
-
(event: KeyboardEvent) => {
|
|
1094
|
-
if (!shouldHandleTimelineToggleHotkey(event)) return;
|
|
1095
|
-
event.preventDefault();
|
|
1096
|
-
toggleTimelineVisibility();
|
|
1097
|
-
},
|
|
1098
|
-
[toggleTimelineVisibility],
|
|
1099
|
-
);
|
|
1100
|
-
|
|
1101
|
-
useMountEffect(() => {
|
|
1102
|
-
window.addEventListener("keydown", handleTimelineToggleHotkey);
|
|
1103
|
-
return () => {
|
|
1104
|
-
window.removeEventListener("keydown", handleTimelineToggleHotkey);
|
|
1105
|
-
};
|
|
1106
|
-
});
|
|
1107
|
-
|
|
1108
|
-
const syncPreviewTimelineHotkey = useCallback(
|
|
1109
|
-
(iframe: HTMLIFrameElement | null) => {
|
|
1110
|
-
const nextWindow = iframe?.contentWindow ?? null;
|
|
1111
|
-
if (previewHotkeyWindowRef.current === nextWindow) return;
|
|
1112
|
-
if (previewHotkeyWindowRef.current) {
|
|
1113
|
-
previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
|
|
1114
|
-
}
|
|
1115
|
-
previewHotkeyWindowRef.current = nextWindow;
|
|
1116
|
-
nextWindow?.addEventListener("keydown", handleTimelineToggleHotkey);
|
|
1117
|
-
},
|
|
1118
|
-
[handleTimelineToggleHotkey],
|
|
1119
|
-
);
|
|
1120
|
-
|
|
1121
|
-
useEffect(
|
|
1122
|
-
() => () => {
|
|
1123
|
-
if (previewHotkeyWindowRef.current) {
|
|
1124
|
-
previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
|
|
1125
|
-
previewHotkeyWindowRef.current = null;
|
|
1126
|
-
}
|
|
1127
|
-
},
|
|
1128
|
-
[handleTimelineToggleHotkey],
|
|
1129
|
-
);
|
|
1130
|
-
|
|
1131
|
-
const renderClipContent = useCallback(
|
|
1132
|
-
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
|
|
1133
|
-
const pid = projectIdRef.current;
|
|
1134
|
-
if (!pid) return null;
|
|
1135
|
-
|
|
1136
|
-
// Resolve composition source path using the compIdToSrc map
|
|
1137
|
-
let compSrc = el.compositionSrc;
|
|
1138
|
-
if (compSrc && compIdToSrc.size > 0) {
|
|
1139
|
-
const resolved =
|
|
1140
|
-
compIdToSrc.get(el.id) ||
|
|
1141
|
-
compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, ""));
|
|
1142
|
-
if (resolved) compSrc = resolved;
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
// Composition clips — always use the comp's own preview URL for thumbnails.
|
|
1146
|
-
// This renders the composition in isolation so we get clean frames
|
|
1147
|
-
// instead of capturing the master at a time when the comp is fading in.
|
|
1148
|
-
if (compSrc) {
|
|
1149
|
-
return (
|
|
1150
|
-
<CompositionThumbnail
|
|
1151
|
-
previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
|
|
1152
|
-
label={getTimelineElementLabel(el)}
|
|
1153
|
-
labelColor={style.label}
|
|
1154
|
-
accentColor={style.clip}
|
|
1155
|
-
seekTime={0}
|
|
1156
|
-
duration={el.duration}
|
|
1157
|
-
/>
|
|
1158
|
-
);
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// When drilled into a composition, render all inner elements via
|
|
1162
|
-
// CompositionThumbnail at their start time — most accurate visual.
|
|
1163
|
-
if (activePreviewUrl && el.duration > 0) {
|
|
1164
|
-
return (
|
|
1165
|
-
<CompositionThumbnail
|
|
1166
|
-
previewUrl={activePreviewUrl}
|
|
1167
|
-
label={getTimelineElementLabel(el)}
|
|
1168
|
-
labelColor={style.label}
|
|
1169
|
-
accentColor={style.clip}
|
|
1170
|
-
selector={el.selector}
|
|
1171
|
-
selectorIndex={el.selectorIndex}
|
|
1172
|
-
seekTime={el.start}
|
|
1173
|
-
duration={el.duration}
|
|
1174
|
-
/>
|
|
1175
|
-
);
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
const htmlPreviewEligible =
|
|
1179
|
-
el.duration > 0 &&
|
|
1180
|
-
effectiveTimelineDuration > 0 &&
|
|
1181
|
-
el.duration < effectiveTimelineDuration * 0.92 &&
|
|
1182
|
-
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
|
|
1183
|
-
|
|
1184
|
-
// Audio clips — waveform visualization
|
|
1185
|
-
if (el.tag === "audio") {
|
|
1186
|
-
const previewBase = `/api/projects/${pid}/preview/`;
|
|
1187
|
-
const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
|
|
1188
|
-
const srcRelative = el.src
|
|
1189
|
-
? previewIdx !== -1
|
|
1190
|
-
? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
|
|
1191
|
-
: el.src.startsWith("http")
|
|
1192
|
-
? null
|
|
1193
|
-
: el.src
|
|
1194
|
-
: null;
|
|
1195
|
-
const audioUrl = srcRelative
|
|
1196
|
-
? `/api/projects/${pid}/preview/${srcRelative}`
|
|
1197
|
-
: (el.src ?? "");
|
|
1198
|
-
const waveformUrl = srcRelative
|
|
1199
|
-
? `/api/projects/${pid}/waveform/${srcRelative}`
|
|
1200
|
-
: undefined;
|
|
1201
|
-
return (
|
|
1202
|
-
<AudioWaveform
|
|
1203
|
-
audioUrl={audioUrl}
|
|
1204
|
-
waveformUrl={waveformUrl}
|
|
1205
|
-
label={getTimelineElementLabel(el)}
|
|
1206
|
-
labelColor={style.label}
|
|
1207
|
-
/>
|
|
1208
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
if ((el.tag === "video" || el.tag === "img") && el.src) {
|
|
1212
|
-
const mediaSrc = el.src.startsWith("http")
|
|
1213
|
-
? el.src
|
|
1214
|
-
: `/api/projects/${pid}/preview/${el.src}`;
|
|
1215
|
-
return (
|
|
1216
|
-
<VideoThumbnail
|
|
1217
|
-
videoSrc={mediaSrc}
|
|
1218
|
-
label={getTimelineElementLabel(el)}
|
|
1219
|
-
labelColor={style.label}
|
|
1220
|
-
duration={el.duration}
|
|
1221
|
-
/>
|
|
1222
|
-
);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
if (htmlPreviewEligible) {
|
|
1226
|
-
return (
|
|
1227
|
-
<CompositionThumbnail
|
|
1228
|
-
previewUrl={`/api/projects/${pid}/preview`}
|
|
1229
|
-
label={getTimelineElementLabel(el)}
|
|
1230
|
-
labelColor={style.label}
|
|
1231
|
-
accentColor={style.clip}
|
|
1232
|
-
selector={el.selector}
|
|
1233
|
-
selectorIndex={el.selectorIndex}
|
|
1234
|
-
seekTime={el.start}
|
|
1235
|
-
duration={el.duration}
|
|
1236
|
-
/>
|
|
1237
|
-
);
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
return null;
|
|
1241
|
-
},
|
|
1242
|
-
[compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
|
|
1243
|
-
);
|
|
1244
|
-
const timelineToolbar = (
|
|
1245
|
-
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
|
1246
|
-
<div className="flex items-center justify-between px-3 py-2">
|
|
1247
|
-
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
1248
|
-
Timeline
|
|
1249
|
-
</div>
|
|
1250
|
-
<div className="flex items-center gap-1">
|
|
1251
|
-
<button
|
|
1252
|
-
type="button"
|
|
1253
|
-
onClick={() => setZoomMode("fit")}
|
|
1254
|
-
className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
|
|
1255
|
-
zoomMode === "fit"
|
|
1256
|
-
? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
|
|
1257
|
-
: "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
|
|
1258
|
-
}`}
|
|
1259
|
-
title="Fit timeline to width"
|
|
1260
|
-
>
|
|
1261
|
-
Fit
|
|
1262
|
-
</button>
|
|
1263
|
-
<button
|
|
1264
|
-
type="button"
|
|
1265
|
-
onClick={() => {
|
|
1266
|
-
setZoomMode("manual");
|
|
1267
|
-
setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
|
|
1268
|
-
}}
|
|
1269
|
-
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
|
1270
|
-
title="Zoom out"
|
|
1271
|
-
>
|
|
1272
|
-
-
|
|
1273
|
-
</button>
|
|
1274
|
-
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
|
|
1275
|
-
{`${displayedTimelineZoomPercent}%`}
|
|
1276
|
-
</div>
|
|
1277
|
-
<button
|
|
1278
|
-
type="button"
|
|
1279
|
-
onClick={() => {
|
|
1280
|
-
setZoomMode("manual");
|
|
1281
|
-
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
|
1282
|
-
}}
|
|
1283
|
-
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
|
1284
|
-
title="Zoom in"
|
|
1285
|
-
>
|
|
1286
|
-
+
|
|
1287
|
-
</button>
|
|
1288
|
-
<button
|
|
1289
|
-
type="button"
|
|
1290
|
-
onClick={toggleTimelineVisibility}
|
|
1291
|
-
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
|
1292
|
-
title={getTimelineToggleTitle(true)}
|
|
1293
|
-
aria-label="Hide timeline editor"
|
|
1294
|
-
>
|
|
1295
|
-
<svg
|
|
1296
|
-
width="14"
|
|
1297
|
-
height="14"
|
|
1298
|
-
viewBox="0 0 24 24"
|
|
1299
|
-
fill="none"
|
|
1300
|
-
stroke="currentColor"
|
|
1301
|
-
strokeWidth="1.8"
|
|
1302
|
-
strokeLinecap="round"
|
|
1303
|
-
strokeLinejoin="round"
|
|
1304
|
-
aria-hidden="true"
|
|
1305
|
-
>
|
|
1306
|
-
<path d="M5 7h14" />
|
|
1307
|
-
<path d="m8 11 4 4 4-4" />
|
|
1308
|
-
</svg>
|
|
1309
|
-
</button>
|
|
1310
|
-
</div>
|
|
1311
|
-
</div>
|
|
1312
|
-
</div>
|
|
1313
|
-
);
|
|
1314
|
-
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
|
1315
|
-
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
|
1316
|
-
const [linting, setLinting] = useState(false);
|
|
1317
|
-
const [refreshKey, setRefreshKey] = useState(0);
|
|
1318
|
-
const [, setStudioMotionRevision] = useState(0);
|
|
1319
|
-
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1320
|
-
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1321
|
-
const projectIdRef = useRef(projectId);
|
|
1322
|
-
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
1323
|
-
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
|
1324
|
-
const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1325
|
-
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
1326
|
-
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
|
1327
|
-
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
|
1328
|
-
const domEditSaveTimestampRef = useRef(0);
|
|
1329
|
-
const domTextCommitVersionRef = useRef(0);
|
|
1330
|
-
const domEditSaveQueueRef = useRef(Promise.resolve());
|
|
1331
|
-
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
|
|
1332
|
-
emptyStudioManualEditManifest(),
|
|
1333
|
-
);
|
|
1334
|
-
const studioManualEditRevisionRef = useRef(0);
|
|
1335
|
-
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
|
|
1336
|
-
const studioMotionRevisionRef = useRef(0);
|
|
1337
|
-
const applyStudioManualEditsToPreviewRef = useRef<
|
|
1338
|
-
(
|
|
1339
|
-
iframe?: HTMLIFrameElement | null,
|
|
1340
|
-
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1341
|
-
) => Promise<void>
|
|
1342
|
-
>(async () => {});
|
|
1343
|
-
const applyStudioMotionToPreviewRef = useRef<
|
|
1344
|
-
(
|
|
1345
|
-
iframe?: HTMLIFrameElement | null,
|
|
1346
|
-
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1347
|
-
) => Promise<void>
|
|
1348
|
-
>(async () => {});
|
|
1349
|
-
const studioManualEditProjectRef = useRef<string | null>(projectId);
|
|
1350
|
-
const activeCompPathRef = useRef(activeCompPath);
|
|
1351
|
-
activeCompPathRef.current = activeCompPath;
|
|
1352
|
-
|
|
1353
|
-
const queueDomEditSave = useCallback((save: () => Promise<void>) => {
|
|
1354
|
-
const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
|
|
1355
|
-
domEditSaveQueueRef.current = queuedSave.then(
|
|
1356
|
-
() => undefined,
|
|
1357
|
-
() => undefined,
|
|
1358
|
-
);
|
|
1359
|
-
return queuedSave;
|
|
1360
|
-
}, []);
|
|
1361
|
-
|
|
1362
|
-
const waitForPendingDomEditSaves = useCallback(async () => {
|
|
1363
|
-
await domEditSaveQueueRef.current.catch(() => undefined);
|
|
1364
|
-
}, []);
|
|
1365
|
-
|
|
1366
|
-
// Listen for external file changes (user editing HTML outside the editor).
|
|
1367
|
-
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
1368
|
-
// Suppress file-change events that echo back from a recent DOM edit save —
|
|
1369
|
-
// those changes are already applied to the iframe DOM and a full reload
|
|
1370
|
-
// would flash the preview.
|
|
1371
|
-
useMountEffect(() => {
|
|
1372
|
-
const handler = (payload?: unknown) => {
|
|
1373
|
-
const changedPath = readStudioFileChangePath(payload);
|
|
1374
|
-
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
|
1375
|
-
if (isStudioManualEditManifestPath(changedPath)) {
|
|
1376
|
-
if (!recentDomEditSave) {
|
|
1377
|
-
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
|
|
1378
|
-
forceFromDisk: true,
|
|
1379
|
-
});
|
|
1380
|
-
}
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
if (isStudioMotionManifestPath(changedPath)) {
|
|
1384
|
-
if (!recentDomEditSave) {
|
|
1385
|
-
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
|
|
1386
|
-
forceFromDisk: true,
|
|
1387
|
-
});
|
|
1388
|
-
}
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
if (recentDomEditSave) return;
|
|
1392
|
-
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1393
|
-
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
1394
|
-
};
|
|
1395
|
-
if (import.meta.hot) {
|
|
1396
|
-
import.meta.hot.on("hf:file-change", handler);
|
|
1397
|
-
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
1398
|
-
}
|
|
1399
|
-
// SSE fallback for embedded studio server
|
|
1400
|
-
const es = new EventSource("/api/events");
|
|
1401
|
-
es.addEventListener("file-change", handler);
|
|
1402
|
-
return () => es.close();
|
|
1403
|
-
});
|
|
1404
|
-
projectIdRef.current = projectId;
|
|
1405
|
-
domEditSelectionRef.current = domEditSelection;
|
|
1406
|
-
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
1407
|
-
domEditHoverSelectionRef.current = domEditHoverSelection;
|
|
1408
|
-
|
|
1409
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
1410
|
-
useEffect(() => {
|
|
1411
|
-
const previousProjectId = studioManualEditProjectRef.current;
|
|
1412
|
-
studioManualEditProjectRef.current = projectId;
|
|
1413
|
-
if (!previousProjectId || previousProjectId === projectId) return;
|
|
1414
|
-
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
|
|
1415
|
-
studioManualEditRevisionRef.current += 1;
|
|
1416
|
-
studioMotionManifestRef.current = emptyStudioMotionManifest();
|
|
1417
|
-
studioMotionRevisionRef.current += 1;
|
|
1418
|
-
setStudioMotionRevision((revision) => revision + 1);
|
|
1419
|
-
}, [projectId]);
|
|
1420
|
-
|
|
1421
|
-
// Load file tree when projectId changes.
|
|
1422
|
-
// Note: This is one of the few places where useEffect with deps is acceptable —
|
|
1423
|
-
// it's data fetching tied to a prop change. Ideally this would use a data-fetching
|
|
1424
|
-
// library (useQuery/useSWR) or the parent component would own the fetch.
|
|
1425
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
1426
|
-
useEffect(() => {
|
|
1427
|
-
if (!projectId) return;
|
|
1428
|
-
let cancelled = false;
|
|
1429
|
-
fetch(`/api/projects/${projectId}`)
|
|
1430
|
-
.then((r) => r.json())
|
|
1431
|
-
.then((data: { files?: string[]; dir?: string }) => {
|
|
1432
|
-
if (!cancelled && data.files) setFileTree(data.files);
|
|
1433
|
-
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
1434
|
-
})
|
|
1435
|
-
.catch(() => {
|
|
1436
|
-
if (!cancelled) setProjectDir(null);
|
|
1437
|
-
});
|
|
1438
|
-
return () => {
|
|
1439
|
-
cancelled = true;
|
|
1440
|
-
};
|
|
1441
|
-
}, [projectId]);
|
|
1442
|
-
|
|
1443
|
-
const handleFileSelect = useCallback((path: string) => {
|
|
1444
|
-
const pid = projectIdRef.current;
|
|
1445
|
-
if (!pid) return;
|
|
1446
|
-
// Expand left panel to 50vw when opening a file in Code tab
|
|
1447
|
-
setLeftWidth((prev) => Math.max(prev, Math.floor(window.innerWidth * 0.5)));
|
|
1448
|
-
// Skip fetching binary content for media files — just set the path for preview
|
|
1449
|
-
if (isMediaFile(path)) {
|
|
1450
|
-
setEditingFile({ path, content: null });
|
|
1451
|
-
return;
|
|
1452
|
-
}
|
|
1453
|
-
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
|
1454
|
-
.then((r) => r.json())
|
|
1455
|
-
.then((data: { content?: string }) => {
|
|
1456
|
-
if (data.content != null) {
|
|
1457
|
-
setEditingFile({ path, content: data.content });
|
|
1458
|
-
}
|
|
1459
|
-
})
|
|
1460
|
-
.catch(() => {});
|
|
1461
|
-
}, []);
|
|
1462
|
-
|
|
1463
|
-
const editingPathRef = useRef(editingFile?.path);
|
|
1464
|
-
editingPathRef.current = editingFile?.path;
|
|
1465
|
-
const editHistory = usePersistentEditHistory({ projectId });
|
|
1466
|
-
|
|
1467
|
-
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
1468
|
-
const pid = projectIdRef.current;
|
|
1469
|
-
if (!pid) throw new Error("No active project");
|
|
1470
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
1471
|
-
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
1472
|
-
const data = (await response.json()) as { content?: string };
|
|
1473
|
-
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
1474
|
-
return data.content;
|
|
1475
|
-
}, []);
|
|
1476
|
-
|
|
1477
|
-
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
|
1478
|
-
const pid = projectIdRef.current;
|
|
1479
|
-
if (!pid) throw new Error("No active project");
|
|
1480
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
1481
|
-
method: "PUT",
|
|
1482
|
-
headers: { "Content-Type": "text/plain" },
|
|
1483
|
-
body: content,
|
|
1484
|
-
});
|
|
1485
|
-
if (!response.ok) throw new Error(`Failed to save ${path}`);
|
|
1486
|
-
if (editingPathRef.current === path) {
|
|
1487
|
-
setEditingFile({ path, content });
|
|
1488
|
-
}
|
|
1489
|
-
}, []);
|
|
1490
|
-
|
|
1491
|
-
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
1492
|
-
const pid = projectIdRef.current;
|
|
1493
|
-
if (!pid) throw new Error("No active project");
|
|
1494
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
1495
|
-
if (response.status === 404) return "";
|
|
1496
|
-
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
1497
|
-
const data = (await response.json()) as { content?: string };
|
|
1498
|
-
return typeof data.content === "string" ? data.content : "";
|
|
1499
|
-
}, []);
|
|
1500
|
-
|
|
1501
|
-
const handleContentChange = useCallback(
|
|
1502
|
-
(content: string) => {
|
|
1503
|
-
const pid = projectIdRef.current;
|
|
1504
|
-
if (!pid) return;
|
|
1505
|
-
const path = editingPathRef.current;
|
|
1506
|
-
if (!path) return;
|
|
1507
|
-
|
|
1508
|
-
// Debounce the server write (600ms)
|
|
1509
|
-
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
1510
|
-
saveTimerRef.current = setTimeout(() => {
|
|
1511
|
-
saveProjectFilesWithHistory({
|
|
1512
|
-
projectId: pid,
|
|
1513
|
-
label: "Edit source",
|
|
1514
|
-
kind: "source",
|
|
1515
|
-
coalesceKey: `source:${path}`,
|
|
1516
|
-
files: { [path]: content },
|
|
1517
|
-
readFile: readProjectFile,
|
|
1518
|
-
writeFile: writeProjectFile,
|
|
1519
|
-
recordEdit: editHistory.recordEdit,
|
|
1520
|
-
})
|
|
1521
|
-
.then(() => {
|
|
1522
|
-
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1523
|
-
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
1524
|
-
})
|
|
1525
|
-
.catch(() => {});
|
|
1526
|
-
}, 600);
|
|
1527
|
-
},
|
|
1528
|
-
[editHistory.recordEdit, readProjectFile, writeProjectFile],
|
|
1529
|
-
);
|
|
1530
|
-
|
|
1531
|
-
const handleTimelineElementMove = useCallback(
|
|
1532
|
-
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
1533
|
-
const pid = projectIdRef.current;
|
|
1534
|
-
if (!pid) throw new Error("No active project");
|
|
1535
|
-
|
|
1536
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
1537
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
1538
|
-
if (!response.ok) {
|
|
1539
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
const data = (await response.json()) as { content?: string };
|
|
1543
|
-
const originalContent = data.content;
|
|
1544
|
-
if (typeof originalContent !== "string") {
|
|
1545
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
const patchTarget = element.domId
|
|
1549
|
-
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
|
1550
|
-
: element.selector
|
|
1551
|
-
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
|
1552
|
-
: null;
|
|
1553
|
-
if (!patchTarget) {
|
|
1554
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
const resolvedTargetPath = targetPath || "index.html";
|
|
1558
|
-
const relevantElements = timelineElements
|
|
1559
|
-
.map((timelineElement) =>
|
|
1560
|
-
(timelineElement.key ?? timelineElement.id) === (element.key ?? element.id)
|
|
1561
|
-
? { ...timelineElement, start: updates.start, track: updates.track }
|
|
1562
|
-
: timelineElement,
|
|
1563
|
-
)
|
|
1564
|
-
.filter(
|
|
1565
|
-
(timelineElement) =>
|
|
1566
|
-
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
1567
|
-
);
|
|
1568
|
-
const trackZIndices = buildTrackZIndexMap(
|
|
1569
|
-
relevantElements.map((timelineElement) => timelineElement.track),
|
|
1570
|
-
);
|
|
1571
|
-
|
|
1572
|
-
let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
|
|
1573
|
-
type: "attribute",
|
|
1574
|
-
property: "start",
|
|
1575
|
-
value: formatTimelineAttributeNumber(updates.start),
|
|
1576
|
-
});
|
|
1577
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
1578
|
-
type: "attribute",
|
|
1579
|
-
property: "track-index",
|
|
1580
|
-
value: String(updates.track),
|
|
1581
|
-
});
|
|
1582
|
-
for (const timelineElement of relevantElements) {
|
|
1583
|
-
const elementTarget = timelineElement.domId
|
|
1584
|
-
? {
|
|
1585
|
-
id: timelineElement.domId,
|
|
1586
|
-
selector: timelineElement.selector,
|
|
1587
|
-
selectorIndex: timelineElement.selectorIndex,
|
|
1588
|
-
}
|
|
1589
|
-
: timelineElement.selector
|
|
1590
|
-
? {
|
|
1591
|
-
selector: timelineElement.selector,
|
|
1592
|
-
selectorIndex: timelineElement.selectorIndex,
|
|
1593
|
-
}
|
|
1594
|
-
: null;
|
|
1595
|
-
if (!elementTarget) continue;
|
|
1596
|
-
const nextZIndex = trackZIndices.get(timelineElement.track);
|
|
1597
|
-
if (nextZIndex == null) continue;
|
|
1598
|
-
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
1599
|
-
type: "inline-style",
|
|
1600
|
-
property: "z-index",
|
|
1601
|
-
value: String(nextZIndex),
|
|
1602
|
-
});
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
if (patchedContent === originalContent) {
|
|
1606
|
-
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
await saveProjectFilesWithHistory({
|
|
1610
|
-
projectId: pid,
|
|
1611
|
-
label: "Move timeline clip",
|
|
1612
|
-
kind: "timeline",
|
|
1613
|
-
files: { [targetPath]: patchedContent },
|
|
1614
|
-
readFile: async () => originalContent,
|
|
1615
|
-
writeFile: writeProjectFile,
|
|
1616
|
-
recordEdit: editHistory.recordEdit,
|
|
1617
|
-
});
|
|
1618
|
-
|
|
1619
|
-
setRefreshKey((k) => k + 1);
|
|
1620
|
-
},
|
|
1621
|
-
[activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
|
|
1622
|
-
);
|
|
1623
|
-
|
|
1624
|
-
const handleTimelineElementResize = useCallback(
|
|
1625
|
-
async (
|
|
1626
|
-
element: TimelineElement,
|
|
1627
|
-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
1628
|
-
) => {
|
|
1629
|
-
const pid = projectIdRef.current;
|
|
1630
|
-
if (!pid) throw new Error("No active project");
|
|
1631
|
-
|
|
1632
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
1633
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
1634
|
-
if (!response.ok) {
|
|
1635
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
const data = (await response.json()) as { content?: string };
|
|
1639
|
-
const originalContent = data.content;
|
|
1640
|
-
if (typeof originalContent !== "string") {
|
|
1641
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
const patchTarget = element.domId
|
|
1645
|
-
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
|
1646
|
-
: element.selector
|
|
1647
|
-
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
|
1648
|
-
: null;
|
|
1649
|
-
if (!patchTarget) {
|
|
1650
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
const playbackStartAttrName =
|
|
1654
|
-
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
1655
|
-
const currentPlaybackStartValue =
|
|
1656
|
-
readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
|
|
1657
|
-
readAttributeByTarget(originalContent, patchTarget, "media-start");
|
|
1658
|
-
const currentPlaybackStart =
|
|
1659
|
-
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
|
1660
|
-
const trimDelta = updates.start - element.start;
|
|
1661
|
-
const fallbackPlaybackStart =
|
|
1662
|
-
updates.playbackStart == null &&
|
|
1663
|
-
trimDelta !== 0 &&
|
|
1664
|
-
Number.isFinite(currentPlaybackStart) &&
|
|
1665
|
-
currentPlaybackStart != null
|
|
1666
|
-
? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
|
|
1667
|
-
: undefined;
|
|
1668
|
-
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
|
1669
|
-
|
|
1670
|
-
let patchedContent = originalContent;
|
|
1671
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
1672
|
-
type: "attribute",
|
|
1673
|
-
property: "start",
|
|
1674
|
-
value: formatTimelineAttributeNumber(updates.start),
|
|
1675
|
-
});
|
|
1676
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
1677
|
-
type: "attribute",
|
|
1678
|
-
property: "duration",
|
|
1679
|
-
value: formatTimelineAttributeNumber(updates.duration),
|
|
1680
|
-
});
|
|
1681
|
-
if (nextPlaybackStart != null) {
|
|
1682
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
1683
|
-
type: "attribute",
|
|
1684
|
-
property: playbackStartAttrName,
|
|
1685
|
-
value: formatTimelineAttributeNumber(nextPlaybackStart),
|
|
1686
|
-
});
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
if (patchedContent === originalContent) {
|
|
1690
|
-
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
await saveProjectFilesWithHistory({
|
|
1694
|
-
projectId: pid,
|
|
1695
|
-
label: "Resize timeline clip",
|
|
1696
|
-
kind: "timeline",
|
|
1697
|
-
files: { [targetPath]: patchedContent },
|
|
1698
|
-
readFile: async () => originalContent,
|
|
1699
|
-
writeFile: writeProjectFile,
|
|
1700
|
-
recordEdit: editHistory.recordEdit,
|
|
1701
|
-
});
|
|
1702
|
-
|
|
1703
|
-
setRefreshKey((k) => k + 1);
|
|
1704
|
-
},
|
|
1705
|
-
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
1706
|
-
);
|
|
1707
|
-
|
|
1708
|
-
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
1709
|
-
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
1710
|
-
setAppToast({ message, tone });
|
|
1711
|
-
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
1712
|
-
}, []);
|
|
1713
|
-
|
|
1714
|
-
const handleCaptureFrameClick = useCallback(
|
|
1715
|
-
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
1716
|
-
if (!projectId) return;
|
|
1717
|
-
event.preventDefault();
|
|
1718
|
-
|
|
1719
|
-
const currentTime = usePlayerStore.getState().currentTime;
|
|
1720
|
-
setCaptureFrameTime(currentTime);
|
|
1721
|
-
await waitForPendingDomEditSaves();
|
|
1722
|
-
const href = buildFrameCaptureUrl({
|
|
1723
|
-
projectId,
|
|
1724
|
-
compositionPath: activeCompPath,
|
|
1725
|
-
currentTime,
|
|
1726
|
-
});
|
|
1727
|
-
const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
|
|
1728
|
-
|
|
1729
|
-
try {
|
|
1730
|
-
const response = await fetch(href, { cache: "no-store" });
|
|
1731
|
-
if (!response.ok) {
|
|
1732
|
-
throw new Error(`Capture failed (${response.status})`);
|
|
1733
|
-
}
|
|
1734
|
-
const blob = await response.blob();
|
|
1735
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
1736
|
-
const link = document.createElement("a");
|
|
1737
|
-
link.href = blobUrl;
|
|
1738
|
-
link.download = filename;
|
|
1739
|
-
document.body.appendChild(link);
|
|
1740
|
-
link.click();
|
|
1741
|
-
link.remove();
|
|
1742
|
-
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
|
1743
|
-
} catch (err) {
|
|
1744
|
-
const message = err instanceof Error ? err.message : "Capture failed";
|
|
1745
|
-
showToast(message);
|
|
1746
|
-
}
|
|
1747
|
-
},
|
|
1748
|
-
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
1749
|
-
);
|
|
1750
|
-
|
|
1751
|
-
const handleTimelineElementDelete = useCallback(
|
|
1752
|
-
async (element: TimelineElement) => {
|
|
1753
|
-
const pid = projectIdRef.current;
|
|
1754
|
-
if (!pid) throw new Error("No active project");
|
|
1755
|
-
|
|
1756
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
1757
|
-
try {
|
|
1758
|
-
const response = await fetch(
|
|
1759
|
-
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1760
|
-
);
|
|
1761
|
-
if (!response.ok) {
|
|
1762
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
const data = (await response.json()) as { content?: string };
|
|
1766
|
-
const originalContent = data.content;
|
|
1767
|
-
if (typeof originalContent !== "string") {
|
|
1768
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
const patchTarget = element.domId
|
|
1772
|
-
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
|
1773
|
-
: element.selector
|
|
1774
|
-
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
|
1775
|
-
: null;
|
|
1776
|
-
if (!patchTarget) {
|
|
1777
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
const resolvedTargetPath = targetPath || "index.html";
|
|
1781
|
-
const remainingElements = timelineElements.filter(
|
|
1782
|
-
(timelineElement) =>
|
|
1783
|
-
(timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id) &&
|
|
1784
|
-
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
1785
|
-
);
|
|
1786
|
-
const trackZIndices = buildTrackZIndexMap(
|
|
1787
|
-
remainingElements.map((timelineElement) => timelineElement.track),
|
|
1788
|
-
);
|
|
1789
|
-
|
|
1790
|
-
const removeResponse = await fetch(
|
|
1791
|
-
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
1792
|
-
{
|
|
1793
|
-
method: "POST",
|
|
1794
|
-
headers: { "Content-Type": "application/json" },
|
|
1795
|
-
body: JSON.stringify({ target: patchTarget }),
|
|
1796
|
-
},
|
|
1797
|
-
);
|
|
1798
|
-
if (!removeResponse.ok) {
|
|
1799
|
-
throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
const removeData = (await removeResponse.json()) as {
|
|
1803
|
-
changed?: boolean;
|
|
1804
|
-
content?: string;
|
|
1805
|
-
};
|
|
1806
|
-
let patchedContent =
|
|
1807
|
-
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
1808
|
-
for (const timelineElement of remainingElements) {
|
|
1809
|
-
const elementTarget = timelineElement.domId
|
|
1810
|
-
? {
|
|
1811
|
-
id: timelineElement.domId,
|
|
1812
|
-
selector: timelineElement.selector,
|
|
1813
|
-
selectorIndex: timelineElement.selectorIndex,
|
|
1814
|
-
}
|
|
1815
|
-
: timelineElement.selector
|
|
1816
|
-
? {
|
|
1817
|
-
selector: timelineElement.selector,
|
|
1818
|
-
selectorIndex: timelineElement.selectorIndex,
|
|
1819
|
-
}
|
|
1820
|
-
: null;
|
|
1821
|
-
if (!elementTarget) continue;
|
|
1822
|
-
const nextZIndex = trackZIndices.get(timelineElement.track);
|
|
1823
|
-
if (nextZIndex == null) continue;
|
|
1824
|
-
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
1825
|
-
type: "inline-style",
|
|
1826
|
-
property: "z-index",
|
|
1827
|
-
value: String(nextZIndex),
|
|
1828
|
-
});
|
|
1829
|
-
}
|
|
1830
|
-
|
|
1831
|
-
await saveProjectFilesWithHistory({
|
|
1832
|
-
projectId: pid,
|
|
1833
|
-
label: "Delete timeline clip",
|
|
1834
|
-
kind: "timeline",
|
|
1835
|
-
files: { [targetPath]: patchedContent },
|
|
1836
|
-
readFile: async () => originalContent,
|
|
1837
|
-
writeFile: writeProjectFile,
|
|
1838
|
-
recordEdit: editHistory.recordEdit,
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
usePlayerStore
|
|
1842
|
-
.getState()
|
|
1843
|
-
.setElements(
|
|
1844
|
-
timelineElements.filter(
|
|
1845
|
-
(timelineElement) =>
|
|
1846
|
-
(timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
|
|
1847
|
-
),
|
|
1848
|
-
);
|
|
1849
|
-
usePlayerStore.getState().setSelectedElementId(null);
|
|
1850
|
-
setRefreshKey((k) => k + 1);
|
|
1851
|
-
} catch (error) {
|
|
1852
|
-
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
1853
|
-
showToast(message);
|
|
1854
|
-
}
|
|
1855
|
-
},
|
|
1856
|
-
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
1857
|
-
);
|
|
1858
|
-
|
|
1859
|
-
const handleBlockedTimelineEdit = useCallback(
|
|
1860
|
-
(_element: TimelineElement) => {
|
|
1861
|
-
const now = Date.now();
|
|
1862
|
-
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
1863
|
-
lastBlockedTimelineToastAtRef.current = now;
|
|
1864
|
-
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
|
|
1865
|
-
},
|
|
1866
|
-
[showToast],
|
|
1867
|
-
);
|
|
1868
|
-
|
|
1869
|
-
const handleBlockedDomMove = useCallback(
|
|
1870
|
-
(selection: DomEditSelection) => {
|
|
1871
|
-
const now = Date.now();
|
|
1872
|
-
if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
|
|
1873
|
-
lastBlockedDomMoveToastAtRef.current = now;
|
|
1874
|
-
showToast(
|
|
1875
|
-
selection.capabilities.reasonIfDisabled ??
|
|
1876
|
-
"This element can’t be adjusted directly from the preview.",
|
|
1877
|
-
"info",
|
|
1878
|
-
);
|
|
1879
|
-
},
|
|
1880
|
-
[showToast],
|
|
1881
|
-
);
|
|
1882
|
-
|
|
1883
|
-
const applyDomSelection = useCallback(
|
|
1884
|
-
(
|
|
1885
|
-
selection: DomEditSelection | null,
|
|
1886
|
-
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
1887
|
-
) => {
|
|
1888
|
-
setAgentPromptTagSnippet(undefined);
|
|
1889
|
-
setAgentPromptSelectionContext(undefined);
|
|
1890
|
-
setAgentModalAnchorPoint(null);
|
|
1891
|
-
setCopiedAgentPrompt(false);
|
|
1892
|
-
if (!selection) {
|
|
1893
|
-
domEditSelectionRef.current = null;
|
|
1894
|
-
domEditGroupSelectionsRef.current = [];
|
|
1895
|
-
setDomEditSelection(null);
|
|
1896
|
-
setDomEditGroupSelections([]);
|
|
1897
|
-
setSelectedTimelineElementId(null);
|
|
1898
|
-
return;
|
|
1899
|
-
}
|
|
1900
|
-
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
|
|
1901
|
-
domEditSelectionRef.current = null;
|
|
1902
|
-
domEditGroupSelectionsRef.current = [];
|
|
1903
|
-
setDomEditSelection(null);
|
|
1904
|
-
setDomEditGroupSelections([]);
|
|
1905
|
-
setSelectedTimelineElementId(null);
|
|
1906
|
-
return;
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
const isAdditiveSelection = Boolean(options?.additive);
|
|
1910
|
-
const currentSelection = domEditSelectionRef.current;
|
|
1911
|
-
const previousGroup = domEditGroupSelectionsRef.current;
|
|
1912
|
-
const currentGroup = isAdditiveSelection
|
|
1913
|
-
? seedDomEditGroupWithSelection(previousGroup, currentSelection)
|
|
1914
|
-
: previousGroup;
|
|
1915
|
-
const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
|
|
1916
|
-
const nextGroup = options?.preserveGroup
|
|
1917
|
-
? replaceDomEditGroupSelection(currentGroup, selection)
|
|
1918
|
-
: isAdditiveSelection
|
|
1919
|
-
? toggleDomEditGroupSelection(currentGroup, selection)
|
|
1920
|
-
: [selection];
|
|
1921
|
-
const nextSelection = options?.preserveGroup
|
|
1922
|
-
? selection
|
|
1923
|
-
: isAdditiveSelection && wasInGroup
|
|
1924
|
-
? domEditSelectionsTargetSame(currentSelection, selection)
|
|
1925
|
-
? (nextGroup[0] ?? null)
|
|
1926
|
-
: domEditSelectionInGroup(nextGroup, currentSelection)
|
|
1927
|
-
? currentSelection
|
|
1928
|
-
: (nextGroup[0] ?? null)
|
|
1929
|
-
: selection;
|
|
1930
|
-
|
|
1931
|
-
domEditSelectionRef.current = nextSelection;
|
|
1932
|
-
domEditGroupSelectionsRef.current = nextGroup;
|
|
1933
|
-
setDomEditSelection(nextSelection);
|
|
1934
|
-
setDomEditGroupSelections(nextGroup);
|
|
1935
|
-
|
|
1936
|
-
if (nextSelection) {
|
|
1937
|
-
if (options?.revealPanel !== false) {
|
|
1938
|
-
setRightCollapsed(false);
|
|
1939
|
-
setRightPanelTab("design");
|
|
1940
|
-
}
|
|
1941
|
-
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
|
1942
|
-
nextSelection,
|
|
1943
|
-
timelineElements,
|
|
1944
|
-
);
|
|
1945
|
-
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
1946
|
-
return;
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
setSelectedTimelineElementId(null);
|
|
1950
|
-
},
|
|
1951
|
-
[setSelectedTimelineElementId, timelineElements],
|
|
1952
|
-
);
|
|
1953
|
-
|
|
1954
|
-
const clearDomSelection = useCallback(() => {
|
|
1955
|
-
applyDomSelection(null, { revealPanel: false });
|
|
1956
|
-
}, [applyDomSelection]);
|
|
1957
|
-
|
|
1958
|
-
const readHistoryProjectFile = useCallback(
|
|
1959
|
-
async (path: string): Promise<string> => {
|
|
1960
|
-
return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
|
|
1961
|
-
? readOptionalProjectFile(path)
|
|
1962
|
-
: readProjectFile(path);
|
|
1963
|
-
},
|
|
1964
|
-
[readOptionalProjectFile, readProjectFile],
|
|
1965
|
-
);
|
|
1966
|
-
|
|
1967
|
-
const writeHistoryProjectFile = useCallback(
|
|
1968
|
-
async (path: string, content: string): Promise<void> => {
|
|
1969
|
-
await writeProjectFile(path, content);
|
|
1970
|
-
if (path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH) {
|
|
1971
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
1972
|
-
}
|
|
1973
|
-
},
|
|
1974
|
-
[writeProjectFile],
|
|
1975
|
-
);
|
|
1976
|
-
|
|
1977
|
-
const applyCurrentStudioManualEditsToPreview = useCallback(
|
|
1978
|
-
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
1979
|
-
if (!iframe) return;
|
|
1980
|
-
let doc: Document | null = null;
|
|
1981
|
-
try {
|
|
1982
|
-
doc = iframe.contentDocument;
|
|
1983
|
-
} catch {
|
|
1984
|
-
return;
|
|
1985
|
-
}
|
|
1986
|
-
if (!doc) return;
|
|
1987
|
-
const previewDoc = doc;
|
|
1988
|
-
|
|
1989
|
-
const applyManifest = () => {
|
|
1990
|
-
applyStudioManualEditManifest(
|
|
1991
|
-
previewDoc,
|
|
1992
|
-
studioManualEditManifestRef.current,
|
|
1993
|
-
activeCompPathRef.current,
|
|
1994
|
-
);
|
|
1995
|
-
};
|
|
1996
|
-
const applyAndInstallSeekHooks = () => {
|
|
1997
|
-
applyManifest();
|
|
1998
|
-
if (iframe.contentWindow) {
|
|
1999
|
-
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
|
2000
|
-
}
|
|
2001
|
-
};
|
|
2002
|
-
|
|
2003
|
-
const win = iframe.contentWindow;
|
|
2004
|
-
applyAndInstallSeekHooks();
|
|
2005
|
-
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
2006
|
-
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
|
|
2007
|
-
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
|
|
2008
|
-
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
|
|
2009
|
-
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
|
|
2010
|
-
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
|
|
2011
|
-
},
|
|
2012
|
-
[],
|
|
2013
|
-
);
|
|
2014
|
-
|
|
2015
|
-
const applyStudioManualEditsToPreview = useCallback(
|
|
2016
|
-
async (
|
|
2017
|
-
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
2018
|
-
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
2019
|
-
) => {
|
|
2020
|
-
const readRevision = studioManualEditRevisionRef.current;
|
|
2021
|
-
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
2022
|
-
if (!readFromDiskFirst) {
|
|
2023
|
-
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2024
|
-
}
|
|
2025
|
-
let content: string;
|
|
2026
|
-
try {
|
|
2027
|
-
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
2028
|
-
} catch (error) {
|
|
2029
|
-
const message =
|
|
2030
|
-
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
|
2031
|
-
showToast(message);
|
|
2032
|
-
if (readFromDiskFirst) {
|
|
2033
|
-
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2034
|
-
}
|
|
2035
|
-
return;
|
|
2036
|
-
}
|
|
2037
|
-
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
|
2038
|
-
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
|
2039
|
-
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
|
2040
|
-
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2041
|
-
return;
|
|
2042
|
-
}
|
|
2043
|
-
if (readFromDiskFirst) {
|
|
2044
|
-
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2045
|
-
}
|
|
2046
|
-
},
|
|
2047
|
-
[applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
|
|
2048
|
-
);
|
|
2049
|
-
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
|
2050
|
-
|
|
2051
|
-
const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
|
|
2052
|
-
(iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
|
|
2053
|
-
applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
|
|
2054
|
-
[applyStudioManualEditsToPreview],
|
|
2055
|
-
);
|
|
2056
|
-
|
|
2057
|
-
const applyCurrentStudioMotionToPreview = useCallback(
|
|
2058
|
-
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
2059
|
-
if (!iframe) return;
|
|
2060
|
-
let doc: Document | null = null;
|
|
2061
|
-
try {
|
|
2062
|
-
doc = iframe.contentDocument;
|
|
2063
|
-
} catch {
|
|
2064
|
-
return;
|
|
2065
|
-
}
|
|
2066
|
-
if (!doc) return;
|
|
2067
|
-
const previewDoc = doc;
|
|
2068
|
-
|
|
2069
|
-
const applyManifest = () => {
|
|
2070
|
-
applyStudioMotionManifest(
|
|
2071
|
-
previewDoc,
|
|
2072
|
-
studioMotionManifestRef.current,
|
|
2073
|
-
activeCompPathRef.current,
|
|
2074
|
-
);
|
|
2075
|
-
};
|
|
2076
|
-
const applyAndInstallSeekHooks = () => {
|
|
2077
|
-
applyManifest();
|
|
2078
|
-
if (iframe.contentWindow) {
|
|
2079
|
-
installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
|
|
2080
|
-
}
|
|
2081
|
-
};
|
|
2082
|
-
|
|
2083
|
-
const win = iframe.contentWindow;
|
|
2084
|
-
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
2085
|
-
win?.setTimeout?.(applyAndInstallSeekHooks, 120);
|
|
2086
|
-
},
|
|
2087
|
-
[],
|
|
2088
|
-
);
|
|
2089
|
-
|
|
2090
|
-
const applyStudioMotionToPreview = useCallback(
|
|
2091
|
-
async (
|
|
2092
|
-
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
2093
|
-
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
2094
|
-
) => {
|
|
2095
|
-
const readRevision = studioMotionRevisionRef.current;
|
|
2096
|
-
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
2097
|
-
if (!readFromDiskFirst) {
|
|
2098
|
-
applyCurrentStudioMotionToPreview(iframe);
|
|
2099
|
-
}
|
|
2100
|
-
let content: string;
|
|
2101
|
-
try {
|
|
2102
|
-
content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
2103
|
-
} catch (error) {
|
|
2104
|
-
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
|
|
2105
|
-
showToast(message);
|
|
2106
|
-
if (readFromDiskFirst) {
|
|
2107
|
-
applyCurrentStudioMotionToPreview(iframe);
|
|
2108
|
-
}
|
|
2109
|
-
return;
|
|
2110
|
-
}
|
|
2111
|
-
if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
|
|
2112
|
-
studioMotionManifestRef.current = parseStudioMotionManifest(content);
|
|
2113
|
-
if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
|
|
2114
|
-
setStudioMotionRevision((revision) => revision + 1);
|
|
2115
|
-
applyCurrentStudioMotionToPreview(iframe);
|
|
2116
|
-
return;
|
|
2117
|
-
}
|
|
2118
|
-
if (readFromDiskFirst) {
|
|
2119
|
-
applyCurrentStudioMotionToPreview(iframe);
|
|
2120
|
-
}
|
|
2121
|
-
},
|
|
2122
|
-
[applyCurrentStudioMotionToPreview, readOptionalProjectFile, showToast],
|
|
2123
|
-
);
|
|
2124
|
-
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
|
|
2125
|
-
|
|
2126
|
-
const applyStudioMotionToPreviewAfterRefresh = useCallback(
|
|
2127
|
-
(iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
|
|
2128
|
-
applyStudioMotionToPreview(iframe, { readFromDiskFirst: true }),
|
|
2129
|
-
[applyStudioMotionToPreview],
|
|
2130
|
-
);
|
|
2131
|
-
|
|
2132
|
-
const commitStudioManualEditManifestOptimistically = useCallback(
|
|
2133
|
-
(
|
|
2134
|
-
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
|
2135
|
-
options: { label: string; coalesceKey: string },
|
|
2136
|
-
) => {
|
|
2137
|
-
const previousManifest = studioManualEditManifestRef.current;
|
|
2138
|
-
const nextManifest = updateManifest(previousManifest);
|
|
2139
|
-
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
|
2140
|
-
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
|
2141
|
-
if (nextContent === previousContent) {
|
|
2142
|
-
return;
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
const revision = studioManualEditRevisionRef.current + 1;
|
|
2146
|
-
studioManualEditRevisionRef.current = revision;
|
|
2147
|
-
studioManualEditManifestRef.current = nextManifest;
|
|
2148
|
-
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2149
|
-
|
|
2150
|
-
const save = async () => {
|
|
2151
|
-
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
2152
|
-
const diskManifest = parseStudioManualEditManifest(originalContent);
|
|
2153
|
-
const nextDiskManifest = updateManifest(diskManifest);
|
|
2154
|
-
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
|
2155
|
-
if (nextDiskContent === originalContent) {
|
|
2156
|
-
return;
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
const pid = projectIdRef.current;
|
|
2160
|
-
if (!pid) throw new Error("No active project");
|
|
2161
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
2162
|
-
await saveProjectFilesWithHistory({
|
|
2163
|
-
projectId: pid,
|
|
2164
|
-
label: options.label,
|
|
2165
|
-
kind: "manual",
|
|
2166
|
-
coalesceKey: options.coalesceKey,
|
|
2167
|
-
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
|
2168
|
-
readFile: async () => originalContent,
|
|
2169
|
-
writeFile: writeProjectFile,
|
|
2170
|
-
recordEdit: editHistory.recordEdit,
|
|
2171
|
-
});
|
|
2172
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
2173
|
-
|
|
2174
|
-
if (studioManualEditRevisionRef.current === revision) {
|
|
2175
|
-
studioManualEditManifestRef.current = nextDiskManifest;
|
|
2176
|
-
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2177
|
-
}
|
|
2178
|
-
};
|
|
2179
|
-
|
|
2180
|
-
void queueDomEditSave(save).catch((error) => {
|
|
2181
|
-
if (studioManualEditRevisionRef.current === revision) {
|
|
2182
|
-
studioManualEditRevisionRef.current += 1;
|
|
2183
|
-
studioManualEditManifestRef.current = previousManifest;
|
|
2184
|
-
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2185
|
-
}
|
|
2186
|
-
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
|
2187
|
-
showToast(message);
|
|
2188
|
-
});
|
|
2189
|
-
},
|
|
2190
|
-
[
|
|
2191
|
-
applyCurrentStudioManualEditsToPreview,
|
|
2192
|
-
editHistory.recordEdit,
|
|
2193
|
-
queueDomEditSave,
|
|
2194
|
-
readOptionalProjectFile,
|
|
2195
|
-
showToast,
|
|
2196
|
-
writeProjectFile,
|
|
2197
|
-
],
|
|
2198
|
-
);
|
|
2199
|
-
|
|
2200
|
-
const commitStudioMotionManifestOptimistically = useCallback(
|
|
2201
|
-
(
|
|
2202
|
-
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
|
2203
|
-
options: { label: string; coalesceKey: string },
|
|
2204
|
-
) => {
|
|
2205
|
-
const previousManifest = studioMotionManifestRef.current;
|
|
2206
|
-
const nextManifest = updateManifest(previousManifest);
|
|
2207
|
-
const previousContent = serializeStudioMotionManifest(previousManifest);
|
|
2208
|
-
const nextContent = serializeStudioMotionManifest(nextManifest);
|
|
2209
|
-
if (nextContent === previousContent) {
|
|
2210
|
-
return;
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
|
-
const revision = studioMotionRevisionRef.current + 1;
|
|
2214
|
-
studioMotionRevisionRef.current = revision;
|
|
2215
|
-
studioMotionManifestRef.current = nextManifest;
|
|
2216
|
-
setStudioMotionRevision((current) => current + 1);
|
|
2217
|
-
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2218
|
-
|
|
2219
|
-
const save = async () => {
|
|
2220
|
-
const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
2221
|
-
const diskManifest = parseStudioMotionManifest(originalContent);
|
|
2222
|
-
const nextDiskManifest = updateManifest(diskManifest);
|
|
2223
|
-
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
|
|
2224
|
-
if (nextDiskContent === originalContent) {
|
|
2225
|
-
return;
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
|
-
const pid = projectIdRef.current;
|
|
2229
|
-
if (!pid) throw new Error("No active project");
|
|
2230
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
2231
|
-
await saveProjectFilesWithHistory({
|
|
2232
|
-
projectId: pid,
|
|
2233
|
-
label: options.label,
|
|
2234
|
-
kind: "motion",
|
|
2235
|
-
coalesceKey: options.coalesceKey,
|
|
2236
|
-
files: { [STUDIO_MOTION_PATH]: nextDiskContent },
|
|
2237
|
-
readFile: async () => originalContent,
|
|
2238
|
-
writeFile: writeProjectFile,
|
|
2239
|
-
recordEdit: editHistory.recordEdit,
|
|
2240
|
-
});
|
|
2241
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
2242
|
-
|
|
2243
|
-
if (studioMotionRevisionRef.current === revision) {
|
|
2244
|
-
studioMotionManifestRef.current = nextDiskManifest;
|
|
2245
|
-
setStudioMotionRevision((current) => current + 1);
|
|
2246
|
-
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2247
|
-
}
|
|
2248
|
-
};
|
|
2249
|
-
|
|
2250
|
-
void queueDomEditSave(save).catch((error) => {
|
|
2251
|
-
if (studioMotionRevisionRef.current === revision) {
|
|
2252
|
-
studioMotionRevisionRef.current += 1;
|
|
2253
|
-
studioMotionManifestRef.current = previousManifest;
|
|
2254
|
-
setStudioMotionRevision((current) => current + 1);
|
|
2255
|
-
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2256
|
-
}
|
|
2257
|
-
const message = error instanceof Error ? error.message : "Failed to save motion edit";
|
|
2258
|
-
showToast(message);
|
|
2259
|
-
});
|
|
2260
|
-
},
|
|
2261
|
-
[
|
|
2262
|
-
applyCurrentStudioMotionToPreview,
|
|
2263
|
-
editHistory.recordEdit,
|
|
2264
|
-
queueDomEditSave,
|
|
2265
|
-
readOptionalProjectFile,
|
|
2266
|
-
showToast,
|
|
2267
|
-
writeProjectFile,
|
|
2268
|
-
],
|
|
2269
|
-
);
|
|
2270
|
-
|
|
2271
|
-
const syncHistoryPreviewAfterApply = useCallback(
|
|
2272
|
-
async (paths: string[] | undefined) => {
|
|
2273
|
-
const changedPaths = paths ?? [];
|
|
2274
|
-
const manualManifestOnly =
|
|
2275
|
-
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
|
2276
|
-
const motionManifestOnly =
|
|
2277
|
-
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
|
|
2278
|
-
|
|
2279
|
-
if (manualManifestOnly) {
|
|
2280
|
-
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
2281
|
-
return;
|
|
2282
|
-
}
|
|
2283
|
-
if (motionManifestOnly) {
|
|
2284
|
-
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
2285
|
-
return;
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
setRefreshKey((key) => key + 1);
|
|
2289
|
-
},
|
|
2290
|
-
[applyStudioManualEditsToPreview, applyStudioMotionToPreview],
|
|
2291
|
-
);
|
|
2292
|
-
|
|
2293
|
-
const handleUndo = useCallback(async () => {
|
|
2294
|
-
await waitForPendingDomEditSaves();
|
|
2295
|
-
const result = await editHistory.undo({
|
|
2296
|
-
readFile: readHistoryProjectFile,
|
|
2297
|
-
writeFile: writeHistoryProjectFile,
|
|
2298
|
-
});
|
|
2299
|
-
if (!result.ok && result.reason === "content-mismatch") {
|
|
2300
|
-
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
|
2301
|
-
return;
|
|
2302
|
-
}
|
|
2303
|
-
if (result.ok && result.label) {
|
|
2304
|
-
clearDomSelection();
|
|
2305
|
-
await syncHistoryPreviewAfterApply(result.paths);
|
|
2306
|
-
showToast(`Undid ${result.label}`, "info");
|
|
2307
|
-
}
|
|
2308
|
-
}, [
|
|
2309
|
-
clearDomSelection,
|
|
2310
|
-
editHistory,
|
|
2311
|
-
readHistoryProjectFile,
|
|
2312
|
-
showToast,
|
|
2313
|
-
syncHistoryPreviewAfterApply,
|
|
2314
|
-
waitForPendingDomEditSaves,
|
|
2315
|
-
writeHistoryProjectFile,
|
|
2316
|
-
]);
|
|
2317
|
-
|
|
2318
|
-
const handleRedo = useCallback(async () => {
|
|
2319
|
-
await waitForPendingDomEditSaves();
|
|
2320
|
-
const result = await editHistory.redo({
|
|
2321
|
-
readFile: readHistoryProjectFile,
|
|
2322
|
-
writeFile: writeHistoryProjectFile,
|
|
2323
|
-
});
|
|
2324
|
-
if (!result.ok && result.reason === "content-mismatch") {
|
|
2325
|
-
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
|
2326
|
-
return;
|
|
2327
|
-
}
|
|
2328
|
-
if (result.ok && result.label) {
|
|
2329
|
-
clearDomSelection();
|
|
2330
|
-
await syncHistoryPreviewAfterApply(result.paths);
|
|
2331
|
-
showToast(`Redid ${result.label}`, "info");
|
|
2332
|
-
}
|
|
2333
|
-
}, [
|
|
2334
|
-
clearDomSelection,
|
|
2335
|
-
editHistory,
|
|
2336
|
-
readHistoryProjectFile,
|
|
2337
|
-
showToast,
|
|
2338
|
-
syncHistoryPreviewAfterApply,
|
|
2339
|
-
waitForPendingDomEditSaves,
|
|
2340
|
-
writeHistoryProjectFile,
|
|
2341
|
-
]);
|
|
2342
|
-
|
|
2343
|
-
const handleUndoRef = useRef(handleUndo);
|
|
2344
|
-
const handleRedoRef = useRef(handleRedo);
|
|
2345
|
-
handleUndoRef.current = handleUndo;
|
|
2346
|
-
handleRedoRef.current = handleRedo;
|
|
2347
|
-
|
|
2348
|
-
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
|
2349
|
-
if (!(event.metaKey || event.ctrlKey)) return;
|
|
2350
|
-
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
|
2351
|
-
const key = event.key.toLowerCase();
|
|
2352
|
-
if (key === "z" && !event.shiftKey) {
|
|
2353
|
-
event.preventDefault();
|
|
2354
|
-
void handleUndoRef.current();
|
|
2355
|
-
return;
|
|
2356
|
-
}
|
|
2357
|
-
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
2358
|
-
event.preventDefault();
|
|
2359
|
-
void handleRedoRef.current();
|
|
2360
|
-
}
|
|
2361
|
-
}, []);
|
|
2362
|
-
|
|
2363
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
2364
|
-
useEffect(() => {
|
|
2365
|
-
window.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2366
|
-
return () => window.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2367
|
-
}, [handleHistoryHotkey]);
|
|
2368
|
-
|
|
2369
|
-
const syncPreviewHistoryHotkey = useCallback(
|
|
2370
|
-
(iframe: HTMLIFrameElement | null) => {
|
|
2371
|
-
previewHistoryHotkeyCleanupRef.current?.();
|
|
2372
|
-
previewHistoryHotkeyCleanupRef.current = null;
|
|
2373
|
-
|
|
2374
|
-
const win = iframe?.contentWindow ?? null;
|
|
2375
|
-
let doc: Document | null = null;
|
|
2376
|
-
try {
|
|
2377
|
-
doc = iframe?.contentDocument ?? null;
|
|
2378
|
-
} catch {
|
|
2379
|
-
doc = null;
|
|
2380
|
-
}
|
|
2381
|
-
if (!win && !doc) return;
|
|
2382
|
-
|
|
2383
|
-
win?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2384
|
-
doc?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2385
|
-
previewHistoryHotkeyCleanupRef.current = () => {
|
|
2386
|
-
win?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2387
|
-
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2388
|
-
};
|
|
2389
|
-
},
|
|
2390
|
-
[handleHistoryHotkey],
|
|
2391
|
-
);
|
|
2392
|
-
|
|
2393
|
-
useEffect(
|
|
2394
|
-
() => () => {
|
|
2395
|
-
previewHistoryHotkeyCleanupRef.current?.();
|
|
2396
|
-
previewHistoryHotkeyCleanupRef.current = null;
|
|
2397
|
-
},
|
|
2398
|
-
[],
|
|
2399
|
-
);
|
|
2400
|
-
|
|
2401
|
-
const buildDomSelectionFromTarget = useCallback(
|
|
2402
|
-
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
2403
|
-
return resolveDomEditSelection(target, {
|
|
2404
|
-
activeCompositionPath: activeCompPath,
|
|
2405
|
-
isMasterView,
|
|
2406
|
-
preferClipAncestor: options?.preferClipAncestor,
|
|
2407
|
-
});
|
|
2408
|
-
},
|
|
2409
|
-
[activeCompPath, isMasterView],
|
|
2410
|
-
);
|
|
2411
|
-
|
|
2412
|
-
const resolveDomSelectionFromPreviewPoint = useCallback(
|
|
2413
|
-
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
|
|
2414
|
-
const iframe = previewIframeRef.current;
|
|
2415
|
-
if (!iframe || captionEditMode) return null;
|
|
2416
|
-
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
|
|
2417
|
-
if (!target) return null;
|
|
2418
|
-
return buildDomSelectionFromTarget(target, {
|
|
2419
|
-
preferClipAncestor: options?.preferClipAncestor,
|
|
2420
|
-
});
|
|
2421
|
-
},
|
|
2422
|
-
[activeCompPath, buildDomSelectionFromTarget, captionEditMode],
|
|
2423
|
-
);
|
|
2424
|
-
|
|
2425
|
-
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
|
|
2426
|
-
if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
|
|
2427
|
-
domEditHoverSelectionRef.current = selection;
|
|
2428
|
-
setDomEditHoverSelection(selection);
|
|
2429
|
-
}, []);
|
|
2430
|
-
|
|
2431
|
-
const buildDomSelectionForTimelineElement = useCallback(
|
|
2432
|
-
(element: TimelineElement): DomEditSelection | null => {
|
|
2433
|
-
const iframe = previewIframeRef.current;
|
|
2434
|
-
let doc: Document | null = null;
|
|
2435
|
-
try {
|
|
2436
|
-
doc = iframe?.contentDocument ?? null;
|
|
2437
|
-
} catch {
|
|
2438
|
-
return null;
|
|
2439
|
-
}
|
|
2440
|
-
if (!doc) return null;
|
|
2441
|
-
|
|
2442
|
-
const targetElement = findElementForTimelineElement(doc, element, {
|
|
2443
|
-
activeCompositionPath: activeCompPath,
|
|
2444
|
-
compIdToSrc,
|
|
2445
|
-
isMasterView,
|
|
2446
|
-
});
|
|
2447
|
-
return targetElement
|
|
2448
|
-
? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
|
|
2449
|
-
: null;
|
|
2450
|
-
},
|
|
2451
|
-
[activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView],
|
|
2452
|
-
);
|
|
2453
|
-
|
|
2454
|
-
const inspectedTimelineElement = useMemo(
|
|
2455
|
-
() =>
|
|
2456
|
-
timelineElements.find(
|
|
2457
|
-
(element) => getTimelineElementKey(element) === inspectedTimelineElementId,
|
|
2458
|
-
) ?? null,
|
|
2459
|
-
[inspectedTimelineElementId, timelineElements],
|
|
2460
|
-
);
|
|
2461
|
-
|
|
2462
|
-
const timelineLayerChildCounts = useMemo(() => {
|
|
2463
|
-
void previewDocumentVersion;
|
|
2464
|
-
const counts = new Map<string, number>();
|
|
2465
|
-
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return counts;
|
|
2466
|
-
|
|
2467
|
-
const key = getTimelineElementKey(inspectedTimelineElement);
|
|
2468
|
-
if (key) {
|
|
2469
|
-
const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
|
|
2470
|
-
const count = countDomEditChildLayers(selection?.element, {
|
|
2471
|
-
activeCompositionPath: activeCompPath,
|
|
2472
|
-
isMasterView,
|
|
2473
|
-
});
|
|
2474
|
-
if (count > 0) counts.set(key, count);
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2477
|
-
return counts;
|
|
2478
|
-
}, [
|
|
2479
|
-
activeCompPath,
|
|
2480
|
-
buildDomSelectionForTimelineElement,
|
|
2481
|
-
inspectedTimelineElement,
|
|
2482
|
-
isMasterView,
|
|
2483
|
-
previewDocumentVersion,
|
|
2484
|
-
]);
|
|
2485
|
-
|
|
2486
|
-
const inspectedTimelineLayers = useMemo(() => {
|
|
2487
|
-
void previewDocumentVersion;
|
|
2488
|
-
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return [];
|
|
2489
|
-
const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
|
|
2490
|
-
return collectDomEditLayerItems(selection?.element, {
|
|
2491
|
-
activeCompositionPath: activeCompPath,
|
|
2492
|
-
isMasterView,
|
|
2493
|
-
});
|
|
2494
|
-
}, [
|
|
2495
|
-
activeCompPath,
|
|
2496
|
-
buildDomSelectionForTimelineElement,
|
|
2497
|
-
inspectedTimelineElement,
|
|
2498
|
-
isMasterView,
|
|
2499
|
-
previewDocumentVersion,
|
|
2500
|
-
]);
|
|
2501
|
-
|
|
2502
|
-
const selectedTimelineLayerKey = useMemo(
|
|
2503
|
-
() => (domEditSelection ? getDomEditLayerKey(domEditSelection) : null),
|
|
2504
|
-
[domEditSelection],
|
|
2505
|
-
);
|
|
2506
|
-
|
|
2507
|
-
const handleTimelineElementSelect = useCallback(
|
|
2508
|
-
(element: TimelineElement | null) => {
|
|
2509
|
-
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2510
|
-
if (!element) {
|
|
2511
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2512
|
-
setInspectedTimelineElementId(null);
|
|
2513
|
-
return;
|
|
2514
|
-
}
|
|
2515
|
-
|
|
2516
|
-
const selection = buildDomSelectionForTimelineElement(element);
|
|
2517
|
-
if (selection) applyDomSelection(selection);
|
|
2518
|
-
|
|
2519
|
-
const key = getTimelineElementKey(element);
|
|
2520
|
-
if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
|
|
2521
|
-
setInspectedTimelineElementId(key);
|
|
2522
|
-
setLeftCollapsed(false);
|
|
2523
|
-
|
|
2524
|
-
const iframe = previewIframeRef.current;
|
|
2525
|
-
if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
|
|
2526
|
-
seekStudioPreview(iframe, element.start);
|
|
2527
|
-
}
|
|
2528
|
-
} else {
|
|
2529
|
-
setInspectedTimelineElementId(null);
|
|
2530
|
-
}
|
|
2531
|
-
},
|
|
2532
|
-
[applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
|
|
2533
|
-
);
|
|
2534
|
-
|
|
2535
|
-
const handleTimelineElementInspect = useCallback(
|
|
2536
|
-
(element: TimelineElement) => {
|
|
2537
|
-
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2538
|
-
if (!canInspectTimelineElement(element)) {
|
|
2539
|
-
showToast("Audio clips do not have visual layers.", "info");
|
|
2540
|
-
return;
|
|
2541
|
-
}
|
|
2542
|
-
|
|
2543
|
-
const key = getTimelineElementKey(element);
|
|
2544
|
-
if (!key) return;
|
|
2545
|
-
setInspectedTimelineElementId((current) => (current === key ? null : key));
|
|
2546
|
-
setLeftCollapsed(false);
|
|
2547
|
-
|
|
2548
|
-
const iframe = previewIframeRef.current;
|
|
2549
|
-
if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
|
|
2550
|
-
seekStudioPreview(iframe, element.start);
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
const selection = buildDomSelectionForTimelineElement(element);
|
|
2554
|
-
if (selection) applyDomSelection(selection);
|
|
2555
|
-
},
|
|
2556
|
-
[applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
|
|
2557
|
-
);
|
|
2558
|
-
|
|
2559
|
-
const handleTimelineLayerSelect = useCallback(
|
|
2560
|
-
(layer: DomEditLayerItem) => {
|
|
2561
|
-
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2562
|
-
|
|
2563
|
-
const iframe = previewIframeRef.current;
|
|
2564
|
-
const player = getPreviewPlayer(iframe?.contentWindow);
|
|
2565
|
-
const visibleTime = resolveLayerVisibleSeekTime(
|
|
2566
|
-
layer.element,
|
|
2567
|
-
inspectedTimelineElement,
|
|
2568
|
-
player,
|
|
2569
|
-
);
|
|
2570
|
-
if (visibleTime != null) {
|
|
2571
|
-
seekStudioPreview(iframe, visibleTime);
|
|
2572
|
-
}
|
|
2573
|
-
|
|
2574
|
-
const selection = buildDomSelectionFromTarget(layer.element, { preferClipAncestor: false });
|
|
2575
|
-
if (!selection) {
|
|
2576
|
-
showToast("Studio could not resolve this nested layer.", "error");
|
|
2577
|
-
return;
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
applyDomSelection(selection);
|
|
2581
|
-
requestAnimationFrame(refreshPreviewDocumentVersion);
|
|
2582
|
-
},
|
|
2583
|
-
[
|
|
2584
|
-
applyDomSelection,
|
|
2585
|
-
buildDomSelectionFromTarget,
|
|
2586
|
-
inspectedTimelineElement,
|
|
2587
|
-
refreshPreviewDocumentVersion,
|
|
2588
|
-
showToast,
|
|
2589
|
-
],
|
|
2590
|
-
);
|
|
2591
|
-
|
|
2592
|
-
const handleTimelineLayerPanelClose = useCallback(() => {
|
|
2593
|
-
setInspectedTimelineElementId(null);
|
|
2594
|
-
}, []);
|
|
2595
|
-
|
|
2596
|
-
const preloadAgentPromptSnippet = useCallback(
|
|
2597
|
-
async (selection: DomEditSelection) => {
|
|
2598
|
-
const pid = projectIdRef.current;
|
|
2599
|
-
if (!pid) return;
|
|
2600
|
-
|
|
2601
|
-
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
2602
|
-
try {
|
|
2603
|
-
const response = await fetch(
|
|
2604
|
-
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
2605
|
-
);
|
|
2606
|
-
if (!response.ok) return;
|
|
2607
|
-
|
|
2608
|
-
const data = (await response.json()) as { content?: string };
|
|
2609
|
-
const html = data.content;
|
|
2610
|
-
const tagSnippet =
|
|
2611
|
-
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
|
2612
|
-
|
|
2613
|
-
setAgentPromptTagSnippet((current) => {
|
|
2614
|
-
if (domEditSelectionRef.current !== selection) return current;
|
|
2615
|
-
return tagSnippet;
|
|
2616
|
-
});
|
|
2617
|
-
} catch {
|
|
2618
|
-
// Runtime outerHTML is still available as a synchronous copy fallback.
|
|
2619
|
-
}
|
|
2620
|
-
},
|
|
2621
|
-
[activeCompPath],
|
|
2622
|
-
);
|
|
2623
|
-
|
|
2624
|
-
const resolveImportedFontAsset = useCallback(
|
|
2625
|
-
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
2626
|
-
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
2627
|
-
if (!family) return null;
|
|
2628
|
-
const imported = importedFontAssetsRef.current.find(
|
|
2629
|
-
(font) => font.family.toLowerCase() === family.toLowerCase(),
|
|
2630
|
-
);
|
|
2631
|
-
if (imported) return imported;
|
|
2632
|
-
const asset = fileTree.find(
|
|
2633
|
-
(path) =>
|
|
2634
|
-
FONT_EXT.test(path) &&
|
|
2635
|
-
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
|
|
2636
|
-
);
|
|
2637
|
-
if (!asset) return null;
|
|
2638
|
-
return {
|
|
2639
|
-
family: fontFamilyFromAssetPath(asset),
|
|
2640
|
-
path: asset,
|
|
2641
|
-
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
2642
|
-
};
|
|
2643
|
-
},
|
|
2644
|
-
[fileTree, projectId],
|
|
2645
|
-
);
|
|
2646
|
-
|
|
2647
|
-
const persistDomEditOperations = useCallback(
|
|
2648
|
-
async (
|
|
2649
|
-
selection: DomEditSelection,
|
|
2650
|
-
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
2651
|
-
options?: {
|
|
2652
|
-
label?: string;
|
|
2653
|
-
coalesceKey?: string;
|
|
2654
|
-
skipRefresh?: boolean;
|
|
2655
|
-
prepareContent?: (html: string, sourceFile: string) => string;
|
|
2656
|
-
shouldSave?: () => boolean;
|
|
2657
|
-
},
|
|
2658
|
-
) => {
|
|
2659
|
-
const pid = projectIdRef.current;
|
|
2660
|
-
if (!pid) throw new Error("No active project");
|
|
2661
|
-
if (options?.shouldSave && !options.shouldSave()) return;
|
|
2662
|
-
|
|
2663
|
-
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
2664
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
2665
|
-
if (!response.ok) {
|
|
2666
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2669
|
-
const data = (await response.json()) as { content?: string };
|
|
2670
|
-
const originalContent = data.content;
|
|
2671
|
-
if (typeof originalContent !== "string") {
|
|
2672
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
2673
|
-
}
|
|
2674
|
-
|
|
2675
|
-
let patchedContent = originalContent;
|
|
2676
|
-
for (const operation of operations) {
|
|
2677
|
-
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
|
2678
|
-
}
|
|
2679
|
-
if (options?.prepareContent) {
|
|
2680
|
-
patchedContent = options.prepareContent(patchedContent, targetPath);
|
|
2681
|
-
}
|
|
2682
|
-
if (options?.shouldSave && !options.shouldSave()) return;
|
|
2683
|
-
|
|
2684
|
-
if (patchedContent === originalContent) {
|
|
2685
|
-
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
2686
|
-
}
|
|
2687
|
-
|
|
2688
|
-
await saveProjectFilesWithHistory({
|
|
2689
|
-
projectId: pid,
|
|
2690
|
-
label: options?.label ?? "Edit layer",
|
|
2691
|
-
kind: "manual",
|
|
2692
|
-
coalesceKey: options?.coalesceKey,
|
|
2693
|
-
files: { [targetPath]: patchedContent },
|
|
2694
|
-
readFile: async () => originalContent,
|
|
2695
|
-
writeFile: writeProjectFile,
|
|
2696
|
-
recordEdit: editHistory.recordEdit,
|
|
2697
|
-
});
|
|
2698
|
-
|
|
2699
|
-
if (options?.skipRefresh) {
|
|
2700
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
2701
|
-
} else {
|
|
2702
|
-
setRefreshKey((k) => k + 1);
|
|
2703
|
-
}
|
|
2704
|
-
},
|
|
2705
|
-
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
2706
|
-
);
|
|
2707
|
-
|
|
2708
|
-
const refreshDomEditSelectionFromPreview = useCallback(
|
|
2709
|
-
(selection: DomEditSelection) => {
|
|
2710
|
-
const iframe = previewIframeRef.current;
|
|
2711
|
-
let doc: Document | null = null;
|
|
2712
|
-
try {
|
|
2713
|
-
doc = iframe?.contentDocument ?? null;
|
|
2714
|
-
} catch {
|
|
2715
|
-
return;
|
|
2716
|
-
}
|
|
2717
|
-
if (!doc) return;
|
|
2718
|
-
|
|
2719
|
-
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2720
|
-
if (!element) return;
|
|
2721
|
-
|
|
2722
|
-
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2723
|
-
if (nextSelection) {
|
|
2724
|
-
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2725
|
-
}
|
|
2726
|
-
},
|
|
2727
|
-
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
|
|
2728
|
-
);
|
|
2729
|
-
|
|
2730
|
-
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
|
2731
|
-
(selections: DomEditSelection[]) => {
|
|
2732
|
-
const iframe = previewIframeRef.current;
|
|
2733
|
-
let doc: Document | null = null;
|
|
2734
|
-
try {
|
|
2735
|
-
doc = iframe?.contentDocument ?? null;
|
|
2736
|
-
} catch {
|
|
2737
|
-
return;
|
|
2738
|
-
}
|
|
2739
|
-
if (!doc) return;
|
|
2740
|
-
|
|
2741
|
-
const nextGroup: DomEditSelection[] = [];
|
|
2742
|
-
for (const selection of selections) {
|
|
2743
|
-
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2744
|
-
if (!element) continue;
|
|
2745
|
-
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2746
|
-
if (nextSelection) nextGroup.push(nextSelection);
|
|
2747
|
-
}
|
|
2748
|
-
if (nextGroup.length === 0) return;
|
|
2749
|
-
|
|
2750
|
-
const currentSelection = domEditSelectionRef.current;
|
|
2751
|
-
const nextSelection =
|
|
2752
|
-
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
|
2753
|
-
nextGroup[0] ??
|
|
2754
|
-
null;
|
|
2755
|
-
|
|
2756
|
-
setAgentPromptTagSnippet(undefined);
|
|
2757
|
-
setCopiedAgentPrompt(false);
|
|
2758
|
-
domEditSelectionRef.current = nextSelection;
|
|
2759
|
-
domEditGroupSelectionsRef.current = nextGroup;
|
|
2760
|
-
setDomEditSelection(nextSelection);
|
|
2761
|
-
setDomEditGroupSelections(nextGroup);
|
|
2762
|
-
|
|
2763
|
-
if (nextSelection) {
|
|
2764
|
-
setSelectedTimelineElementId(
|
|
2765
|
-
findMatchingTimelineElementId(nextSelection, timelineElements),
|
|
2766
|
-
);
|
|
2767
|
-
} else {
|
|
2768
|
-
setSelectedTimelineElementId(null);
|
|
2769
|
-
}
|
|
2770
|
-
},
|
|
2771
|
-
[activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
|
|
2772
|
-
);
|
|
2773
|
-
|
|
2774
|
-
const handleDomManualDragStart = useCallback(() => {
|
|
2775
|
-
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
|
|
2776
|
-
const playerStore = usePlayerStore.getState();
|
|
2777
|
-
playerStore.setIsPlaying(false);
|
|
2778
|
-
if (pausedTime != null) {
|
|
2779
|
-
playerStore.setCurrentTime(pausedTime);
|
|
2780
|
-
liveTime.notify(pausedTime);
|
|
2781
|
-
}
|
|
2782
|
-
}, []);
|
|
2783
|
-
|
|
2784
|
-
const handleDomPathOffsetCommit = useCallback(
|
|
2785
|
-
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
2786
|
-
commitStudioManualEditManifestOptimistically(
|
|
2787
|
-
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
|
|
2788
|
-
{
|
|
2789
|
-
label: "Move layer",
|
|
2790
|
-
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
2791
|
-
},
|
|
2792
|
-
);
|
|
2793
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
2794
|
-
},
|
|
2795
|
-
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2796
|
-
);
|
|
2797
|
-
|
|
2798
|
-
const handleDomGroupPathOffsetCommit = useCallback(
|
|
2799
|
-
(updates: DomEditGroupPathOffsetCommit[]) => {
|
|
2800
|
-
if (updates.length === 0) return;
|
|
2801
|
-
const coalesceKey = updates
|
|
2802
|
-
.map((update) => getDomEditTargetKey(update.selection))
|
|
2803
|
-
.sort()
|
|
2804
|
-
.join(":");
|
|
2805
|
-
commitStudioManualEditManifestOptimistically(
|
|
2806
|
-
(manifest) =>
|
|
2807
|
-
updates.reduce(
|
|
2808
|
-
(nextManifest, update) =>
|
|
2809
|
-
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
|
|
2810
|
-
manifest,
|
|
2811
|
-
),
|
|
2812
|
-
{
|
|
2813
|
-
label: `Move ${updates.length} layers`,
|
|
2814
|
-
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
2815
|
-
},
|
|
2816
|
-
);
|
|
2817
|
-
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
|
|
2818
|
-
},
|
|
2819
|
-
[commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
|
|
2820
|
-
);
|
|
2821
|
-
|
|
2822
|
-
const handleDomBoxSizeCommit = useCallback(
|
|
2823
|
-
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
2824
|
-
commitStudioManualEditManifestOptimistically(
|
|
2825
|
-
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
|
|
2826
|
-
{
|
|
2827
|
-
label: "Resize layer box",
|
|
2828
|
-
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
2829
|
-
},
|
|
2830
|
-
);
|
|
2831
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
2832
|
-
},
|
|
2833
|
-
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2834
|
-
);
|
|
2835
|
-
|
|
2836
|
-
const handleDomRotationCommit = useCallback(
|
|
2837
|
-
(selection: DomEditSelection, next: { angle: number }) => {
|
|
2838
|
-
commitStudioManualEditManifestOptimistically(
|
|
2839
|
-
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
|
|
2840
|
-
{
|
|
2841
|
-
label: "Rotate layer",
|
|
2842
|
-
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
2843
|
-
},
|
|
2844
|
-
);
|
|
2845
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
2846
|
-
},
|
|
2847
|
-
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2848
|
-
);
|
|
2849
|
-
|
|
2850
|
-
const handleDomManualEditsReset = useCallback(
|
|
2851
|
-
(selection: DomEditSelection) => {
|
|
2852
|
-
commitStudioManualEditManifestOptimistically(
|
|
2853
|
-
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
|
|
2854
|
-
{
|
|
2855
|
-
label: "Reset layer edits",
|
|
2856
|
-
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
|
2857
|
-
},
|
|
2858
|
-
);
|
|
2859
|
-
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2860
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
2861
|
-
},
|
|
2862
|
-
[
|
|
2863
|
-
applyCurrentStudioManualEditsToPreview,
|
|
2864
|
-
commitStudioManualEditManifestOptimistically,
|
|
2865
|
-
refreshDomEditSelectionFromPreview,
|
|
2866
|
-
],
|
|
2867
|
-
);
|
|
2868
|
-
|
|
2869
|
-
const handleDomMotionCommit = useCallback(
|
|
2870
|
-
(
|
|
2871
|
-
selection: DomEditSelection,
|
|
2872
|
-
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
|
2873
|
-
) => {
|
|
2874
|
-
commitStudioMotionManifestOptimistically(
|
|
2875
|
-
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
|
|
2876
|
-
{
|
|
2877
|
-
label: "Set GSAP motion",
|
|
2878
|
-
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
2879
|
-
},
|
|
2880
|
-
);
|
|
2881
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
2882
|
-
},
|
|
2883
|
-
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2884
|
-
);
|
|
2885
|
-
|
|
2886
|
-
const handleDomMotionClear = useCallback(
|
|
2887
|
-
(selection: DomEditSelection) => {
|
|
2888
|
-
commitStudioMotionManifestOptimistically(
|
|
2889
|
-
(manifest) => removeStudioMotionForSelection(manifest, selection),
|
|
2890
|
-
{
|
|
2891
|
-
label: "Clear GSAP motion",
|
|
2892
|
-
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
2893
|
-
},
|
|
2894
|
-
);
|
|
2895
|
-
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2896
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
2897
|
-
},
|
|
2898
|
-
[
|
|
2899
|
-
applyCurrentStudioMotionToPreview,
|
|
2900
|
-
commitStudioMotionManifestOptimistically,
|
|
2901
|
-
refreshDomEditSelectionFromPreview,
|
|
2902
|
-
],
|
|
2903
|
-
);
|
|
2904
|
-
|
|
2905
|
-
const handleDomStyleCommit = useCallback(
|
|
2906
|
-
async (property: string, value: string) => {
|
|
2907
|
-
if (!domEditSelection) return;
|
|
2908
|
-
if (isManualGeometryStyleProperty(property)) return;
|
|
2909
|
-
if (!domEditSelection.capabilities.canEditStyles) return;
|
|
2910
|
-
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
2911
|
-
const iframe = previewIframeRef.current;
|
|
2912
|
-
const doc = iframe?.contentDocument;
|
|
2913
|
-
if (doc) {
|
|
2914
|
-
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
2915
|
-
if (el) {
|
|
2916
|
-
el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
|
|
2917
|
-
if (property === "font-family") {
|
|
2918
|
-
injectPreviewGoogleFont(doc, value);
|
|
2919
|
-
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
2920
|
-
}
|
|
2921
|
-
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
2922
|
-
el.style.setProperty("background-position", "center");
|
|
2923
|
-
el.style.setProperty("background-repeat", "no-repeat");
|
|
2924
|
-
el.style.setProperty("background-size", "contain");
|
|
2925
|
-
}
|
|
2926
|
-
}
|
|
2927
|
-
}
|
|
2928
|
-
const operations: PatchOperation[] = [
|
|
2929
|
-
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
|
2930
|
-
];
|
|
2931
|
-
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
2932
|
-
operations.push(
|
|
2933
|
-
buildDomEditStylePatchOperation("background-position", "center"),
|
|
2934
|
-
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
|
2935
|
-
buildDomEditStylePatchOperation("background-size", "contain"),
|
|
2936
|
-
);
|
|
2937
|
-
}
|
|
2938
|
-
try {
|
|
2939
|
-
await persistDomEditOperations(domEditSelection, operations, {
|
|
2940
|
-
label: "Edit layer style",
|
|
2941
|
-
skipRefresh: true,
|
|
2942
|
-
prepareContent: importedFont
|
|
2943
|
-
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
2944
|
-
: undefined,
|
|
2945
|
-
});
|
|
2946
|
-
} catch (err) {
|
|
2947
|
-
console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
|
|
2948
|
-
}
|
|
2949
|
-
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
2950
|
-
},
|
|
2951
|
-
[
|
|
2952
|
-
activeCompPath,
|
|
2953
|
-
domEditSelection,
|
|
2954
|
-
persistDomEditOperations,
|
|
2955
|
-
refreshDomEditSelectionFromPreview,
|
|
2956
|
-
resolveImportedFontAsset,
|
|
2957
|
-
],
|
|
2958
|
-
);
|
|
2959
|
-
|
|
2960
|
-
const handleDomTextCommit = useCallback(
|
|
2961
|
-
async (value: string, fieldKey?: string) => {
|
|
2962
|
-
if (!domEditSelection) return;
|
|
2963
|
-
if (!isTextEditableSelection(domEditSelection)) return;
|
|
2964
|
-
const commitVersion = domTextCommitVersionRef.current + 1;
|
|
2965
|
-
domTextCommitVersionRef.current = commitVersion;
|
|
2966
|
-
const nextTextFields =
|
|
2967
|
-
domEditSelection.textFields.length > 0
|
|
2968
|
-
? domEditSelection.textFields.map((field) =>
|
|
2969
|
-
field.key === fieldKey ? { ...field, value } : field,
|
|
2970
|
-
)
|
|
2971
|
-
: [];
|
|
2972
|
-
const nextContent =
|
|
2973
|
-
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
2974
|
-
? serializeDomEditTextFields(nextTextFields)
|
|
2975
|
-
: value;
|
|
2976
|
-
const iframe = previewIframeRef.current;
|
|
2977
|
-
const doc = iframe?.contentDocument;
|
|
2978
|
-
if (doc) {
|
|
2979
|
-
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
2980
|
-
if (el) {
|
|
2981
|
-
if (
|
|
2982
|
-
nextTextFields.length > 1 ||
|
|
2983
|
-
nextTextFields.some((field) => field.source === "child")
|
|
2984
|
-
) {
|
|
2985
|
-
el.innerHTML = nextContent;
|
|
2986
|
-
} else {
|
|
2987
|
-
el.textContent = value;
|
|
2988
|
-
}
|
|
2989
|
-
}
|
|
2990
|
-
}
|
|
2991
|
-
await persistDomEditOperations(
|
|
2992
|
-
domEditSelection,
|
|
2993
|
-
[buildDomEditTextPatchOperation(nextContent)],
|
|
2994
|
-
{
|
|
2995
|
-
label: "Edit text",
|
|
2996
|
-
skipRefresh: true,
|
|
2997
|
-
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
2998
|
-
},
|
|
2999
|
-
);
|
|
3000
|
-
if (domTextCommitVersionRef.current !== commitVersion) return;
|
|
3001
|
-
|
|
3002
|
-
if (doc) {
|
|
3003
|
-
const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
3004
|
-
if (refreshed) {
|
|
3005
|
-
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
3006
|
-
if (nextSelection) {
|
|
3007
|
-
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
3008
|
-
}
|
|
3009
|
-
}
|
|
3010
|
-
}
|
|
3011
|
-
},
|
|
3012
|
-
[
|
|
3013
|
-
activeCompPath,
|
|
3014
|
-
applyDomSelection,
|
|
3015
|
-
buildDomSelectionFromTarget,
|
|
3016
|
-
domEditSelection,
|
|
3017
|
-
persistDomEditOperations,
|
|
3018
|
-
],
|
|
3019
|
-
);
|
|
3020
|
-
|
|
3021
|
-
const commitDomTextFields = useCallback(
|
|
3022
|
-
async (
|
|
3023
|
-
selection: DomEditSelection,
|
|
3024
|
-
nextTextFields: DomEditTextField[],
|
|
3025
|
-
options?: { importedFont?: ImportedFontAsset | null },
|
|
3026
|
-
) => {
|
|
3027
|
-
const nextContent =
|
|
3028
|
-
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
3029
|
-
? serializeDomEditTextFields(nextTextFields)
|
|
3030
|
-
: (nextTextFields[0]?.value ?? "");
|
|
5
|
+
import { usePlayerStore } from "./player";
|
|
6
|
+
import { LintModal } from "./components/LintModal";
|
|
7
|
+
import { useCaptionStore } from "./captions/store";
|
|
8
|
+
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
9
|
+
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
10
|
+
import { usePanelLayout } from "./hooks/usePanelLayout";
|
|
11
|
+
import { useFileManager } from "./hooks/useFileManager";
|
|
12
|
+
import { useManifestPersistence } from "./hooks/useManifestPersistence";
|
|
13
|
+
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
14
|
+
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
15
|
+
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
|
16
|
+
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
|
17
|
+
import { useRenderClipContent } from "./hooks/useRenderClipContent";
|
|
18
|
+
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
|
|
19
|
+
import { useFrameCapture } from "./hooks/useFrameCapture";
|
|
20
|
+
import { useLintModal } from "./hooks/useLintModal";
|
|
21
|
+
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
|
|
22
|
+
import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
|
|
23
|
+
import {
|
|
24
|
+
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
25
|
+
STUDIO_MOTION_PANEL_ENABLED,
|
|
26
|
+
} from "./components/editor/manualEditingAvailability";
|
|
27
|
+
import { getStudioMotionForSelection } from "./components/editor/studioMotion";
|
|
28
|
+
import { getTimelineElementKey, isTimelineElementActiveAtTime } from "./utils/timelineInspector";
|
|
29
|
+
import type { DomEditSelection } from "./components/editor/domEditing";
|
|
30
|
+
import type { AppToast } from "./utils/studioHelpers";
|
|
31
|
+
import { AskAgentModal } from "./components/AskAgentModal";
|
|
32
|
+
import { StudioHeader } from "./components/StudioHeader";
|
|
33
|
+
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
|
|
34
|
+
import { StudioPreviewArea } from "./components/StudioPreviewArea";
|
|
35
|
+
import { StudioRightPanel } from "./components/StudioRightPanel";
|
|
36
|
+
import { TimelineToolbar } from "./components/TimelineToolbar";
|
|
37
|
+
import { StudioProvider, type StudioContextValue } from "./contexts/StudioContext";
|
|
38
|
+
import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
|
|
39
|
+
import { FileManagerProvider } from "./contexts/FileManagerContext";
|
|
40
|
+
import { DomEditProvider } from "./contexts/DomEditContext";
|
|
3031
41
|
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
42
|
+
export function StudioApp() {
|
|
43
|
+
const [projectId, setProjectId] = useState<string | null>(null);
|
|
44
|
+
const [resolving, setResolving] = useState(true);
|
|
45
|
+
useMountEffect(() => {
|
|
46
|
+
const hashProjectId = parseProjectIdFromHash(window.location.hash);
|
|
47
|
+
if (hashProjectId) {
|
|
48
|
+
setProjectId(hashProjectId);
|
|
49
|
+
setResolving(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
fetch("/api/projects")
|
|
53
|
+
.then((r) => r.json())
|
|
54
|
+
.then((data) => {
|
|
55
|
+
const first = (data.projects ?? [])[0];
|
|
56
|
+
if (first) {
|
|
57
|
+
setProjectId(first.id);
|
|
58
|
+
window.location.hash = buildProjectHash(first.id);
|
|
3045
59
|
}
|
|
3046
|
-
}
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {})
|
|
62
|
+
.finally(() => setResolving(false));
|
|
63
|
+
});
|
|
3047
64
|
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
: undefined,
|
|
3055
|
-
});
|
|
65
|
+
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
66
|
+
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
67
|
+
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
68
|
+
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
69
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
70
|
+
const [, setPreviewDocumentVersion] = useState(0);
|
|
3056
71
|
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
);
|
|
72
|
+
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
73
|
+
const activeCompPathRef = useRef(activeCompPath);
|
|
74
|
+
activeCompPathRef.current = activeCompPath;
|
|
75
|
+
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
|
|
76
|
+
const renderQueue = useRenderQueue(projectId);
|
|
77
|
+
const captionEditMode = useCaptionStore((s) => s.isEditMode);
|
|
78
|
+
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
|
|
79
|
+
const captionSync = useCaptionSync(projectId);
|
|
80
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
81
|
+
const timelineElements = usePlayerStore((s) => s.elements);
|
|
82
|
+
const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
|
|
83
|
+
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
84
|
+
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
85
|
+
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
86
|
+
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
87
|
+
const activePreviewUrl = activeCompPath
|
|
88
|
+
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
89
|
+
: null;
|
|
90
|
+
const effectiveTimelineDuration = useMemo(() => {
|
|
91
|
+
const maxEnd =
|
|
92
|
+
timelineElements.length > 0
|
|
93
|
+
? Math.max(...timelineElements.map((el) => el.start + el.duration))
|
|
94
|
+
: 0;
|
|
95
|
+
return Math.max(timelineDuration, maxEnd);
|
|
96
|
+
}, [timelineDuration, timelineElements]);
|
|
97
|
+
const refreshPreviewDocumentVersion = useCallback(() => {
|
|
98
|
+
setPreviewDocumentVersion((v) => v + 1);
|
|
99
|
+
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80);
|
|
100
|
+
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300);
|
|
101
|
+
}, []);
|
|
3069
102
|
|
|
3070
|
-
const
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
103
|
+
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
104
|
+
const toggleTimelineVisibility = useCallback(() => setTimelineVisible((v) => !v), []);
|
|
105
|
+
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
106
|
+
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
107
|
+
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
108
|
+
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
109
|
+
setAppToast({ message, tone });
|
|
110
|
+
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
111
|
+
}, []);
|
|
3075
112
|
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
}
|
|
113
|
+
useMountEffect(() => () => {
|
|
114
|
+
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
115
|
+
});
|
|
3080
116
|
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
entry.key === fieldKey
|
|
3092
|
-
? {
|
|
3093
|
-
...entry,
|
|
3094
|
-
inlineStyles: {
|
|
3095
|
-
...entry.inlineStyles,
|
|
3096
|
-
[property]: normalizedValue,
|
|
3097
|
-
},
|
|
3098
|
-
computedStyles: {
|
|
3099
|
-
...entry.computedStyles,
|
|
3100
|
-
[property]: normalizedValue,
|
|
3101
|
-
},
|
|
3102
|
-
}
|
|
3103
|
-
: entry,
|
|
3104
|
-
);
|
|
117
|
+
const panelLayout = usePanelLayout();
|
|
118
|
+
const editHistory = usePersistentEditHistory({ projectId });
|
|
119
|
+
const domEditSaveTimestampRef = useRef(0);
|
|
120
|
+
const reloadPreview = useCallback(() => {
|
|
121
|
+
try {
|
|
122
|
+
previewIframeRef.current?.contentWindow?.location.reload();
|
|
123
|
+
} catch {
|
|
124
|
+
setRefreshKey((k) => k + 1);
|
|
125
|
+
}
|
|
126
|
+
}, []);
|
|
3105
127
|
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
128
|
+
const fileManager = useFileManager({
|
|
129
|
+
projectId,
|
|
130
|
+
showToast,
|
|
131
|
+
recordEdit: editHistory.recordEdit,
|
|
132
|
+
domEditSaveTimestampRef,
|
|
133
|
+
setRefreshKey,
|
|
134
|
+
});
|
|
3110
135
|
|
|
3111
|
-
const
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
136
|
+
const manifestPersistence = useManifestPersistence({
|
|
137
|
+
projectId,
|
|
138
|
+
showToast,
|
|
139
|
+
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
|
140
|
+
writeProjectFile: fileManager.writeProjectFile,
|
|
141
|
+
recordEdit: editHistory.recordEdit,
|
|
142
|
+
previewIframeRef,
|
|
143
|
+
activeCompPathRef,
|
|
144
|
+
});
|
|
3115
145
|
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
nextField,
|
|
3128
|
-
);
|
|
146
|
+
const timelineEditing = useTimelineEditing({
|
|
147
|
+
projectId,
|
|
148
|
+
activeCompPath,
|
|
149
|
+
timelineElements,
|
|
150
|
+
showToast,
|
|
151
|
+
writeProjectFile: fileManager.writeProjectFile,
|
|
152
|
+
recordEdit: editHistory.recordEdit,
|
|
153
|
+
domEditSaveTimestampRef,
|
|
154
|
+
reloadPreview,
|
|
155
|
+
uploadProjectFiles: fileManager.uploadProjectFiles,
|
|
156
|
+
});
|
|
3129
157
|
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
158
|
+
const clearDomSelectionRef = useRef<() => void>(() => {});
|
|
159
|
+
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
|
|
160
|
+
const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise<void>>(
|
|
161
|
+
async () => {},
|
|
3134
162
|
);
|
|
3135
163
|
|
|
3136
|
-
const
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
164
|
+
const appHotkeys = useAppHotkeys({
|
|
165
|
+
toggleTimelineVisibility,
|
|
166
|
+
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
|
|
167
|
+
handleDomEditElementDelete: async (s: DomEditSelection) =>
|
|
168
|
+
handleDomEditElementDeleteRef.current(s),
|
|
169
|
+
domEditSelectionRef: domEditSelectionBridgeRef,
|
|
170
|
+
clearDomSelectionRef,
|
|
171
|
+
editHistory,
|
|
172
|
+
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
|
173
|
+
readProjectFile: fileManager.readProjectFile,
|
|
174
|
+
writeProjectFile: fileManager.writeProjectFile,
|
|
175
|
+
domEditSaveTimestampRef,
|
|
176
|
+
showToast,
|
|
177
|
+
syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
|
|
178
|
+
waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
|
|
179
|
+
leftSidebarRef,
|
|
180
|
+
});
|
|
3146
181
|
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
182
|
+
const domEditSession = useDomEditSession({
|
|
183
|
+
projectId,
|
|
184
|
+
activeCompPath,
|
|
185
|
+
isMasterView,
|
|
186
|
+
compIdToSrc,
|
|
187
|
+
captionEditMode,
|
|
188
|
+
compositionLoading,
|
|
189
|
+
previewIframeRef,
|
|
190
|
+
timelineElements,
|
|
191
|
+
currentTime,
|
|
192
|
+
setSelectedTimelineElementId,
|
|
193
|
+
setRightCollapsed: panelLayout.setRightCollapsed,
|
|
194
|
+
setRightPanelTab: panelLayout.setRightPanelTab,
|
|
195
|
+
showToast,
|
|
196
|
+
refreshPreviewDocumentVersion,
|
|
197
|
+
commitStudioManualEditManifestOptimistically:
|
|
198
|
+
manifestPersistence.commitStudioManualEditManifestOptimistically,
|
|
199
|
+
commitStudioMotionManifestOptimistically:
|
|
200
|
+
manifestPersistence.commitStudioMotionManifestOptimistically,
|
|
201
|
+
applyCurrentStudioManualEditsToPreview:
|
|
202
|
+
manifestPersistence.applyCurrentStudioManualEditsToPreview,
|
|
203
|
+
applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview,
|
|
204
|
+
readProjectFile: fileManager.readProjectFile,
|
|
205
|
+
writeProjectFile: fileManager.writeProjectFile,
|
|
206
|
+
domEditSaveTimestampRef,
|
|
207
|
+
editHistory: { recordEdit: editHistory.recordEdit },
|
|
208
|
+
fileTree: fileManager.fileTree,
|
|
209
|
+
importedFontAssetsRef: fileManager.importedFontAssetsRef,
|
|
210
|
+
projectDir: fileManager.projectDir,
|
|
211
|
+
projectIdRef: fileManager.projectIdRef,
|
|
212
|
+
previewIframe,
|
|
213
|
+
refreshKey,
|
|
214
|
+
rightPanelTab: panelLayout.rightPanelTab,
|
|
215
|
+
applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef,
|
|
216
|
+
applyStudioMotionToPreviewRef: manifestPersistence.applyStudioMotionToPreviewRef,
|
|
217
|
+
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
|
|
218
|
+
reloadPreview,
|
|
219
|
+
setRefreshKey,
|
|
220
|
+
});
|
|
3152
221
|
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
setAgentPromptSelectionContext(undefined);
|
|
3157
|
-
setAgentModalAnchorPoint(null);
|
|
3158
|
-
void preloadAgentPromptSnippet(domEditSelection);
|
|
3159
|
-
setAgentModalOpen(true);
|
|
3160
|
-
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
222
|
+
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
|
223
|
+
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
|
224
|
+
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
|
|
3161
225
|
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
226
|
+
useCaptionDetection({
|
|
227
|
+
projectId,
|
|
228
|
+
activeCompPath,
|
|
229
|
+
compIdToSrc,
|
|
230
|
+
captionEditMode,
|
|
231
|
+
captionHasSelection,
|
|
232
|
+
previewIframeRef,
|
|
233
|
+
captionSync,
|
|
234
|
+
setRightCollapsed: panelLayout.setRightCollapsed,
|
|
235
|
+
});
|
|
3165
236
|
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
selectionContext: agentPromptSelectionContext,
|
|
3173
|
-
userInstruction,
|
|
3174
|
-
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
3175
|
-
});
|
|
237
|
+
const renderClipContent = useRenderClipContent({
|
|
238
|
+
projectIdRef: fileManager.projectIdRef,
|
|
239
|
+
compIdToSrc,
|
|
240
|
+
activePreviewUrl,
|
|
241
|
+
effectiveTimelineDuration,
|
|
242
|
+
});
|
|
3176
243
|
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
244
|
+
const compositionDimensions = useCompositionDimensions();
|
|
245
|
+
const { lintModal, linting, handleLint, closeLintModal } = useLintModal(projectId);
|
|
246
|
+
const frameCapture = useFrameCapture({
|
|
247
|
+
projectId,
|
|
248
|
+
activeCompPath,
|
|
249
|
+
showToast,
|
|
250
|
+
waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
|
|
251
|
+
});
|
|
252
|
+
const {
|
|
253
|
+
consoleErrors,
|
|
254
|
+
setConsoleErrors,
|
|
255
|
+
resetErrors: resetConsoleErrors,
|
|
256
|
+
} = useConsoleErrorCapture(previewIframe);
|
|
3182
257
|
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
setAgentModalAnchorPoint(null);
|
|
3186
|
-
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
3187
|
-
setCopiedAgentPrompt(true);
|
|
3188
|
-
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
3189
|
-
},
|
|
3190
|
-
[
|
|
3191
|
-
activeCompPath,
|
|
3192
|
-
agentPromptSelectionContext,
|
|
3193
|
-
agentPromptTagSnippet,
|
|
3194
|
-
currentTime,
|
|
3195
|
-
domEditSelection,
|
|
3196
|
-
projectDir,
|
|
3197
|
-
showToast,
|
|
3198
|
-
],
|
|
3199
|
-
);
|
|
258
|
+
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
259
|
+
const dragCounterRef = useRef(0);
|
|
3200
260
|
|
|
261
|
+
const { syncPreviewTimelineHotkey, syncPreviewHistoryHotkey } = appHotkeys;
|
|
3201
262
|
const handlePreviewIframeRef = useCallback(
|
|
3202
263
|
(iframe: HTMLIFrameElement | null) => {
|
|
3203
264
|
previewIframeRef.current = iframe;
|
|
3204
265
|
setPreviewIframe(iframe);
|
|
3205
266
|
syncPreviewTimelineHotkey(iframe);
|
|
3206
267
|
syncPreviewHistoryHotkey(iframe);
|
|
3207
|
-
|
|
3208
|
-
setConsoleErrors(null);
|
|
268
|
+
resetConsoleErrors();
|
|
3209
269
|
refreshPreviewDocumentVersion();
|
|
3210
270
|
},
|
|
3211
|
-
[refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
|
|
3212
|
-
);
|
|
3213
|
-
|
|
3214
|
-
const handlePreviewCanvasMouseDown = useCallback(
|
|
3215
|
-
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
3216
|
-
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
|
|
3217
|
-
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
3218
|
-
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
3219
|
-
});
|
|
3220
|
-
if (!nextSelection) {
|
|
3221
|
-
if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
|
|
3222
|
-
return;
|
|
3223
|
-
}
|
|
3224
|
-
e.preventDefault();
|
|
3225
|
-
e.stopPropagation();
|
|
3226
|
-
const localPointer = previewIframeRef.current
|
|
3227
|
-
? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
|
|
3228
|
-
: null;
|
|
3229
|
-
applyDomSelection(nextSelection, { additive: e.shiftKey });
|
|
3230
|
-
if (
|
|
3231
|
-
!e.shiftKey &&
|
|
3232
|
-
localPointer &&
|
|
3233
|
-
isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
|
|
3234
|
-
) {
|
|
3235
|
-
setAgentPromptSelectionContext(
|
|
3236
|
-
buildRasterClickSelectionContext(nextSelection, localPointer),
|
|
3237
|
-
);
|
|
3238
|
-
setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
|
|
3239
|
-
void preloadAgentPromptSnippet(nextSelection);
|
|
3240
|
-
setAgentModalOpen(true);
|
|
3241
|
-
}
|
|
3242
|
-
},
|
|
3243
|
-
[
|
|
3244
|
-
applyDomSelection,
|
|
3245
|
-
captionEditMode,
|
|
3246
|
-
compositionLoading,
|
|
3247
|
-
preloadAgentPromptSnippet,
|
|
3248
|
-
resolveDomSelectionFromPreviewPoint,
|
|
3249
|
-
],
|
|
3250
|
-
);
|
|
3251
|
-
|
|
3252
|
-
const handlePreviewCanvasPointerMove = useCallback(
|
|
3253
|
-
(e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
3254
|
-
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
|
|
3255
|
-
updateDomEditHoverSelection(null);
|
|
3256
|
-
return null;
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3259
|
-
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
3260
|
-
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
3261
|
-
});
|
|
3262
|
-
updateDomEditHoverSelection(nextSelection);
|
|
3263
|
-
return nextSelection;
|
|
3264
|
-
},
|
|
3265
271
|
[
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
272
|
+
refreshPreviewDocumentVersion,
|
|
273
|
+
resetConsoleErrors,
|
|
274
|
+
syncPreviewHistoryHotkey,
|
|
275
|
+
syncPreviewTimelineHotkey,
|
|
3270
276
|
],
|
|
3271
277
|
);
|
|
3272
278
|
|
|
3273
|
-
const
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
3283
|
-
useEffect(() => {
|
|
3284
|
-
updateDomEditHoverSelection(null);
|
|
3285
|
-
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
|
|
3286
|
-
|
|
3287
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
3288
|
-
useEffect(() => {
|
|
3289
|
-
if (!domEditHoverSelection) return;
|
|
3290
|
-
const hoverMatchesSelection = domEditSelectionsTargetSame(
|
|
3291
|
-
domEditHoverSelection,
|
|
3292
|
-
domEditSelection,
|
|
3293
|
-
);
|
|
3294
|
-
const hoverMatchesGroup = domEditSelectionInGroup(
|
|
3295
|
-
domEditGroupSelections,
|
|
3296
|
-
domEditHoverSelection,
|
|
3297
|
-
);
|
|
3298
|
-
if (!hoverMatchesSelection && !hoverMatchesGroup) return;
|
|
3299
|
-
updateDomEditHoverSelection(null);
|
|
3300
|
-
}, [
|
|
3301
|
-
domEditGroupSelections,
|
|
3302
|
-
domEditHoverSelection,
|
|
3303
|
-
domEditSelection,
|
|
3304
|
-
updateDomEditHoverSelection,
|
|
3305
|
-
]);
|
|
3306
|
-
|
|
3307
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
3308
|
-
useEffect(() => {
|
|
3309
|
-
if (!domEditHoverSelection) return;
|
|
3310
|
-
if (domEditHoverSelection.element.isConnected) return;
|
|
3311
|
-
updateDomEditHoverSelection(null);
|
|
3312
|
-
}, [domEditHoverSelection, updateDomEditHoverSelection]);
|
|
3313
|
-
|
|
3314
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
3315
|
-
useEffect(() => {
|
|
3316
|
-
if (!previewIframe) return;
|
|
3317
|
-
|
|
3318
|
-
const syncSelectionFromDocument = () => {
|
|
3319
|
-
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
|
|
3320
|
-
const currentSelection = domEditSelectionRef.current;
|
|
3321
|
-
if (!currentSelection) return;
|
|
3322
|
-
let doc: Document | null = null;
|
|
3323
|
-
try {
|
|
3324
|
-
doc = previewIframe.contentDocument;
|
|
3325
|
-
} catch {
|
|
3326
|
-
return;
|
|
3327
|
-
}
|
|
3328
|
-
if (!doc) return;
|
|
3329
|
-
|
|
3330
|
-
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
|
3331
|
-
if (!nextElement) {
|
|
3332
|
-
applyDomSelection(null, { revealPanel: false });
|
|
3333
|
-
return;
|
|
3334
|
-
}
|
|
3335
|
-
|
|
3336
|
-
const nextSelection = buildDomSelectionFromTarget(nextElement);
|
|
3337
|
-
if (nextSelection) {
|
|
3338
|
-
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
3339
|
-
}
|
|
3340
|
-
};
|
|
3341
|
-
|
|
3342
|
-
const attachErrorCapture = () => {
|
|
3343
|
-
try {
|
|
3344
|
-
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
|
|
3345
|
-
if (!win) return;
|
|
3346
|
-
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
3347
|
-
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
3348
|
-
const origError = win.console.error.bind(win.console);
|
|
3349
|
-
win.console.error = function (...args: unknown[]) {
|
|
3350
|
-
origError(...args);
|
|
3351
|
-
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
|
3352
|
-
if (text.includes("favicon")) return;
|
|
3353
|
-
consoleErrorsRef.current = [
|
|
3354
|
-
...consoleErrorsRef.current,
|
|
3355
|
-
{ severity: "error", message: text },
|
|
3356
|
-
];
|
|
3357
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
3358
|
-
};
|
|
3359
|
-
win.addEventListener("error", (e: ErrorEvent) => {
|
|
3360
|
-
const text = e.message || String(e);
|
|
3361
|
-
consoleErrorsRef.current = [
|
|
3362
|
-
...consoleErrorsRef.current,
|
|
3363
|
-
{ severity: "error", message: text },
|
|
3364
|
-
];
|
|
3365
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
3366
|
-
});
|
|
3367
|
-
} catch {
|
|
3368
|
-
// same-origin only
|
|
3369
|
-
}
|
|
3370
|
-
};
|
|
3371
|
-
|
|
3372
|
-
attachErrorCapture();
|
|
3373
|
-
syncPreviewHistoryHotkey(previewIframe);
|
|
3374
|
-
void (async () => {
|
|
3375
|
-
await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
3376
|
-
await applyStudioMotionToPreviewAfterRefresh(previewIframe);
|
|
3377
|
-
})();
|
|
3378
|
-
syncSelectionFromDocument();
|
|
3379
|
-
refreshPreviewDocumentVersion();
|
|
3380
|
-
|
|
3381
|
-
const handleLoad = () => {
|
|
3382
|
-
consoleErrorsRef.current = [];
|
|
3383
|
-
setConsoleErrors(null);
|
|
3384
|
-
attachErrorCapture();
|
|
3385
|
-
syncPreviewHistoryHotkey(previewIframe);
|
|
3386
|
-
void (async () => {
|
|
3387
|
-
await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
3388
|
-
await applyStudioMotionToPreviewAfterRefresh(previewIframe);
|
|
3389
|
-
})();
|
|
3390
|
-
syncSelectionFromDocument();
|
|
3391
|
-
refreshPreviewDocumentVersion();
|
|
3392
|
-
};
|
|
3393
|
-
|
|
3394
|
-
previewIframe.addEventListener("load", handleLoad);
|
|
3395
|
-
return () => {
|
|
3396
|
-
previewIframe.removeEventListener("load", handleLoad);
|
|
3397
|
-
};
|
|
3398
|
-
}, [
|
|
3399
|
-
activeCompPath,
|
|
3400
|
-
applyDomSelection,
|
|
3401
|
-
applyStudioManualEditsToPreviewAfterRefresh,
|
|
3402
|
-
applyStudioMotionToPreviewAfterRefresh,
|
|
3403
|
-
buildDomSelectionFromTarget,
|
|
3404
|
-
captionEditMode,
|
|
3405
|
-
previewIframe,
|
|
3406
|
-
refreshPreviewDocumentVersion,
|
|
3407
|
-
syncPreviewHistoryHotkey,
|
|
3408
|
-
]);
|
|
3409
|
-
|
|
3410
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
3411
|
-
useEffect(() => {
|
|
3412
|
-
if (!captionEditMode) return;
|
|
3413
|
-
applyDomSelection(null, { revealPanel: false });
|
|
3414
|
-
}, [applyDomSelection, captionEditMode]);
|
|
3415
|
-
|
|
3416
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
3417
|
-
useEffect(() => {
|
|
3418
|
-
if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
3419
|
-
updateDomEditHoverSelection(null);
|
|
3420
|
-
applyDomSelection(null, { revealPanel: false });
|
|
3421
|
-
if (rightPanelTab !== "renders") setRightPanelTab("renders");
|
|
3422
|
-
}, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection]);
|
|
3423
|
-
|
|
3424
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
3425
|
-
useEffect(
|
|
3426
|
-
() => () => {
|
|
3427
|
-
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
3428
|
-
},
|
|
3429
|
-
[],
|
|
3430
|
-
);
|
|
3431
|
-
|
|
3432
|
-
const refreshFileTree = useCallback(async () => {
|
|
3433
|
-
const pid = projectIdRef.current;
|
|
3434
|
-
if (!pid) return;
|
|
3435
|
-
const res = await fetch(`/api/projects/${pid}`);
|
|
3436
|
-
const data = await res.json();
|
|
3437
|
-
if (data.files) setFileTree(data.files);
|
|
3438
|
-
}, []);
|
|
3439
|
-
|
|
3440
|
-
const uploadProjectFiles = useCallback(
|
|
3441
|
-
async (files: Iterable<File>, dir?: string): Promise<string[]> => {
|
|
3442
|
-
const pid = projectIdRef.current;
|
|
3443
|
-
const fileList = Array.from(files);
|
|
3444
|
-
if (!pid || fileList.length === 0) return [];
|
|
3445
|
-
|
|
3446
|
-
const formData = new FormData();
|
|
3447
|
-
for (const file of fileList) {
|
|
3448
|
-
formData.append("file", file);
|
|
3449
|
-
}
|
|
3450
|
-
|
|
3451
|
-
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
3452
|
-
try {
|
|
3453
|
-
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
|
3454
|
-
method: "POST",
|
|
3455
|
-
body: formData,
|
|
3456
|
-
});
|
|
3457
|
-
if (res.ok) {
|
|
3458
|
-
const data = await res.json();
|
|
3459
|
-
if (data.skipped?.length) {
|
|
3460
|
-
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
|
3461
|
-
}
|
|
3462
|
-
if (data.invalid?.length) {
|
|
3463
|
-
const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
|
|
3464
|
-
showToast(`Unsupported media skipped: ${names}`);
|
|
3465
|
-
}
|
|
3466
|
-
await refreshFileTree();
|
|
3467
|
-
setRefreshKey((k) => k + 1);
|
|
3468
|
-
return Array.isArray(data.files) ? data.files : [];
|
|
3469
|
-
} else if (res.status === 413) {
|
|
3470
|
-
showToast("Upload rejected: payload too large");
|
|
3471
|
-
} else {
|
|
3472
|
-
showToast(`Upload failed (${res.status})`);
|
|
3473
|
-
}
|
|
3474
|
-
} catch {
|
|
3475
|
-
showToast("Upload failed: network error");
|
|
3476
|
-
}
|
|
3477
|
-
return [];
|
|
3478
|
-
},
|
|
3479
|
-
[refreshFileTree, showToast],
|
|
3480
|
-
);
|
|
3481
|
-
|
|
3482
|
-
const handleTimelineAssetDrop = useCallback(
|
|
3483
|
-
async (
|
|
3484
|
-
assetPath: string,
|
|
3485
|
-
placement: Pick<TimelineElement, "start" | "track">,
|
|
3486
|
-
durationOverride?: number,
|
|
3487
|
-
) => {
|
|
3488
|
-
const pid = projectIdRef.current;
|
|
3489
|
-
if (!pid) throw new Error("No active project");
|
|
3490
|
-
|
|
3491
|
-
const kind = getTimelineAssetKind(assetPath);
|
|
3492
|
-
if (!kind) {
|
|
3493
|
-
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
|
3494
|
-
return;
|
|
3495
|
-
}
|
|
3496
|
-
|
|
3497
|
-
const targetPath = activeCompPath || "index.html";
|
|
3498
|
-
try {
|
|
3499
|
-
const response = await fetch(
|
|
3500
|
-
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
3501
|
-
);
|
|
3502
|
-
if (!response.ok) {
|
|
3503
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
3504
|
-
}
|
|
3505
|
-
|
|
3506
|
-
const data = (await response.json()) as { content?: string };
|
|
3507
|
-
const originalContent = data.content;
|
|
3508
|
-
if (typeof originalContent !== "string") {
|
|
3509
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
3510
|
-
}
|
|
3511
|
-
|
|
3512
|
-
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
3513
|
-
const duration =
|
|
3514
|
-
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
|
3515
|
-
? durationOverride
|
|
3516
|
-
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
3517
|
-
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
3518
|
-
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
3519
|
-
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
3520
|
-
|
|
3521
|
-
const resolvedTargetPath = targetPath || "index.html";
|
|
3522
|
-
const relevantElements = timelineElements.filter(
|
|
3523
|
-
(timelineElement) =>
|
|
3524
|
-
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
3525
|
-
);
|
|
3526
|
-
const trackZIndices = buildTrackZIndexMap([
|
|
3527
|
-
...relevantElements.map((timelineElement) => timelineElement.track),
|
|
3528
|
-
placement.track,
|
|
3529
|
-
]);
|
|
3530
|
-
|
|
3531
|
-
let patchedContent = originalContent;
|
|
3532
|
-
for (const timelineElement of relevantElements) {
|
|
3533
|
-
const elementTarget = timelineElement.domId
|
|
3534
|
-
? {
|
|
3535
|
-
id: timelineElement.domId,
|
|
3536
|
-
selector: timelineElement.selector,
|
|
3537
|
-
selectorIndex: timelineElement.selectorIndex,
|
|
3538
|
-
}
|
|
3539
|
-
: timelineElement.selector
|
|
3540
|
-
? {
|
|
3541
|
-
selector: timelineElement.selector,
|
|
3542
|
-
selectorIndex: timelineElement.selectorIndex,
|
|
3543
|
-
}
|
|
3544
|
-
: null;
|
|
3545
|
-
if (!elementTarget) continue;
|
|
3546
|
-
const nextZIndex = trackZIndices.get(timelineElement.track);
|
|
3547
|
-
if (nextZIndex == null) continue;
|
|
3548
|
-
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
3549
|
-
type: "inline-style",
|
|
3550
|
-
property: "z-index",
|
|
3551
|
-
value: String(nextZIndex),
|
|
3552
|
-
});
|
|
3553
|
-
}
|
|
3554
|
-
|
|
3555
|
-
patchedContent = insertTimelineAssetIntoSource(
|
|
3556
|
-
patchedContent,
|
|
3557
|
-
buildTimelineAssetInsertHtml({
|
|
3558
|
-
id: newId,
|
|
3559
|
-
assetPath: resolvedAssetSrc,
|
|
3560
|
-
kind,
|
|
3561
|
-
start: normalizedStart,
|
|
3562
|
-
duration: normalizedDuration,
|
|
3563
|
-
track: placement.track,
|
|
3564
|
-
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
3565
|
-
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
|
3566
|
-
}),
|
|
3567
|
-
);
|
|
3568
|
-
|
|
3569
|
-
await saveProjectFilesWithHistory({
|
|
3570
|
-
projectId: pid,
|
|
3571
|
-
label: "Add timeline asset",
|
|
3572
|
-
kind: "timeline",
|
|
3573
|
-
files: { [targetPath]: patchedContent },
|
|
3574
|
-
readFile: async () => originalContent,
|
|
3575
|
-
writeFile: writeProjectFile,
|
|
3576
|
-
recordEdit: editHistory.recordEdit,
|
|
3577
|
-
});
|
|
3578
|
-
|
|
3579
|
-
setRefreshKey((k) => k + 1);
|
|
3580
|
-
} catch (error) {
|
|
3581
|
-
const message =
|
|
3582
|
-
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
|
3583
|
-
showToast(message);
|
|
3584
|
-
}
|
|
3585
|
-
},
|
|
3586
|
-
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
3587
|
-
);
|
|
3588
|
-
|
|
3589
|
-
const handleTimelineFileDrop = useCallback(
|
|
3590
|
-
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
3591
|
-
const pid = projectIdRef.current;
|
|
3592
|
-
if (!pid) return;
|
|
3593
|
-
const uploaded = await uploadProjectFiles(files);
|
|
3594
|
-
if (uploaded.length === 0) return;
|
|
3595
|
-
const durations: number[] = [];
|
|
3596
|
-
for (const assetPath of uploaded) {
|
|
3597
|
-
const kind = getTimelineAssetKind(assetPath);
|
|
3598
|
-
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
|
3599
|
-
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
|
3600
|
-
}
|
|
3601
|
-
const placements = buildTimelineFileDropPlacements(
|
|
3602
|
-
placement ?? { start: 0, track: 0 },
|
|
3603
|
-
durations,
|
|
3604
|
-
timelineElements
|
|
3605
|
-
.filter(
|
|
3606
|
-
(timelineElement) =>
|
|
3607
|
-
(timelineElement.sourceFile || activeCompPath || "index.html") ===
|
|
3608
|
-
(activeCompPath || "index.html"),
|
|
3609
|
-
)
|
|
3610
|
-
.map((timelineElement) => ({
|
|
3611
|
-
start: timelineElement.start,
|
|
3612
|
-
duration: timelineElement.duration,
|
|
3613
|
-
track: timelineElement.track,
|
|
3614
|
-
})),
|
|
3615
|
-
);
|
|
3616
|
-
for (const [index, assetPath] of uploaded.entries()) {
|
|
3617
|
-
await handleTimelineAssetDrop(
|
|
3618
|
-
assetPath,
|
|
3619
|
-
placements[index] ?? placements[0],
|
|
3620
|
-
durations[index],
|
|
3621
|
-
);
|
|
3622
|
-
}
|
|
3623
|
-
},
|
|
3624
|
-
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
|
|
3625
|
-
);
|
|
3626
|
-
|
|
3627
|
-
// ── File Management Handlers ──
|
|
3628
|
-
|
|
3629
|
-
const handleCreateFile = useCallback(
|
|
3630
|
-
async (path: string) => {
|
|
3631
|
-
const pid = projectIdRef.current;
|
|
3632
|
-
if (!pid) return;
|
|
3633
|
-
let content = "";
|
|
3634
|
-
if (path.endsWith(".html")) {
|
|
3635
|
-
content =
|
|
3636
|
-
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
|
|
3637
|
-
}
|
|
3638
|
-
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
3639
|
-
method: "POST",
|
|
3640
|
-
headers: { "Content-Type": "text/plain" },
|
|
3641
|
-
body: content,
|
|
3642
|
-
});
|
|
3643
|
-
if (res.ok) {
|
|
3644
|
-
await refreshFileTree();
|
|
3645
|
-
handleFileSelect(path);
|
|
3646
|
-
} else {
|
|
3647
|
-
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
3648
|
-
console.error(`Create file failed: ${err.error}`);
|
|
3649
|
-
}
|
|
3650
|
-
},
|
|
3651
|
-
[refreshFileTree, handleFileSelect],
|
|
3652
|
-
);
|
|
3653
|
-
|
|
3654
|
-
const handleCreateFolder = useCallback(
|
|
3655
|
-
async (path: string) => {
|
|
3656
|
-
const pid = projectIdRef.current;
|
|
3657
|
-
if (!pid) return;
|
|
3658
|
-
// Create a .gitkeep inside the folder so it appears in the tree
|
|
3659
|
-
const res = await fetch(
|
|
3660
|
-
`/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
|
3661
|
-
{
|
|
3662
|
-
method: "POST",
|
|
3663
|
-
headers: { "Content-Type": "text/plain" },
|
|
3664
|
-
body: "",
|
|
3665
|
-
},
|
|
3666
|
-
);
|
|
3667
|
-
if (res.ok) {
|
|
3668
|
-
await refreshFileTree();
|
|
3669
|
-
} else {
|
|
3670
|
-
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
3671
|
-
console.error(`Create folder failed: ${err.error}`);
|
|
3672
|
-
}
|
|
3673
|
-
},
|
|
3674
|
-
[refreshFileTree],
|
|
3675
|
-
);
|
|
3676
|
-
|
|
3677
|
-
const handleDeleteFile = useCallback(
|
|
3678
|
-
async (path: string) => {
|
|
3679
|
-
const pid = projectIdRef.current;
|
|
3680
|
-
if (!pid) return;
|
|
3681
|
-
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
3682
|
-
method: "DELETE",
|
|
3683
|
-
});
|
|
3684
|
-
if (res.ok) {
|
|
3685
|
-
if (editingPathRef.current === path) setEditingFile(null);
|
|
3686
|
-
await refreshFileTree();
|
|
3687
|
-
} else {
|
|
3688
|
-
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
3689
|
-
console.error(`Delete failed: ${err.error}`);
|
|
3690
|
-
}
|
|
3691
|
-
},
|
|
3692
|
-
[refreshFileTree],
|
|
3693
|
-
);
|
|
3694
|
-
|
|
3695
|
-
const handleRenameFile = useCallback(
|
|
3696
|
-
async (oldPath: string, newPath: string) => {
|
|
3697
|
-
const pid = projectIdRef.current;
|
|
3698
|
-
if (!pid) return;
|
|
3699
|
-
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
|
|
3700
|
-
method: "PATCH",
|
|
3701
|
-
headers: { "Content-Type": "application/json" },
|
|
3702
|
-
body: JSON.stringify({ newPath }),
|
|
3703
|
-
});
|
|
3704
|
-
if (res.ok) {
|
|
3705
|
-
if (editingPathRef.current === oldPath) {
|
|
3706
|
-
handleFileSelect(newPath);
|
|
3707
|
-
}
|
|
3708
|
-
await refreshFileTree();
|
|
3709
|
-
// Refresh preview — references in compositions may have been updated
|
|
3710
|
-
setRefreshKey((k) => k + 1);
|
|
3711
|
-
} else {
|
|
3712
|
-
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
3713
|
-
console.error(`Rename failed: ${err.error}`);
|
|
3714
|
-
}
|
|
3715
|
-
},
|
|
3716
|
-
[refreshFileTree, handleFileSelect],
|
|
3717
|
-
);
|
|
3718
|
-
|
|
3719
|
-
const handleDuplicateFile = useCallback(
|
|
3720
|
-
async (path: string) => {
|
|
3721
|
-
const pid = projectIdRef.current;
|
|
3722
|
-
if (!pid) return;
|
|
3723
|
-
const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
|
|
3724
|
-
method: "POST",
|
|
3725
|
-
headers: { "Content-Type": "application/json" },
|
|
3726
|
-
body: JSON.stringify({ path }),
|
|
3727
|
-
});
|
|
3728
|
-
if (res.ok) {
|
|
3729
|
-
const data = await res.json();
|
|
3730
|
-
await refreshFileTree();
|
|
3731
|
-
if (data.path) handleFileSelect(data.path);
|
|
3732
|
-
} else {
|
|
3733
|
-
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
3734
|
-
console.error(`Duplicate failed: ${err.error}`);
|
|
3735
|
-
}
|
|
3736
|
-
},
|
|
3737
|
-
[refreshFileTree, handleFileSelect],
|
|
3738
|
-
);
|
|
3739
|
-
|
|
3740
|
-
const handleMoveFile = handleRenameFile;
|
|
3741
|
-
|
|
3742
|
-
const handleImportFiles = useCallback(
|
|
3743
|
-
async (files: FileList | File[], dir?: string) => {
|
|
3744
|
-
return uploadProjectFiles(Array.from(files), dir);
|
|
3745
|
-
},
|
|
3746
|
-
[uploadProjectFiles],
|
|
3747
|
-
);
|
|
3748
|
-
|
|
3749
|
-
const handleImportFonts = useCallback(
|
|
3750
|
-
async (files: FileList | File[]) => {
|
|
3751
|
-
const uploaded = await uploadProjectFiles(
|
|
3752
|
-
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
|
3753
|
-
"assets/fonts",
|
|
3754
|
-
);
|
|
3755
|
-
const pid = projectIdRef.current;
|
|
3756
|
-
const imported = uploaded
|
|
3757
|
-
.filter((asset) => FONT_EXT.test(asset))
|
|
3758
|
-
.map((asset) => ({
|
|
3759
|
-
family: fontFamilyFromAssetPath(asset),
|
|
3760
|
-
path: asset,
|
|
3761
|
-
url: `/api/projects/${pid}/preview/${asset}`,
|
|
3762
|
-
}));
|
|
3763
|
-
importedFontAssetsRef.current = [
|
|
3764
|
-
...imported,
|
|
3765
|
-
...importedFontAssetsRef.current.filter(
|
|
3766
|
-
(existing) =>
|
|
3767
|
-
!imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
|
|
3768
|
-
),
|
|
3769
|
-
];
|
|
3770
|
-
return imported;
|
|
3771
|
-
},
|
|
3772
|
-
[uploadProjectFiles],
|
|
3773
|
-
);
|
|
3774
|
-
|
|
3775
|
-
const handleLint = useCallback(async () => {
|
|
3776
|
-
const pid = projectIdRef.current;
|
|
3777
|
-
if (!pid) return;
|
|
3778
|
-
setLinting(true);
|
|
3779
|
-
try {
|
|
3780
|
-
const res = await fetch(`/api/projects/${pid}/lint`);
|
|
3781
|
-
const data = await res.json();
|
|
3782
|
-
const findings: LintFinding[] = (data.findings ?? []).map(
|
|
3783
|
-
(f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
|
|
3784
|
-
severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
|
|
3785
|
-
message: f.message ?? "",
|
|
3786
|
-
file: f.file,
|
|
3787
|
-
fixHint: f.fixHint,
|
|
3788
|
-
}),
|
|
3789
|
-
);
|
|
3790
|
-
setLintModal(findings);
|
|
3791
|
-
} catch (err) {
|
|
3792
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3793
|
-
setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
|
|
3794
|
-
} finally {
|
|
3795
|
-
setLinting(false);
|
|
3796
|
-
}
|
|
3797
|
-
}, []);
|
|
3798
|
-
|
|
3799
|
-
// Panel resize via pointer events (works for both left sidebar and right panel)
|
|
3800
|
-
const handlePanelResizeStart = useCallback(
|
|
3801
|
-
(side: "left" | "right", e: React.PointerEvent) => {
|
|
3802
|
-
e.preventDefault();
|
|
3803
|
-
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
3804
|
-
panelDragRef.current = {
|
|
3805
|
-
side,
|
|
3806
|
-
startX: e.clientX,
|
|
3807
|
-
startW: side === "left" ? leftWidth : rightWidth,
|
|
3808
|
-
};
|
|
279
|
+
const handleSelectComposition = useCallback(
|
|
280
|
+
(comp: string) => {
|
|
281
|
+
setActiveCompPath(comp === "index.html" || comp.startsWith("compositions/") ? comp : null);
|
|
282
|
+
fileManager.setEditingFile({ path: comp, content: null });
|
|
283
|
+
fetch(`/api/projects/${projectId}/files/${comp}`)
|
|
284
|
+
.then((r) => r.json())
|
|
285
|
+
.then((data) => fileManager.setEditingFile({ path: comp, content: data.content }))
|
|
286
|
+
.catch(() => {});
|
|
3809
287
|
},
|
|
3810
|
-
[
|
|
288
|
+
[projectId, fileManager],
|
|
3811
289
|
);
|
|
3812
290
|
|
|
3813
|
-
const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
|
|
3814
|
-
const drag = panelDragRef.current;
|
|
3815
|
-
if (!drag) return;
|
|
3816
|
-
const delta = e.clientX - drag.startX;
|
|
3817
|
-
const maxLeft = Math.floor(window.innerWidth * 0.5);
|
|
3818
|
-
const newW = Math.max(
|
|
3819
|
-
160,
|
|
3820
|
-
Math.min(
|
|
3821
|
-
drag.side === "left" ? maxLeft : 600,
|
|
3822
|
-
drag.startW + (drag.side === "left" ? delta : -delta),
|
|
3823
|
-
),
|
|
3824
|
-
);
|
|
3825
|
-
if (drag.side === "left") setLeftWidth(newW);
|
|
3826
|
-
else setRightWidth(newW);
|
|
3827
|
-
}, []);
|
|
3828
|
-
|
|
3829
|
-
const handlePanelResizeEnd = useCallback(() => {
|
|
3830
|
-
panelDragRef.current = null;
|
|
3831
|
-
}, []);
|
|
3832
|
-
|
|
3833
|
-
const compositions = useMemo(
|
|
3834
|
-
() => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
|
|
3835
|
-
[fileTree],
|
|
3836
|
-
);
|
|
3837
|
-
const assets = useMemo(
|
|
3838
|
-
() =>
|
|
3839
|
-
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
3840
|
-
[fileTree],
|
|
3841
|
-
);
|
|
3842
|
-
const fontAssets = useMemo<ImportedFontAsset[]>(
|
|
3843
|
-
() =>
|
|
3844
|
-
assets
|
|
3845
|
-
.filter((asset) => FONT_EXT.test(asset))
|
|
3846
|
-
.map((asset) => ({
|
|
3847
|
-
family: fontFamilyFromAssetPath(asset),
|
|
3848
|
-
path: asset,
|
|
3849
|
-
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
3850
|
-
})),
|
|
3851
|
-
[assets, projectId],
|
|
3852
|
-
);
|
|
3853
291
|
const selectedStudioMotion =
|
|
3854
|
-
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
|
|
3855
|
-
? getStudioMotionForSelection(
|
|
292
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
|
|
293
|
+
? getStudioMotionForSelection(
|
|
294
|
+
manifestPersistence.studioMotionManifestRef.current,
|
|
295
|
+
domEditSession.domEditSelection,
|
|
296
|
+
)
|
|
3856
297
|
: null;
|
|
3857
298
|
const selectedTimelineElement = useMemo(
|
|
3858
299
|
() =>
|
|
3859
300
|
selectedTimelineElementId
|
|
3860
|
-
? (timelineElements.find(
|
|
3861
|
-
|
|
3862
|
-
) ?? null)
|
|
301
|
+
? (timelineElements.find((el) => getTimelineElementKey(el) === selectedTimelineElementId) ??
|
|
302
|
+
null)
|
|
3863
303
|
: null,
|
|
3864
304
|
[selectedTimelineElementId, timelineElements],
|
|
3865
305
|
);
|
|
3866
|
-
const designPanelActive =
|
|
306
|
+
const designPanelActive =
|
|
307
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "design";
|
|
3867
308
|
const motionPanelActive =
|
|
3868
|
-
STUDIO_INSPECTOR_PANELS_ENABLED &&
|
|
309
|
+
STUDIO_INSPECTOR_PANELS_ENABLED &&
|
|
310
|
+
STUDIO_MOTION_PANEL_ENABLED &&
|
|
311
|
+
panelLayout.rightPanelTab === "motion";
|
|
3869
312
|
const inspectorPanelActive = designPanelActive || motionPanelActive;
|
|
3870
313
|
const shouldShowSelectedDomBounds =
|
|
3871
314
|
inspectorPanelActive &&
|
|
3872
|
-
!rightCollapsed &&
|
|
315
|
+
!panelLayout.rightCollapsed &&
|
|
316
|
+
!isPlaying &&
|
|
3873
317
|
(!selectedTimelineElement ||
|
|
3874
318
|
isTimelineElementActiveAtTime(currentTime, selectedTimelineElement));
|
|
3875
319
|
const inspectorButtonActive =
|
|
3876
|
-
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive;
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
320
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && !panelLayout.rightCollapsed && inspectorPanelActive;
|
|
321
|
+
|
|
322
|
+
// StudioProvider performs its own useMemo — no need for a second memo here.
|
|
323
|
+
const studioCtxValue: StudioContextValue = {
|
|
324
|
+
projectId: projectId!,
|
|
325
|
+
activeCompPath,
|
|
326
|
+
setActiveCompPath,
|
|
327
|
+
showToast,
|
|
328
|
+
previewIframeRef,
|
|
329
|
+
captionEditMode,
|
|
330
|
+
compositionLoading,
|
|
331
|
+
refreshKey,
|
|
332
|
+
setRefreshKey,
|
|
333
|
+
currentTime,
|
|
334
|
+
timelineElements,
|
|
335
|
+
isPlaying,
|
|
336
|
+
editHistory: {
|
|
337
|
+
canUndo: editHistory.canUndo,
|
|
338
|
+
canRedo: editHistory.canRedo,
|
|
339
|
+
undoLabel: editHistory.undoLabel,
|
|
340
|
+
redoLabel: editHistory.redoLabel,
|
|
341
|
+
},
|
|
342
|
+
handleUndo: appHotkeys.handleUndo,
|
|
343
|
+
handleRedo: appHotkeys.handleRedo,
|
|
344
|
+
renderQueue: {
|
|
345
|
+
jobs: renderQueue.jobs,
|
|
346
|
+
isRendering: renderQueue.isRendering,
|
|
347
|
+
deleteRender: renderQueue.deleteRender,
|
|
348
|
+
clearCompleted: renderQueue.clearCompleted,
|
|
349
|
+
startRender: renderQueue.startRender as (options: unknown) => Promise<void>,
|
|
350
|
+
},
|
|
351
|
+
compositionDimensions,
|
|
352
|
+
waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
|
|
353
|
+
handlePreviewIframeRef,
|
|
354
|
+
refreshPreviewDocumentVersion,
|
|
355
|
+
timelineVisible,
|
|
356
|
+
toggleTimelineVisibility,
|
|
357
|
+
};
|
|
3889
358
|
|
|
3890
359
|
if (resolving || !projectId) {
|
|
3891
360
|
return (
|
|
@@ -3895,481 +364,141 @@ export function StudioApp() {
|
|
|
3895
364
|
);
|
|
3896
365
|
}
|
|
3897
366
|
|
|
3898
|
-
|
|
3899
|
-
|
|
367
|
+
const timelineToolbar = <TimelineToolbar toggleTimelineVisibility={toggleTimelineVisibility} />;
|
|
3900
368
|
return (
|
|
3901
|
-
<
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
<div className="flex items-center gap-2">
|
|
3930
|
-
<span className="text-[11px] font-medium text-neutral-400">{projectId}</span>
|
|
3931
|
-
</div>
|
|
3932
|
-
{/* Right: toolbar buttons */}
|
|
3933
|
-
<div className="flex items-center gap-1.5">
|
|
3934
|
-
<button
|
|
3935
|
-
type="button"
|
|
3936
|
-
onClick={() => void handleUndo()}
|
|
3937
|
-
disabled={!editHistory.canUndo}
|
|
3938
|
-
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
3939
|
-
editHistory.canUndo
|
|
3940
|
-
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
3941
|
-
: "border-neutral-900 text-neutral-700"
|
|
3942
|
-
}`}
|
|
3943
|
-
title={
|
|
3944
|
-
editHistory.undoLabel
|
|
3945
|
-
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
|
|
3946
|
-
: `Undo (${getHistoryShortcutLabel("undo")})`
|
|
3947
|
-
}
|
|
3948
|
-
aria-label="Undo"
|
|
3949
|
-
>
|
|
3950
|
-
<RotateCcw size={14} />
|
|
3951
|
-
</button>
|
|
3952
|
-
<button
|
|
3953
|
-
type="button"
|
|
3954
|
-
onClick={() => void handleRedo()}
|
|
3955
|
-
disabled={!editHistory.canRedo}
|
|
3956
|
-
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
3957
|
-
editHistory.canRedo
|
|
3958
|
-
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
3959
|
-
: "border-neutral-900 text-neutral-700"
|
|
3960
|
-
}`}
|
|
3961
|
-
title={
|
|
3962
|
-
editHistory.redoLabel
|
|
3963
|
-
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
|
|
3964
|
-
: `Redo (${getHistoryShortcutLabel("redo")})`
|
|
3965
|
-
}
|
|
3966
|
-
aria-label="Redo"
|
|
3967
|
-
>
|
|
3968
|
-
<RotateCw size={14} />
|
|
3969
|
-
</button>
|
|
3970
|
-
<a
|
|
3971
|
-
href={captureFrameHref}
|
|
3972
|
-
download={captureFrameFilename}
|
|
3973
|
-
onClick={handleCaptureFrameClick}
|
|
3974
|
-
onFocus={refreshCaptureFrameTime}
|
|
3975
|
-
onPointerDown={refreshCaptureFrameTime}
|
|
3976
|
-
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"
|
|
3977
|
-
title="Capture current frame"
|
|
3978
|
-
aria-label="Capture current frame"
|
|
3979
|
-
>
|
|
3980
|
-
<Camera size={14} />
|
|
3981
|
-
<span>Capture</span>
|
|
3982
|
-
</a>
|
|
3983
|
-
<button
|
|
3984
|
-
type="button"
|
|
3985
|
-
onClick={() => {
|
|
3986
|
-
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
3987
|
-
if (rightCollapsed || !inspectorPanelActive) {
|
|
3988
|
-
setRightPanelTab("design");
|
|
3989
|
-
setRightCollapsed(false);
|
|
3990
|
-
return;
|
|
3991
|
-
}
|
|
3992
|
-
clearDomSelection();
|
|
3993
|
-
setRightCollapsed(true);
|
|
3994
|
-
}}
|
|
3995
|
-
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
|
|
3996
|
-
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
3997
|
-
inspectorButtonActive
|
|
3998
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
3999
|
-
: STUDIO_INSPECTOR_PANELS_ENABLED
|
|
4000
|
-
? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
|
|
4001
|
-
: "cursor-not-allowed border-transparent text-neutral-700"
|
|
4002
|
-
}`}
|
|
4003
|
-
title={
|
|
4004
|
-
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
|
4005
|
-
}
|
|
4006
|
-
aria-label={
|
|
4007
|
-
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
|
4008
|
-
}
|
|
4009
|
-
>
|
|
4010
|
-
<svg
|
|
4011
|
-
width="12"
|
|
4012
|
-
height="12"
|
|
4013
|
-
viewBox="0 0 24 24"
|
|
4014
|
-
fill="none"
|
|
4015
|
-
stroke="currentColor"
|
|
4016
|
-
strokeWidth="2"
|
|
369
|
+
<StudioProvider value={studioCtxValue}>
|
|
370
|
+
<PanelLayoutProvider value={panelLayout}>
|
|
371
|
+
<FileManagerProvider value={fileManager}>
|
|
372
|
+
<DomEditProvider value={domEditSession}>
|
|
373
|
+
<div
|
|
374
|
+
className="flex flex-col h-full w-full bg-neutral-950 relative"
|
|
375
|
+
onDragOver={(e) => {
|
|
376
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
}}
|
|
379
|
+
onDragEnter={(e) => {
|
|
380
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
dragCounterRef.current++;
|
|
383
|
+
setGlobalDragOver(true);
|
|
384
|
+
}}
|
|
385
|
+
onDragLeave={() => {
|
|
386
|
+
dragCounterRef.current--;
|
|
387
|
+
if (dragCounterRef.current === 0) setGlobalDragOver(false);
|
|
388
|
+
}}
|
|
389
|
+
onDrop={(e) => {
|
|
390
|
+
dragCounterRef.current = 0;
|
|
391
|
+
setGlobalDragOver(false);
|
|
392
|
+
if (e.defaultPrevented) return;
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
if (e.dataTransfer.files.length)
|
|
395
|
+
fileManager.handleImportFiles(e.dataTransfer.files);
|
|
396
|
+
}}
|
|
4017
397
|
>
|
|
4018
|
-
<
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
398
|
+
<StudioHeader
|
|
399
|
+
captureFrameHref={frameCapture.captureFrameHref}
|
|
400
|
+
captureFrameFilename={frameCapture.captureFrameFilename}
|
|
401
|
+
handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
|
|
402
|
+
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
|
|
403
|
+
inspectorButtonActive={inspectorButtonActive}
|
|
404
|
+
inspectorPanelActive={inspectorPanelActive}
|
|
405
|
+
/>
|
|
406
|
+
|
|
407
|
+
<div className="flex flex-1 min-h-0">
|
|
408
|
+
<StudioLeftSidebar
|
|
409
|
+
leftSidebarRef={leftSidebarRef}
|
|
410
|
+
onSelectComposition={handleSelectComposition}
|
|
411
|
+
onLint={handleLint}
|
|
412
|
+
linting={linting}
|
|
413
|
+
/>
|
|
414
|
+
<StudioPreviewArea
|
|
415
|
+
timelineToolbar={timelineToolbar}
|
|
416
|
+
renderClipContent={renderClipContent}
|
|
417
|
+
handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
|
|
418
|
+
handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
|
|
419
|
+
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
|
|
420
|
+
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
|
421
|
+
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
|
422
|
+
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
|
423
|
+
setCompIdToSrc={setCompIdToSrc}
|
|
424
|
+
setCompositionLoading={setCompositionLoading}
|
|
425
|
+
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
426
|
+
/>
|
|
4025
427
|
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
<button
|
|
4032
|
-
type="button"
|
|
4033
|
-
onClick={toggleLeftSidebar}
|
|
4034
|
-
className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
4035
|
-
title="Show sidebar"
|
|
4036
|
-
aria-label="Show sidebar"
|
|
4037
|
-
>
|
|
4038
|
-
<svg
|
|
4039
|
-
width="14"
|
|
4040
|
-
height="14"
|
|
4041
|
-
viewBox="0 0 24 24"
|
|
4042
|
-
fill="none"
|
|
4043
|
-
stroke="currentColor"
|
|
4044
|
-
strokeWidth="1.5"
|
|
4045
|
-
strokeLinecap="round"
|
|
4046
|
-
strokeLinejoin="round"
|
|
4047
|
-
aria-hidden="true"
|
|
4048
|
-
>
|
|
4049
|
-
<path d="M5 4v16" />
|
|
4050
|
-
<path d="m10 7 5 5-5 5" />
|
|
4051
|
-
</svg>
|
|
4052
|
-
</button>
|
|
4053
|
-
</div>
|
|
4054
|
-
) : (
|
|
4055
|
-
<LeftSidebar
|
|
4056
|
-
width={leftWidth}
|
|
4057
|
-
projectId={projectId}
|
|
4058
|
-
compositions={compositions}
|
|
4059
|
-
assets={assets}
|
|
4060
|
-
activeComposition={editingFile?.path ?? null}
|
|
4061
|
-
onSelectComposition={(comp) => {
|
|
4062
|
-
// Set active composition for preview drill-down
|
|
4063
|
-
// Don't increment refreshKey — that reloads the master iframe and
|
|
4064
|
-
// overrides the composition navigation. Let activeCompositionPath
|
|
4065
|
-
// handle the preview change via the composition stack.
|
|
4066
|
-
setActiveCompPath(
|
|
4067
|
-
comp === "index.html" || comp.startsWith("compositions/") ? comp : null,
|
|
4068
|
-
);
|
|
4069
|
-
// Load file content for code editor
|
|
4070
|
-
setEditingFile({ path: comp, content: null });
|
|
4071
|
-
fetch(`/api/projects/${projectId}/files/${comp}`)
|
|
4072
|
-
.then((r) => r.json())
|
|
4073
|
-
.then((data) => setEditingFile({ path: comp, content: data.content }))
|
|
4074
|
-
.catch(() => {});
|
|
4075
|
-
}}
|
|
4076
|
-
fileTree={fileTree}
|
|
4077
|
-
editingFile={editingFile}
|
|
4078
|
-
onSelectFile={handleFileSelect}
|
|
4079
|
-
onCreateFile={handleCreateFile}
|
|
4080
|
-
onCreateFolder={handleCreateFolder}
|
|
4081
|
-
onDeleteFile={handleDeleteFile}
|
|
4082
|
-
onRenameFile={handleRenameFile}
|
|
4083
|
-
onDuplicateFile={handleDuplicateFile}
|
|
4084
|
-
onMoveFile={handleMoveFile}
|
|
4085
|
-
onImportFiles={handleImportFiles}
|
|
4086
|
-
codeChildren={
|
|
4087
|
-
editingFile ? (
|
|
4088
|
-
isMediaFile(editingFile.path) ? (
|
|
4089
|
-
<MediaPreview projectId={projectId ?? ""} filePath={editingFile.path} />
|
|
4090
|
-
) : (
|
|
4091
|
-
<SourceEditor
|
|
4092
|
-
content={editingFile.content ?? ""}
|
|
4093
|
-
filePath={editingFile.path}
|
|
4094
|
-
onChange={handleContentChange}
|
|
428
|
+
{!panelLayout.rightCollapsed && (
|
|
429
|
+
<StudioRightPanel
|
|
430
|
+
selectedStudioMotion={selectedStudioMotion}
|
|
431
|
+
designPanelActive={designPanelActive}
|
|
432
|
+
motionPanelActive={motionPanelActive}
|
|
4095
433
|
/>
|
|
4096
|
-
)
|
|
4097
|
-
|
|
4098
|
-
}
|
|
4099
|
-
onLint={handleLint}
|
|
4100
|
-
linting={linting}
|
|
4101
|
-
onToggleCollapse={toggleLeftSidebar}
|
|
4102
|
-
takeoverContent={timelineLayerPanel}
|
|
4103
|
-
/>
|
|
4104
|
-
)}
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
4105
436
|
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
|
4110
|
-
style={{ touchAction: "none" }}
|
|
4111
|
-
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
|
4112
|
-
onPointerMove={handlePanelResizeMove}
|
|
4113
|
-
onPointerUp={handlePanelResizeEnd}
|
|
4114
|
-
>
|
|
4115
|
-
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
4116
|
-
</div>
|
|
4117
|
-
)}
|
|
437
|
+
{lintModal !== null && (
|
|
438
|
+
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
|
|
439
|
+
)}
|
|
4118
440
|
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
activeCompositionPath={activeCompPath}
|
|
4125
|
-
timelineToolbar={timelineToolbar}
|
|
4126
|
-
renderClipContent={renderClipContent}
|
|
4127
|
-
onDeleteElement={handleTimelineElementDelete}
|
|
4128
|
-
onAssetDrop={handleTimelineAssetDrop}
|
|
4129
|
-
onFileDrop={handleTimelineFileDrop}
|
|
4130
|
-
onMoveElement={handleTimelineElementMove}
|
|
4131
|
-
onResizeElement={handleTimelineElementResize}
|
|
4132
|
-
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
|
4133
|
-
onSelectTimelineElement={handleTimelineElementSelect}
|
|
4134
|
-
onInspectTimelineElement={handleTimelineElementInspect}
|
|
4135
|
-
inspectedTimelineElementId={inspectedTimelineElementId}
|
|
4136
|
-
timelineLayerChildCounts={timelineLayerChildCounts}
|
|
4137
|
-
onCompIdToSrcChange={setCompIdToSrc}
|
|
4138
|
-
onCompositionLoadingChange={setCompositionLoading}
|
|
4139
|
-
onCompositionChange={(compPath) => {
|
|
4140
|
-
// Sync activeCompPath when user drills down via timeline double-click
|
|
4141
|
-
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
|
4142
|
-
setActiveCompPath(compPath);
|
|
4143
|
-
setInspectedTimelineElementId(null);
|
|
4144
|
-
refreshPreviewDocumentVersion();
|
|
4145
|
-
}}
|
|
4146
|
-
onIframeRef={handlePreviewIframeRef}
|
|
4147
|
-
previewOverlay={
|
|
4148
|
-
captionEditMode ? (
|
|
4149
|
-
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
4150
|
-
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
|
4151
|
-
<DomEditOverlay
|
|
4152
|
-
iframeRef={previewIframeRef}
|
|
4153
|
-
activeCompositionPath={activeCompPath}
|
|
4154
|
-
hoverSelection={
|
|
4155
|
-
STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
|
|
4156
|
-
? domEditHoverSelection
|
|
4157
|
-
: null
|
|
4158
|
-
}
|
|
4159
|
-
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
4160
|
-
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
4161
|
-
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
|
4162
|
-
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
4163
|
-
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
4164
|
-
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
4165
|
-
onSelectionChange={applyDomSelection}
|
|
4166
|
-
onBlockedMove={handleBlockedDomMove}
|
|
4167
|
-
onManualDragStart={handleDomManualDragStart}
|
|
4168
|
-
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
4169
|
-
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
4170
|
-
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
4171
|
-
onRotationCommit={handleDomRotationCommit}
|
|
441
|
+
{consoleErrors !== null && consoleErrors.length > 0 && (
|
|
442
|
+
<LintModal
|
|
443
|
+
findings={consoleErrors}
|
|
444
|
+
projectId={projectId}
|
|
445
|
+
onClose={() => setConsoleErrors(null)}
|
|
4172
446
|
/>
|
|
4173
|
-
)
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
447
|
+
)}
|
|
448
|
+
|
|
449
|
+
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
|
|
450
|
+
<AskAgentModal
|
|
451
|
+
selectionLabel={domEditSession.domEditSelection.label}
|
|
452
|
+
anchorPoint={domEditSession.agentModalAnchorPoint}
|
|
453
|
+
onSubmit={domEditSession.handleAgentModalSubmit}
|
|
454
|
+
onClose={() => {
|
|
455
|
+
domEditSession.setAgentModalOpen(false);
|
|
456
|
+
domEditSession.setAgentPromptSelectionContext(undefined);
|
|
457
|
+
domEditSession.setAgentModalAnchorPoint(null);
|
|
458
|
+
}}
|
|
459
|
+
/>
|
|
460
|
+
)}
|
|
461
|
+
|
|
462
|
+
{globalDragOver && (
|
|
463
|
+
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
|
464
|
+
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
|
|
465
|
+
<svg
|
|
466
|
+
width="32"
|
|
467
|
+
height="32"
|
|
468
|
+
viewBox="0 0 24 24"
|
|
469
|
+
fill="none"
|
|
470
|
+
stroke="currentColor"
|
|
471
|
+
strokeWidth="1.5"
|
|
472
|
+
strokeLinecap="round"
|
|
473
|
+
strokeLinejoin="round"
|
|
474
|
+
className="text-studio-accent"
|
|
475
|
+
>
|
|
476
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
477
|
+
<polyline points="7 10 12 15 17 10" />
|
|
478
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
479
|
+
</svg>
|
|
480
|
+
<span className="text-sm font-medium text-studio-accent">
|
|
481
|
+
Drop files to import into project
|
|
4184
482
|
</span>
|
|
4185
483
|
</div>
|
|
4186
|
-
<CaptionTimeline pixelsPerSecond={100} />
|
|
4187
484
|
</div>
|
|
4188
|
-
)
|
|
4189
|
-
}
|
|
4190
|
-
timelineVisible={timelineVisible}
|
|
4191
|
-
onToggleTimeline={toggleTimelineVisibility}
|
|
4192
|
-
/>
|
|
4193
|
-
</div>
|
|
485
|
+
)}
|
|
4194
486
|
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
4206
|
-
</div>
|
|
4207
|
-
<div
|
|
4208
|
-
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
|
4209
|
-
style={{ width: rightWidth }}
|
|
4210
|
-
>
|
|
4211
|
-
{captionEditMode ? (
|
|
4212
|
-
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
4213
|
-
) : (
|
|
4214
|
-
<>
|
|
4215
|
-
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
|
4216
|
-
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
|
4217
|
-
<>
|
|
4218
|
-
<button
|
|
4219
|
-
type="button"
|
|
4220
|
-
onClick={() => setRightPanelTab("design")}
|
|
4221
|
-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4222
|
-
rightPanelTab === "design"
|
|
4223
|
-
? "bg-neutral-800 text-white"
|
|
4224
|
-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4225
|
-
}`}
|
|
4226
|
-
>
|
|
4227
|
-
Design
|
|
4228
|
-
</button>
|
|
4229
|
-
{STUDIO_MOTION_PANEL_ENABLED && (
|
|
4230
|
-
<button
|
|
4231
|
-
type="button"
|
|
4232
|
-
onClick={() => setRightPanelTab("motion")}
|
|
4233
|
-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4234
|
-
rightPanelTab === "motion"
|
|
4235
|
-
? "bg-neutral-800 text-white"
|
|
4236
|
-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4237
|
-
}`}
|
|
4238
|
-
>
|
|
4239
|
-
Motion
|
|
4240
|
-
</button>
|
|
4241
|
-
)}
|
|
4242
|
-
</>
|
|
4243
|
-
)}
|
|
4244
|
-
<button
|
|
4245
|
-
type="button"
|
|
4246
|
-
onClick={() => setRightPanelTab("renders")}
|
|
4247
|
-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4248
|
-
rightPanelTab === "renders"
|
|
4249
|
-
? "bg-neutral-800 text-white"
|
|
4250
|
-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4251
|
-
}`}
|
|
4252
|
-
>
|
|
4253
|
-
{renderQueue.jobs.length > 0
|
|
4254
|
-
? `Renders (${renderQueue.jobs.length})`
|
|
4255
|
-
: "Renders"}
|
|
4256
|
-
</button>
|
|
4257
|
-
</div>
|
|
4258
|
-
<div className="min-h-0 flex-1">
|
|
4259
|
-
{designPanelActive ? (
|
|
4260
|
-
<PropertyPanel
|
|
4261
|
-
projectId={projectId}
|
|
4262
|
-
assets={assets}
|
|
4263
|
-
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
4264
|
-
multiSelectCount={domEditGroupSelections.length}
|
|
4265
|
-
copiedAgentPrompt={copiedAgentPrompt}
|
|
4266
|
-
onClearSelection={clearDomSelection}
|
|
4267
|
-
onSetStyle={handleDomStyleCommit}
|
|
4268
|
-
onSetManualOffset={handleDomPathOffsetCommit}
|
|
4269
|
-
onSetManualSize={handleDomBoxSizeCommit}
|
|
4270
|
-
onSetText={handleDomTextCommit}
|
|
4271
|
-
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
|
4272
|
-
onAddTextField={handleDomAddTextField}
|
|
4273
|
-
onRemoveTextField={handleDomRemoveTextField}
|
|
4274
|
-
onResetManualEdits={handleDomManualEditsReset}
|
|
4275
|
-
onAskAgent={handleAskAgent}
|
|
4276
|
-
onImportAssets={handleImportFiles}
|
|
4277
|
-
fontAssets={fontAssets}
|
|
4278
|
-
onImportFonts={handleImportFonts}
|
|
4279
|
-
/>
|
|
4280
|
-
) : motionPanelActive ? (
|
|
4281
|
-
<MotionPanel
|
|
4282
|
-
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
4283
|
-
motion={selectedStudioMotion}
|
|
4284
|
-
onClearSelection={clearDomSelection}
|
|
4285
|
-
onSetMotion={handleDomMotionCommit}
|
|
4286
|
-
onClearMotion={handleDomMotionClear}
|
|
4287
|
-
/>
|
|
4288
|
-
) : (
|
|
4289
|
-
<RenderQueue
|
|
4290
|
-
jobs={renderQueue.jobs}
|
|
4291
|
-
projectId={projectId}
|
|
4292
|
-
onDelete={renderQueue.deleteRender}
|
|
4293
|
-
onClearCompleted={renderQueue.clearCompleted}
|
|
4294
|
-
onStartRender={async (format, quality, resolution, fps) => {
|
|
4295
|
-
await waitForPendingDomEditSaves();
|
|
4296
|
-
await renderQueue.startRender({ fps, quality, format, resolution });
|
|
4297
|
-
}}
|
|
4298
|
-
isRendering={renderQueue.isRendering}
|
|
4299
|
-
/>
|
|
4300
|
-
)}
|
|
4301
|
-
</div>
|
|
4302
|
-
</>
|
|
487
|
+
{appToast && (
|
|
488
|
+
<div
|
|
489
|
+
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
|
490
|
+
appToast.tone === "error"
|
|
491
|
+
? "bg-red-900/90 border-red-700/50 text-red-200"
|
|
492
|
+
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
|
493
|
+
}`}
|
|
494
|
+
>
|
|
495
|
+
{appToast.message}
|
|
496
|
+
</div>
|
|
4303
497
|
)}
|
|
4304
498
|
</div>
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
</
|
|
4308
|
-
|
|
4309
|
-
{/* Lint modal */}
|
|
4310
|
-
{lintModal !== null && projectId && (
|
|
4311
|
-
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
|
4312
|
-
)}
|
|
4313
|
-
|
|
4314
|
-
{/* Console errors modal — auto-shows when composition has runtime errors */}
|
|
4315
|
-
{consoleErrors !== null && consoleErrors.length > 0 && projectId && (
|
|
4316
|
-
<LintModal
|
|
4317
|
-
findings={consoleErrors}
|
|
4318
|
-
projectId={projectId}
|
|
4319
|
-
onClose={() => setConsoleErrors(null)}
|
|
4320
|
-
/>
|
|
4321
|
-
)}
|
|
4322
|
-
|
|
4323
|
-
{/* Ask agent modal */}
|
|
4324
|
-
{agentModalOpen && domEditSelection && (
|
|
4325
|
-
<AskAgentModal
|
|
4326
|
-
selectionLabel={domEditSelection.label}
|
|
4327
|
-
anchorPoint={agentModalAnchorPoint}
|
|
4328
|
-
onSubmit={handleAgentModalSubmit}
|
|
4329
|
-
onClose={() => {
|
|
4330
|
-
setAgentModalOpen(false);
|
|
4331
|
-
setAgentPromptSelectionContext(undefined);
|
|
4332
|
-
setAgentModalAnchorPoint(null);
|
|
4333
|
-
}}
|
|
4334
|
-
/>
|
|
4335
|
-
)}
|
|
4336
|
-
|
|
4337
|
-
{/* Global drag-drop overlay */}
|
|
4338
|
-
{globalDragOver && (
|
|
4339
|
-
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
|
4340
|
-
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
|
|
4341
|
-
<svg
|
|
4342
|
-
width="32"
|
|
4343
|
-
height="32"
|
|
4344
|
-
viewBox="0 0 24 24"
|
|
4345
|
-
fill="none"
|
|
4346
|
-
stroke="currentColor"
|
|
4347
|
-
strokeWidth="1.5"
|
|
4348
|
-
strokeLinecap="round"
|
|
4349
|
-
strokeLinejoin="round"
|
|
4350
|
-
className="text-studio-accent"
|
|
4351
|
-
>
|
|
4352
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
4353
|
-
<polyline points="7 10 12 15 17 10" />
|
|
4354
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
4355
|
-
</svg>
|
|
4356
|
-
<span className="text-sm font-medium text-studio-accent">
|
|
4357
|
-
Drop files to import into project
|
|
4358
|
-
</span>
|
|
4359
|
-
</div>
|
|
4360
|
-
</div>
|
|
4361
|
-
)}
|
|
4362
|
-
{appToast && (
|
|
4363
|
-
<div
|
|
4364
|
-
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
|
4365
|
-
appToast.tone === "error"
|
|
4366
|
-
? "bg-red-900/90 border-red-700/50 text-red-200"
|
|
4367
|
-
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
|
4368
|
-
}`}
|
|
4369
|
-
>
|
|
4370
|
-
{appToast.message}
|
|
4371
|
-
</div>
|
|
4372
|
-
)}
|
|
4373
|
-
</div>
|
|
499
|
+
</DomEditProvider>
|
|
500
|
+
</FileManagerProvider>
|
|
501
|
+
</PanelLayoutProvider>
|
|
502
|
+
</StudioProvider>
|
|
4374
503
|
);
|
|
4375
504
|
}
|