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