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