@hyperframes/studio 0.5.7 → 0.6.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-14zH9lqh.css +1 -0
- package/dist/assets/index-B-16fRnH.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2965 -186
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +116 -0
- package/src/components/editor/PropertyPanel.tsx +2829 -205
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +1120 -0
- package/src/components/editor/domEditing.ts +1117 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +131 -0
- package/src/components/editor/manualEditingAvailability.ts +62 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1409 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +84 -22
- package/src/components/nle/NLEPreview.tsx +56 -5
- package/src/components/renders/RenderQueue.tsx +24 -11
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +194 -179
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +88 -5
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +147 -40
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineDiscovery.ts +1 -1
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-Dcw3BoVw.js +0 -93
package/src/App.tsx
CHANGED
|
@@ -4,13 +4,14 @@ import {
|
|
|
4
4
|
useRef,
|
|
5
5
|
useEffect,
|
|
6
6
|
useMemo,
|
|
7
|
+
type CSSProperties,
|
|
7
8
|
type MouseEvent,
|
|
8
9
|
type ReactNode,
|
|
9
10
|
} from "react";
|
|
10
11
|
import { useMountEffect } from "./hooks/useMountEffect";
|
|
11
12
|
import { NLELayout } from "./components/nle/NLELayout";
|
|
12
13
|
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
13
|
-
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
14
|
+
import { LeftSidebar, type LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
|
|
14
15
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
15
16
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
16
17
|
import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
|
|
@@ -19,13 +20,15 @@ import type { TimelineElement } from "./player";
|
|
|
19
20
|
import { LintModal } from "./components/LintModal";
|
|
20
21
|
import type { LintFinding } from "./components/LintModal";
|
|
21
22
|
import { MediaPreview } from "./components/MediaPreview";
|
|
22
|
-
import {
|
|
23
|
+
import { RotateCcw, RotateCw } from "./icons/SystemIcons";
|
|
24
|
+
import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
|
|
23
25
|
import {
|
|
24
26
|
buildTimelineAssetId,
|
|
25
27
|
buildTimelineAssetInsertHtml,
|
|
26
28
|
buildTimelineFileDropPlacements,
|
|
27
29
|
getTimelineAssetKind,
|
|
28
30
|
insertTimelineAssetIntoSource,
|
|
31
|
+
resolveTimelineAssetInitialGeometry,
|
|
29
32
|
resolveTimelineAssetSrc,
|
|
30
33
|
type TimelineAssetKind,
|
|
31
34
|
} from "./utils/timelineAssetDrop";
|
|
@@ -35,7 +38,14 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
35
38
|
import { useCaptionStore } from "./captions/store";
|
|
36
39
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
37
40
|
import { parseCaptionComposition } from "./captions/parser";
|
|
38
|
-
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";
|
|
39
49
|
import {
|
|
40
50
|
buildTrackZIndexMap,
|
|
41
51
|
formatTimelineAttributeNumber,
|
|
@@ -46,11 +56,92 @@ import {
|
|
|
46
56
|
} from "./player/components/timelineZoom";
|
|
47
57
|
import {
|
|
48
58
|
getTimelineToggleTitle,
|
|
59
|
+
isEditableTarget,
|
|
49
60
|
shouldHandleTimelineToggleHotkey,
|
|
50
61
|
} from "./utils/timelineDiscovery";
|
|
51
62
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
|
52
63
|
import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
|
|
53
64
|
import { Camera } from "./icons/SystemIcons";
|
|
65
|
+
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
66
|
+
import { MotionPanel } from "./components/editor/MotionPanel";
|
|
67
|
+
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
68
|
+
import {
|
|
69
|
+
fontFamilyFromAssetPath,
|
|
70
|
+
importedFontFaceCss,
|
|
71
|
+
type ImportedFontAsset,
|
|
72
|
+
} from "./components/editor/fontAssets";
|
|
73
|
+
import {
|
|
74
|
+
DomEditOverlay,
|
|
75
|
+
type DomEditGroupPathOffsetCommit,
|
|
76
|
+
} from "./components/editor/DomEditOverlay";
|
|
77
|
+
import { TimelineLayerPanel } from "./components/editor/TimelineLayerPanel";
|
|
78
|
+
import {
|
|
79
|
+
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
80
|
+
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
|
|
81
|
+
STUDIO_MOTION_PANEL_ENABLED,
|
|
82
|
+
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
|
|
83
|
+
STUDIO_PREVIEW_SELECTION_ENABLED,
|
|
84
|
+
STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
|
|
85
|
+
} from "./components/editor/manualEditingAvailability";
|
|
86
|
+
import {
|
|
87
|
+
buildDomEditStylePatchOperation,
|
|
88
|
+
buildDomEditTextPatchOperation,
|
|
89
|
+
buildElementAgentPrompt,
|
|
90
|
+
collectDomEditLayerItems,
|
|
91
|
+
countDomEditChildLayers,
|
|
92
|
+
findElementForSelection,
|
|
93
|
+
findElementForTimelineElement,
|
|
94
|
+
getDomEditLayerKey,
|
|
95
|
+
getDomEditTargetKey,
|
|
96
|
+
isLargeRasterDomEditSelection,
|
|
97
|
+
isTextEditableSelection,
|
|
98
|
+
resolveVisualDomEditSelectionTarget,
|
|
99
|
+
serializeDomEditTextFields,
|
|
100
|
+
resolveDomEditSelection,
|
|
101
|
+
type DomEditViewport,
|
|
102
|
+
type DomEditLayerItem,
|
|
103
|
+
type DomEditTextField,
|
|
104
|
+
type DomEditSelection,
|
|
105
|
+
buildDefaultDomEditTextField,
|
|
106
|
+
} from "./components/editor/domEditing";
|
|
107
|
+
import {
|
|
108
|
+
STUDIO_MANUAL_EDITS_PATH,
|
|
109
|
+
applyStudioManualEditManifest,
|
|
110
|
+
emptyStudioManualEditManifest,
|
|
111
|
+
installStudioManualEditSeekReapply,
|
|
112
|
+
isStudioManualEditManifestPath,
|
|
113
|
+
parseStudioManualEditManifest,
|
|
114
|
+
readStudioFileChangePath,
|
|
115
|
+
removeStudioManualEditsForSelection,
|
|
116
|
+
serializeStudioManualEditManifest,
|
|
117
|
+
type StudioManualEditManifest,
|
|
118
|
+
upsertStudioBoxSizeEdit,
|
|
119
|
+
upsertStudioPathOffsetEdit,
|
|
120
|
+
upsertStudioRotationEdit,
|
|
121
|
+
} from "./components/editor/manualEdits";
|
|
122
|
+
import {
|
|
123
|
+
STUDIO_MOTION_PATH,
|
|
124
|
+
applyStudioMotionManifest,
|
|
125
|
+
emptyStudioMotionManifest,
|
|
126
|
+
getStudioMotionForSelection,
|
|
127
|
+
installStudioMotionSeekReapply,
|
|
128
|
+
isStudioMotionManifestPath,
|
|
129
|
+
parseStudioMotionManifest,
|
|
130
|
+
removeStudioMotionForSelection,
|
|
131
|
+
serializeStudioMotionManifest,
|
|
132
|
+
type StudioGsapMotion,
|
|
133
|
+
type StudioMotionManifest,
|
|
134
|
+
upsertStudioGsapMotion,
|
|
135
|
+
} from "./components/editor/studioMotion";
|
|
136
|
+
import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
|
|
137
|
+
import {
|
|
138
|
+
canInspectTimelineElement,
|
|
139
|
+
getTimelineElementKey,
|
|
140
|
+
getTimelineLayerVisibilityInPreview,
|
|
141
|
+
isTimelineElementActiveAtTime,
|
|
142
|
+
isTimelineLayerVisibleInPreview,
|
|
143
|
+
shouldShowTimelineInspectorBounds,
|
|
144
|
+
} from "./utils/timelineInspector";
|
|
54
145
|
|
|
55
146
|
interface EditingFile {
|
|
56
147
|
path: string;
|
|
@@ -66,6 +157,638 @@ function getTimelineElementLabel(element: TimelineElement): string {
|
|
|
66
157
|
return element.label || element.id || element.tag;
|
|
67
158
|
}
|
|
68
159
|
|
|
160
|
+
type RightPanelTab = "design" | "motion" | "renders";
|
|
161
|
+
|
|
162
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
163
|
+
"inherit",
|
|
164
|
+
"initial",
|
|
165
|
+
"revert",
|
|
166
|
+
"revert-layer",
|
|
167
|
+
"serif",
|
|
168
|
+
"sans-serif",
|
|
169
|
+
"monospace",
|
|
170
|
+
"cursive",
|
|
171
|
+
"fantasy",
|
|
172
|
+
"system-ui",
|
|
173
|
+
"ui-sans-serif",
|
|
174
|
+
"ui-serif",
|
|
175
|
+
"ui-monospace",
|
|
176
|
+
"ui-rounded",
|
|
177
|
+
"emoji",
|
|
178
|
+
"math",
|
|
179
|
+
"fangsong",
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
function primaryFontFamilyFromCss(value: string): string {
|
|
183
|
+
const first = value.split(",")[0] ?? "";
|
|
184
|
+
return first.trim().replace(/^["']|["']$/g, "");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
|
188
|
+
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
|
189
|
+
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
|
190
|
+
|
|
191
|
+
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
192
|
+
if (doc.getElementById(id)) return;
|
|
193
|
+
|
|
194
|
+
const link = doc.createElement("link");
|
|
195
|
+
link.id = id;
|
|
196
|
+
link.rel = "stylesheet";
|
|
197
|
+
link.href = googleFontStylesheetUrl(family);
|
|
198
|
+
doc.head.appendChild(link);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function primaryFontFamilyValue(value: string): string {
|
|
202
|
+
return (
|
|
203
|
+
value
|
|
204
|
+
.split(",")[0]
|
|
205
|
+
?.trim()
|
|
206
|
+
.replace(/^["']|["']$/g, "")
|
|
207
|
+
.trim() ?? ""
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
|
212
|
+
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
213
|
+
if (doc.getElementById(id)) return;
|
|
214
|
+
const style = doc.createElement("style");
|
|
215
|
+
style.id = id;
|
|
216
|
+
style.textContent = importedFontFaceCss(asset);
|
|
217
|
+
doc.head.appendChild(style);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeProjectAssetPath(value: string): string {
|
|
221
|
+
const trimmed = value.trim();
|
|
222
|
+
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
223
|
+
return decodeURIComponent(maybeUrl)
|
|
224
|
+
.replace(/\\/g, "/")
|
|
225
|
+
.replace(/^\.?\//, "");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
229
|
+
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
|
230
|
+
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
|
231
|
+
|
|
232
|
+
fromParts.pop();
|
|
233
|
+
|
|
234
|
+
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
235
|
+
fromParts.shift();
|
|
236
|
+
targetParts.shift();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
243
|
+
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
|
|
247
|
+
const trimmedSource = sourceFile.trim();
|
|
248
|
+
if (!trimmedSource) return undefined;
|
|
249
|
+
|
|
250
|
+
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
251
|
+
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
252
|
+
|
|
253
|
+
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
254
|
+
if (!normalizedRoot) return undefined;
|
|
255
|
+
|
|
256
|
+
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function ensureImportedFontFace(
|
|
260
|
+
html: string,
|
|
261
|
+
asset: ImportedFontAsset,
|
|
262
|
+
sourceFile: string,
|
|
263
|
+
): string {
|
|
264
|
+
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
265
|
+
if (html.includes(css)) return html;
|
|
266
|
+
|
|
267
|
+
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
268
|
+
const styleMatch = styleRe.exec(html);
|
|
269
|
+
if (styleMatch) {
|
|
270
|
+
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
271
|
+
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
275
|
+
if (/<\/head>/i.test(html)) {
|
|
276
|
+
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
|
277
|
+
}
|
|
278
|
+
return `${styleTag}\n${html}`;
|
|
279
|
+
}
|
|
280
|
+
function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
281
|
+
const trimmed = value.trim();
|
|
282
|
+
if (!trimmed) return trimmed;
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
|
|
286
|
+
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
287
|
+
) {
|
|
288
|
+
return `${trimmed}px`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return trimmed;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isImageBackgroundValue(value: string): boolean {
|
|
295
|
+
return /^url\(/i.test(value.trim());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
299
|
+
if (!target || typeof target !== "object") return null;
|
|
300
|
+
const maybeNode = target as {
|
|
301
|
+
nodeType?: number;
|
|
302
|
+
parentElement?: Element | null;
|
|
303
|
+
};
|
|
304
|
+
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
|
305
|
+
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
|
306
|
+
return maybeNode.parentElement as HTMLElement;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
|
312
|
+
const el = getEventTargetElement(target);
|
|
313
|
+
if (!el) return false;
|
|
314
|
+
return Boolean(
|
|
315
|
+
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
320
|
+
const isMac =
|
|
321
|
+
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
322
|
+
const modifier = isMac ? "Cmd" : "Ctrl";
|
|
323
|
+
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function findMatchingTimelineElementId(
|
|
327
|
+
selection: Pick<
|
|
328
|
+
DomEditSelection,
|
|
329
|
+
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
330
|
+
>,
|
|
331
|
+
elements: TimelineElement[],
|
|
332
|
+
): string | null {
|
|
333
|
+
const selectionSourceFile = selection.sourceFile || "index.html";
|
|
334
|
+
for (const element of elements) {
|
|
335
|
+
const elementSourceFile = element.sourceFile || "index.html";
|
|
336
|
+
if (
|
|
337
|
+
selection.id &&
|
|
338
|
+
element.domId === selection.id &&
|
|
339
|
+
elementSourceFile === selectionSourceFile
|
|
340
|
+
) {
|
|
341
|
+
return element.key ?? element.id;
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
selection.isCompositionHost &&
|
|
345
|
+
selection.compositionSrc &&
|
|
346
|
+
element.compositionSrc === selection.compositionSrc
|
|
347
|
+
) {
|
|
348
|
+
return element.key ?? element.id;
|
|
349
|
+
}
|
|
350
|
+
if (
|
|
351
|
+
selection.selector &&
|
|
352
|
+
element.selector === selection.selector &&
|
|
353
|
+
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
354
|
+
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
355
|
+
) {
|
|
356
|
+
return element.key ?? element.id;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isManualGeometryStyleProperty(property: string): boolean {
|
|
364
|
+
return property === "left" || property === "top" || property === "width" || property === "height";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
interface PreviewLocalPointer {
|
|
368
|
+
x: number;
|
|
369
|
+
y: number;
|
|
370
|
+
viewport: DomEditViewport;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
interface AgentModalAnchorPoint {
|
|
374
|
+
x: number;
|
|
375
|
+
y: number;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function resolvePreviewLocalPointer(
|
|
379
|
+
iframe: HTMLIFrameElement,
|
|
380
|
+
doc: Document,
|
|
381
|
+
win: Window,
|
|
382
|
+
clientX: number,
|
|
383
|
+
clientY: number,
|
|
384
|
+
): PreviewLocalPointer | null {
|
|
385
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
386
|
+
const root =
|
|
387
|
+
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
|
388
|
+
const rootRect = root?.getBoundingClientRect();
|
|
389
|
+
const rootWidth = rootRect?.width || win.innerWidth;
|
|
390
|
+
const rootHeight = rootRect?.height || win.innerHeight;
|
|
391
|
+
if (!rootWidth || !rootHeight) return null;
|
|
392
|
+
|
|
393
|
+
const scaleX = iframeRect.width / rootWidth;
|
|
394
|
+
const scaleY = iframeRect.height / rootHeight;
|
|
395
|
+
return {
|
|
396
|
+
x: (clientX - iframeRect.left) / scaleX,
|
|
397
|
+
y: (clientY - iframeRect.top) / scaleY,
|
|
398
|
+
viewport: { width: rootWidth, height: rootHeight },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getPreviewLocalPointer(
|
|
403
|
+
iframe: HTMLIFrameElement,
|
|
404
|
+
clientX: number,
|
|
405
|
+
clientY: number,
|
|
406
|
+
): PreviewLocalPointer | null {
|
|
407
|
+
let doc: Document | null = null;
|
|
408
|
+
let win: Window | null = null;
|
|
409
|
+
try {
|
|
410
|
+
doc = iframe.contentDocument;
|
|
411
|
+
win = iframe.contentWindow;
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
if (!doc || !win) return null;
|
|
416
|
+
|
|
417
|
+
return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function getPreviewTargetFromPointer(
|
|
421
|
+
iframe: HTMLIFrameElement,
|
|
422
|
+
clientX: number,
|
|
423
|
+
clientY: number,
|
|
424
|
+
activeCompositionPath: string | null,
|
|
425
|
+
): HTMLElement | null {
|
|
426
|
+
let doc: Document | null = null;
|
|
427
|
+
let win: Window | null = null;
|
|
428
|
+
try {
|
|
429
|
+
doc = iframe.contentDocument;
|
|
430
|
+
win = iframe.contentWindow;
|
|
431
|
+
} catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
if (!doc || !win) return null;
|
|
435
|
+
|
|
436
|
+
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
437
|
+
if (!localPointer) return null;
|
|
438
|
+
|
|
439
|
+
if (typeof doc.elementsFromPoint === "function") {
|
|
440
|
+
const visualTarget = resolveVisualDomEditSelectionTarget(
|
|
441
|
+
doc.elementsFromPoint(localPointer.x, localPointer.y),
|
|
442
|
+
{
|
|
443
|
+
activeCompositionPath,
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
if (visualTarget) return visualTarget;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function buildRasterClickSelectionContext(
|
|
453
|
+
selection: DomEditSelection,
|
|
454
|
+
localPointer: PreviewLocalPointer,
|
|
455
|
+
): string {
|
|
456
|
+
return [
|
|
457
|
+
"The user clicked a large raster/background element in the Studio preview.",
|
|
458
|
+
`Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
|
|
459
|
+
localPointer.viewport.width,
|
|
460
|
+
)}x${Math.round(localPointer.viewport.height)} composition.`,
|
|
461
|
+
`Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
|
|
462
|
+
"Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
|
|
463
|
+
"If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
|
|
464
|
+
].join("\n");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function domEditSelectionsTargetSame(
|
|
468
|
+
a: DomEditSelection | null,
|
|
469
|
+
b: DomEditSelection | null,
|
|
470
|
+
): boolean {
|
|
471
|
+
if (a === b) return true;
|
|
472
|
+
if (!a || !b) return false;
|
|
473
|
+
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function domEditSelectionInGroup(
|
|
477
|
+
group: DomEditSelection[],
|
|
478
|
+
selection: DomEditSelection | null,
|
|
479
|
+
): boolean {
|
|
480
|
+
if (!selection) return false;
|
|
481
|
+
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function toggleDomEditGroupSelection(
|
|
485
|
+
group: DomEditSelection[],
|
|
486
|
+
selection: DomEditSelection,
|
|
487
|
+
): DomEditSelection[] {
|
|
488
|
+
if (domEditSelectionInGroup(group, selection)) {
|
|
489
|
+
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
|
|
490
|
+
}
|
|
491
|
+
return [...group, selection];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function replaceDomEditGroupSelection(
|
|
495
|
+
group: DomEditSelection[],
|
|
496
|
+
selection: DomEditSelection,
|
|
497
|
+
): DomEditSelection[] {
|
|
498
|
+
let replaced = false;
|
|
499
|
+
const nextGroup = group.map((entry) => {
|
|
500
|
+
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
|
|
501
|
+
replaced = true;
|
|
502
|
+
return selection;
|
|
503
|
+
});
|
|
504
|
+
return replaced ? nextGroup : [...group, selection];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function seedDomEditGroupWithSelection(
|
|
508
|
+
group: DomEditSelection[],
|
|
509
|
+
selection: DomEditSelection | null,
|
|
510
|
+
): DomEditSelection[] {
|
|
511
|
+
if (!selection || domEditSelectionInGroup(group, selection)) return group;
|
|
512
|
+
return [selection, ...group];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function objectLike(value: unknown): object | null {
|
|
516
|
+
return value && (typeof value === "object" || typeof value === "function") ? value : null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function callPlaybackMethod(target: object | null, key: string): void {
|
|
520
|
+
const method = target ? Reflect.get(target, key) : null;
|
|
521
|
+
if (typeof method !== "function") return;
|
|
522
|
+
try {
|
|
523
|
+
method.call(target);
|
|
524
|
+
} catch {
|
|
525
|
+
// Best-effort playback freeze; drag should still work if playback control is unavailable.
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function readPlaybackTime(target: object | null, key: string): number | null {
|
|
530
|
+
const method = target ? Reflect.get(target, key) : null;
|
|
531
|
+
if (typeof method !== "function") return null;
|
|
532
|
+
try {
|
|
533
|
+
const value = method.call(target);
|
|
534
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
interface PreviewPlayerCompat {
|
|
541
|
+
getTime: () => number;
|
|
542
|
+
renderSeek: (timeSeconds: number) => void;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
|
|
546
|
+
const player = objectLike(win ? Reflect.get(win, "__player") : null);
|
|
547
|
+
if (!player) return null;
|
|
548
|
+
const getTime = Reflect.get(player, "getTime");
|
|
549
|
+
const renderSeek = Reflect.get(player, "renderSeek");
|
|
550
|
+
if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
|
|
551
|
+
return {
|
|
552
|
+
getTime: () => {
|
|
553
|
+
const value = getTime.call(player);
|
|
554
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
555
|
+
},
|
|
556
|
+
renderSeek: (timeSeconds: number) => {
|
|
557
|
+
renderSeek.call(player, timeSeconds);
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
|
|
563
|
+
const player = getPreviewPlayer(iframe?.contentWindow);
|
|
564
|
+
if (!player) return false;
|
|
565
|
+
const nextTime = Math.max(0, timeSeconds);
|
|
566
|
+
player.renderSeek(nextTime);
|
|
567
|
+
usePlayerStore.getState().setCurrentTime(nextTime);
|
|
568
|
+
liveTime.notify(nextTime);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function parseFiniteSeconds(value: string | null): number | null {
|
|
573
|
+
if (value == null || value.trim() === "") return null;
|
|
574
|
+
const parsed = Number.parseFloat(value);
|
|
575
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function resolveLayerVisibleSeekTime(
|
|
579
|
+
layerElement: HTMLElement,
|
|
580
|
+
timelineElement: TimelineElement | null,
|
|
581
|
+
player: PreviewPlayerCompat | null,
|
|
582
|
+
): number | null {
|
|
583
|
+
if (!timelineElement || !player) return null;
|
|
584
|
+
const originalTime = player.getTime();
|
|
585
|
+
|
|
586
|
+
const clipStart = Math.max(0, timelineElement.start);
|
|
587
|
+
const clipEnd = Math.max(clipStart, clipStart + Math.max(0, timelineElement.duration));
|
|
588
|
+
const authoredStart = parseFiniteSeconds(
|
|
589
|
+
layerElement.getAttribute("data-start") ??
|
|
590
|
+
layerElement.closest<HTMLElement>("[data-start]")?.getAttribute("data-start") ??
|
|
591
|
+
null,
|
|
592
|
+
);
|
|
593
|
+
const preferredTime =
|
|
594
|
+
authoredStart == null
|
|
595
|
+
? clipStart
|
|
596
|
+
: Math.min(clipEnd, Math.max(clipStart, clipStart + authoredStart));
|
|
597
|
+
const candidates = [preferredTime, clipStart];
|
|
598
|
+
const duration = clipEnd - clipStart;
|
|
599
|
+
if (duration > 0) {
|
|
600
|
+
const maxSamples = 24;
|
|
601
|
+
const frameStep = 1 / 24;
|
|
602
|
+
const step = Math.max(frameStep, duration / maxSamples);
|
|
603
|
+
for (let time = clipStart; time <= clipEnd + 0.0001; time += step) {
|
|
604
|
+
candidates.push(Math.min(clipEnd, time));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
candidates.push(clipEnd);
|
|
608
|
+
|
|
609
|
+
let lastTried = preferredTime;
|
|
610
|
+
let clearestVisibleTime: number | null = null;
|
|
611
|
+
let clearestVisibleOpacity = 0;
|
|
612
|
+
let resolvedTime: number | null = null;
|
|
613
|
+
const seen = new Set<string>();
|
|
614
|
+
try {
|
|
615
|
+
for (const candidate of candidates) {
|
|
616
|
+
const time = Math.min(clipEnd, Math.max(clipStart, candidate));
|
|
617
|
+
const key = time.toFixed(4);
|
|
618
|
+
if (seen.has(key)) continue;
|
|
619
|
+
seen.add(key);
|
|
620
|
+
lastTried = time;
|
|
621
|
+
player.renderSeek(time);
|
|
622
|
+
const visibility = getTimelineLayerVisibilityInPreview(layerElement);
|
|
623
|
+
if (visibility.visible && visibility.compositeOpacity > clearestVisibleOpacity) {
|
|
624
|
+
clearestVisibleTime = time;
|
|
625
|
+
clearestVisibleOpacity = visibility.compositeOpacity;
|
|
626
|
+
}
|
|
627
|
+
if (isTimelineLayerVisibleInPreview(layerElement, { minCompositeOpacity: 0.9 })) {
|
|
628
|
+
resolvedTime = time;
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
} finally {
|
|
633
|
+
player.renderSeek(originalTime);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return resolvedTime ?? clearestVisibleTime ?? lastTried;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
|
|
640
|
+
const win = iframe?.contentWindow;
|
|
641
|
+
if (!win) return null;
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
let pausedTime: number | null = null;
|
|
645
|
+
const player = objectLike(Reflect.get(win, "__player"));
|
|
646
|
+
pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
|
|
647
|
+
callPlaybackMethod(player, "pause");
|
|
648
|
+
|
|
649
|
+
const timeline = objectLike(Reflect.get(win, "__timeline"));
|
|
650
|
+
pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
|
|
651
|
+
callPlaybackMethod(timeline, "pause");
|
|
652
|
+
|
|
653
|
+
const timelines = objectLike(Reflect.get(win, "__timelines"));
|
|
654
|
+
if (timelines) {
|
|
655
|
+
for (const value of Object.values(timelines)) {
|
|
656
|
+
const timelineRecord = objectLike(value);
|
|
657
|
+
pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
|
|
658
|
+
callPlaybackMethod(timelineRecord, "pause");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return pausedTime;
|
|
663
|
+
} catch {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ── Ask Agent Modal ──
|
|
669
|
+
|
|
670
|
+
function clampNumber(value: number, min: number, max: number): number {
|
|
671
|
+
if (max < min) return min;
|
|
672
|
+
return Math.min(Math.max(value, min), max);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function getAgentModalPositionStyle(
|
|
676
|
+
anchorPoint: AgentModalAnchorPoint | null,
|
|
677
|
+
): CSSProperties | undefined {
|
|
678
|
+
if (!anchorPoint || typeof window === "undefined") return undefined;
|
|
679
|
+
|
|
680
|
+
const modalWidth = 480;
|
|
681
|
+
const estimatedModalHeight = 270;
|
|
682
|
+
const margin = 16;
|
|
683
|
+
const left = clampNumber(
|
|
684
|
+
anchorPoint.x,
|
|
685
|
+
margin + modalWidth / 2,
|
|
686
|
+
window.innerWidth - margin - modalWidth / 2,
|
|
687
|
+
);
|
|
688
|
+
const top = clampNumber(
|
|
689
|
+
anchorPoint.y + 12,
|
|
690
|
+
margin,
|
|
691
|
+
window.innerHeight - margin - estimatedModalHeight,
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
return { left, top, transform: "translateX(-50%)" };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function AskAgentModal({
|
|
698
|
+
selectionLabel,
|
|
699
|
+
anchorPoint = null,
|
|
700
|
+
onSubmit,
|
|
701
|
+
onClose,
|
|
702
|
+
}: {
|
|
703
|
+
selectionLabel: string;
|
|
704
|
+
anchorPoint?: AgentModalAnchorPoint | null;
|
|
705
|
+
onSubmit: (instruction: string) => void;
|
|
706
|
+
onClose: () => void;
|
|
707
|
+
}) {
|
|
708
|
+
const [value, setValue] = useState("");
|
|
709
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
710
|
+
const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
|
|
711
|
+
|
|
712
|
+
useMountEffect(() => {
|
|
713
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const handleSubmit = () => {
|
|
717
|
+
if (!value.trim()) return;
|
|
718
|
+
onSubmit(value.trim());
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
return (
|
|
722
|
+
<div
|
|
723
|
+
className={
|
|
724
|
+
anchorPoint
|
|
725
|
+
? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
|
|
726
|
+
: "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
727
|
+
}
|
|
728
|
+
onClick={onClose}
|
|
729
|
+
>
|
|
730
|
+
<div
|
|
731
|
+
className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
|
|
732
|
+
anchorPoint ? "fixed" : ""
|
|
733
|
+
}`}
|
|
734
|
+
style={modalPositionStyle}
|
|
735
|
+
onClick={(e) => e.stopPropagation()}
|
|
736
|
+
>
|
|
737
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
|
738
|
+
<div>
|
|
739
|
+
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
|
740
|
+
<p className="text-xs text-neutral-500 mt-0.5">
|
|
741
|
+
{selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
|
|
742
|
+
</p>
|
|
743
|
+
</div>
|
|
744
|
+
<button
|
|
745
|
+
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
|
746
|
+
onClick={onClose}
|
|
747
|
+
>
|
|
748
|
+
<svg
|
|
749
|
+
width="14"
|
|
750
|
+
height="14"
|
|
751
|
+
viewBox="0 0 24 24"
|
|
752
|
+
fill="none"
|
|
753
|
+
stroke="currentColor"
|
|
754
|
+
strokeWidth="2"
|
|
755
|
+
strokeLinecap="round"
|
|
756
|
+
>
|
|
757
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
758
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
759
|
+
</svg>
|
|
760
|
+
</button>
|
|
761
|
+
</div>
|
|
762
|
+
<div className="px-5 py-4">
|
|
763
|
+
<textarea
|
|
764
|
+
ref={inputRef}
|
|
765
|
+
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"
|
|
766
|
+
placeholder="Describe what you want to change…"
|
|
767
|
+
value={value}
|
|
768
|
+
onChange={(e) => setValue(e.target.value)}
|
|
769
|
+
onKeyDown={(e) => {
|
|
770
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
|
|
771
|
+
if (e.key === "Escape") onClose();
|
|
772
|
+
}}
|
|
773
|
+
/>
|
|
774
|
+
</div>
|
|
775
|
+
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
|
776
|
+
<span className="text-[11px] text-neutral-600">
|
|
777
|
+
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
|
778
|
+
</span>
|
|
779
|
+
<button
|
|
780
|
+
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"
|
|
781
|
+
disabled={!value.trim()}
|
|
782
|
+
onClick={handleSubmit}
|
|
783
|
+
>
|
|
784
|
+
Copy prompt
|
|
785
|
+
</button>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
69
792
|
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
70
793
|
image: 3,
|
|
71
794
|
video: 5,
|
|
@@ -144,6 +867,7 @@ export function StudioApp() {
|
|
|
144
867
|
});
|
|
145
868
|
|
|
146
869
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
870
|
+
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
147
871
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
148
872
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
149
873
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -157,6 +881,28 @@ export function StudioApp() {
|
|
|
157
881
|
const [rightWidth, setRightWidth] = useState(400);
|
|
158
882
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
159
883
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
884
|
+
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
885
|
+
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
886
|
+
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
|
887
|
+
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
|
888
|
+
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
889
|
+
const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
|
|
890
|
+
string | undefined
|
|
891
|
+
>();
|
|
892
|
+
const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
|
|
893
|
+
null,
|
|
894
|
+
);
|
|
895
|
+
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
896
|
+
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
897
|
+
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
898
|
+
const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
|
|
899
|
+
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
900
|
+
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
|
901
|
+
const refreshPreviewDocumentVersion = useCallback(() => {
|
|
902
|
+
setPreviewDocumentVersion((version) => version + 1);
|
|
903
|
+
window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 80);
|
|
904
|
+
window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 300);
|
|
905
|
+
}, []);
|
|
160
906
|
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
|
|
161
907
|
// This is a subscription to external events (postMessage from runtime) — useEffect
|
|
162
908
|
// is appropriate here. The runtime fires "state"/"timeline" messages after all
|
|
@@ -283,7 +1029,12 @@ export function StudioApp() {
|
|
|
283
1029
|
const dragCounterRef = useRef(0);
|
|
284
1030
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
285
1031
|
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
1032
|
+
const lastBlockedDomMoveToastAtRef = useRef(0);
|
|
1033
|
+
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
286
1034
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
1035
|
+
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
|
|
1036
|
+
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
|
|
1037
|
+
const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
|
|
287
1038
|
const panelDragRef = useRef<{
|
|
288
1039
|
side: "left" | "right";
|
|
289
1040
|
startX: number;
|
|
@@ -294,11 +1045,15 @@ export function StudioApp() {
|
|
|
294
1045
|
const activePreviewUrl = activeCompPath
|
|
295
1046
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
296
1047
|
: null;
|
|
1048
|
+
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
297
1049
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
298
1050
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
299
1051
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
300
1052
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
1053
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
301
1054
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
1055
|
+
const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
|
|
1056
|
+
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
302
1057
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
303
1058
|
const effectiveTimelineDuration = useMemo(() => {
|
|
304
1059
|
const maxEnd =
|
|
@@ -346,34 +1101,31 @@ export function StudioApp() {
|
|
|
346
1101
|
[toggleTimelineVisibility],
|
|
347
1102
|
);
|
|
348
1103
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
window.removeEventListener("keydown", handleTimelineToggleHotkey);
|
|
353
|
-
};
|
|
354
|
-
});
|
|
1104
|
+
const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
|
|
1105
|
+
handleAppKeyDownRef.current?.(event);
|
|
1106
|
+
}, []);
|
|
355
1107
|
|
|
356
1108
|
const syncPreviewTimelineHotkey = useCallback(
|
|
357
1109
|
(iframe: HTMLIFrameElement | null) => {
|
|
358
1110
|
const nextWindow = iframe?.contentWindow ?? null;
|
|
359
1111
|
if (previewHotkeyWindowRef.current === nextWindow) return;
|
|
360
1112
|
if (previewHotkeyWindowRef.current) {
|
|
361
|
-
previewHotkeyWindowRef.current.removeEventListener("keydown",
|
|
1113
|
+
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
|
|
362
1114
|
}
|
|
363
1115
|
previewHotkeyWindowRef.current = nextWindow;
|
|
364
|
-
nextWindow?.addEventListener("keydown",
|
|
1116
|
+
nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
|
|
365
1117
|
},
|
|
366
|
-
[
|
|
1118
|
+
[previewAppKeyDownHandler],
|
|
367
1119
|
);
|
|
368
1120
|
|
|
369
1121
|
useEffect(
|
|
370
1122
|
() => () => {
|
|
371
1123
|
if (previewHotkeyWindowRef.current) {
|
|
372
|
-
previewHotkeyWindowRef.current.removeEventListener("keydown",
|
|
1124
|
+
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
|
|
373
1125
|
previewHotkeyWindowRef.current = null;
|
|
374
1126
|
}
|
|
375
1127
|
},
|
|
376
|
-
[
|
|
1128
|
+
[previewAppKeyDownHandler],
|
|
377
1129
|
);
|
|
378
1130
|
|
|
379
1131
|
const renderClipContent = useCallback(
|
|
@@ -400,7 +1152,6 @@ export function StudioApp() {
|
|
|
400
1152
|
label={getTimelineElementLabel(el)}
|
|
401
1153
|
labelColor={style.label}
|
|
402
1154
|
accentColor={style.clip}
|
|
403
|
-
selector={el.selector}
|
|
404
1155
|
seekTime={0}
|
|
405
1156
|
duration={el.duration}
|
|
406
1157
|
/>
|
|
@@ -417,6 +1168,7 @@ export function StudioApp() {
|
|
|
417
1168
|
labelColor={style.label}
|
|
418
1169
|
accentColor={style.clip}
|
|
419
1170
|
selector={el.selector}
|
|
1171
|
+
selectorIndex={el.selectorIndex}
|
|
420
1172
|
seekTime={el.start}
|
|
421
1173
|
duration={el.duration}
|
|
422
1174
|
/>
|
|
@@ -478,6 +1230,7 @@ export function StudioApp() {
|
|
|
478
1230
|
labelColor={style.label}
|
|
479
1231
|
accentColor={style.clip}
|
|
480
1232
|
selector={el.selector}
|
|
1233
|
+
selectorIndex={el.selectorIndex}
|
|
481
1234
|
seekTime={el.start}
|
|
482
1235
|
duration={el.duration}
|
|
483
1236
|
/>
|
|
@@ -562,16 +1315,80 @@ export function StudioApp() {
|
|
|
562
1315
|
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
|
563
1316
|
const [linting, setLinting] = useState(false);
|
|
564
1317
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
1318
|
+
const [, setStudioMotionRevision] = useState(0);
|
|
565
1319
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
566
1320
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
567
1321
|
const projectIdRef = useRef(projectId);
|
|
568
1322
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
569
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
|
+
}, []);
|
|
570
1365
|
|
|
571
1366
|
// Listen for external file changes (user editing HTML outside the editor).
|
|
572
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.
|
|
573
1371
|
useMountEffect(() => {
|
|
574
|
-
const handler = () => {
|
|
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;
|
|
575
1392
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
576
1393
|
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
577
1394
|
};
|
|
@@ -585,6 +1402,21 @@ export function StudioApp() {
|
|
|
585
1402
|
return () => es.close();
|
|
586
1403
|
});
|
|
587
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]);
|
|
588
1420
|
|
|
589
1421
|
// Load file tree when projectId changes.
|
|
590
1422
|
// Note: This is one of the few places where useEffect with deps is acceptable —
|
|
@@ -596,10 +1428,13 @@ export function StudioApp() {
|
|
|
596
1428
|
let cancelled = false;
|
|
597
1429
|
fetch(`/api/projects/${projectId}`)
|
|
598
1430
|
.then((r) => r.json())
|
|
599
|
-
.then((data: { files?: string[] }) => {
|
|
1431
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
600
1432
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1433
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
601
1434
|
})
|
|
602
|
-
.catch(() => {
|
|
1435
|
+
.catch(() => {
|
|
1436
|
+
if (!cancelled) setProjectDir(null);
|
|
1437
|
+
});
|
|
603
1438
|
return () => {
|
|
604
1439
|
cancelled = true;
|
|
605
1440
|
};
|
|
@@ -627,29 +1462,76 @@ export function StudioApp() {
|
|
|
627
1462
|
|
|
628
1463
|
const editingPathRef = useRef(editingFile?.path);
|
|
629
1464
|
editingPathRef.current = editingFile?.path;
|
|
1465
|
+
const editHistory = usePersistentEditHistory({ projectId });
|
|
630
1466
|
|
|
631
|
-
const
|
|
1467
|
+
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
632
1468
|
const pid = projectIdRef.current;
|
|
633
|
-
if (!pid)
|
|
634
|
-
const
|
|
635
|
-
if (!path)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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 : "";
|
|
651
1499
|
}, []);
|
|
652
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
|
+
// Suppress the file-change watcher echo — the save callback triggers
|
|
1512
|
+
// its own refresh, so a second one from the watcher causes a double-reload
|
|
1513
|
+
// race that can leave the player in a non-playable state.
|
|
1514
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1515
|
+
saveProjectFilesWithHistory({
|
|
1516
|
+
projectId: pid,
|
|
1517
|
+
label: "Edit source",
|
|
1518
|
+
kind: "source",
|
|
1519
|
+
coalesceKey: `source:${path}`,
|
|
1520
|
+
files: { [path]: content },
|
|
1521
|
+
readFile: readProjectFile,
|
|
1522
|
+
writeFile: writeProjectFile,
|
|
1523
|
+
recordEdit: editHistory.recordEdit,
|
|
1524
|
+
})
|
|
1525
|
+
.then(() => {
|
|
1526
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1527
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
1528
|
+
})
|
|
1529
|
+
.catch(() => {});
|
|
1530
|
+
}, 600);
|
|
1531
|
+
},
|
|
1532
|
+
[editHistory.recordEdit, readProjectFile, writeProjectFile],
|
|
1533
|
+
);
|
|
1534
|
+
|
|
653
1535
|
const handleTimelineElementMove = useCallback(
|
|
654
1536
|
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
655
1537
|
const pid = projectIdRef.current;
|
|
@@ -728,25 +1610,20 @@ export function StudioApp() {
|
|
|
728
1610
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
729
1611
|
}
|
|
730
1612
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (editingPathRef.current === targetPath) {
|
|
744
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
745
|
-
}
|
|
1613
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1614
|
+
await saveProjectFilesWithHistory({
|
|
1615
|
+
projectId: pid,
|
|
1616
|
+
label: "Move timeline clip",
|
|
1617
|
+
kind: "timeline",
|
|
1618
|
+
files: { [targetPath]: patchedContent },
|
|
1619
|
+
readFile: async () => originalContent,
|
|
1620
|
+
writeFile: writeProjectFile,
|
|
1621
|
+
recordEdit: editHistory.recordEdit,
|
|
1622
|
+
});
|
|
746
1623
|
|
|
747
1624
|
setRefreshKey((k) => k + 1);
|
|
748
1625
|
},
|
|
749
|
-
[activeCompPath, timelineElements],
|
|
1626
|
+
[activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
|
|
750
1627
|
);
|
|
751
1628
|
|
|
752
1629
|
const handleTimelineElementResize = useCallback(
|
|
@@ -818,25 +1695,20 @@ export function StudioApp() {
|
|
|
818
1695
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
819
1696
|
}
|
|
820
1697
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
if (editingPathRef.current === targetPath) {
|
|
834
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
835
|
-
}
|
|
1698
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1699
|
+
await saveProjectFilesWithHistory({
|
|
1700
|
+
projectId: pid,
|
|
1701
|
+
label: "Resize timeline clip",
|
|
1702
|
+
kind: "timeline",
|
|
1703
|
+
files: { [targetPath]: patchedContent },
|
|
1704
|
+
readFile: async () => originalContent,
|
|
1705
|
+
writeFile: writeProjectFile,
|
|
1706
|
+
recordEdit: editHistory.recordEdit,
|
|
1707
|
+
});
|
|
836
1708
|
|
|
837
1709
|
setRefreshKey((k) => k + 1);
|
|
838
1710
|
},
|
|
839
|
-
[activeCompPath],
|
|
1711
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
840
1712
|
);
|
|
841
1713
|
|
|
842
1714
|
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
@@ -852,6 +1724,7 @@ export function StudioApp() {
|
|
|
852
1724
|
|
|
853
1725
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
854
1726
|
setCaptureFrameTime(currentTime);
|
|
1727
|
+
await waitForPendingDomEditSaves();
|
|
855
1728
|
const href = buildFrameCaptureUrl({
|
|
856
1729
|
projectId,
|
|
857
1730
|
compositionPath: activeCompPath,
|
|
@@ -878,7 +1751,7 @@ export function StudioApp() {
|
|
|
878
1751
|
showToast(message);
|
|
879
1752
|
}
|
|
880
1753
|
},
|
|
881
|
-
[activeCompPath, projectId, showToast],
|
|
1754
|
+
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
882
1755
|
);
|
|
883
1756
|
|
|
884
1757
|
const handleTimelineElementDelete = useCallback(
|
|
@@ -961,48 +1834,1751 @@ export function StudioApp() {
|
|
|
961
1834
|
});
|
|
962
1835
|
}
|
|
963
1836
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
1837
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1838
|
+
await saveProjectFilesWithHistory({
|
|
1839
|
+
projectId: pid,
|
|
1840
|
+
label: "Delete timeline clip",
|
|
1841
|
+
kind: "timeline",
|
|
1842
|
+
files: { [targetPath]: patchedContent },
|
|
1843
|
+
readFile: async () => originalContent,
|
|
1844
|
+
writeFile: writeProjectFile,
|
|
1845
|
+
recordEdit: editHistory.recordEdit,
|
|
1846
|
+
});
|
|
975
1847
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1848
|
+
usePlayerStore
|
|
1849
|
+
.getState()
|
|
1850
|
+
.setElements(
|
|
1851
|
+
timelineElements.filter(
|
|
1852
|
+
(timelineElement) =>
|
|
1853
|
+
(timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
|
|
1854
|
+
),
|
|
1855
|
+
);
|
|
1856
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
1857
|
+
setRefreshKey((k) => k + 1);
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
1860
|
+
showToast(message);
|
|
1861
|
+
}
|
|
1862
|
+
},
|
|
1863
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
1864
|
+
);
|
|
1865
|
+
|
|
1866
|
+
const handleDomEditElementDelete = useCallback(
|
|
1867
|
+
async (selection: DomEditSelection) => {
|
|
1868
|
+
const pid = projectIdRef.current;
|
|
1869
|
+
if (!pid) return;
|
|
1870
|
+
|
|
1871
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
1872
|
+
try {
|
|
1873
|
+
const response = await fetch(
|
|
1874
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1875
|
+
);
|
|
1876
|
+
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
|
1877
|
+
|
|
1878
|
+
const data = (await response.json()) as { content?: string };
|
|
1879
|
+
const originalContent = data.content;
|
|
1880
|
+
if (typeof originalContent !== "string")
|
|
1881
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
1882
|
+
|
|
1883
|
+
const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
|
|
1884
|
+
? {
|
|
1885
|
+
id: selection.id,
|
|
1886
|
+
selector: selection.selector,
|
|
1887
|
+
selectorIndex: selection.selectorIndex,
|
|
1888
|
+
}
|
|
1889
|
+
: selection.selector
|
|
1890
|
+
? { selector: selection.selector, selectorIndex: selection.selectorIndex }
|
|
1891
|
+
: ({} as never);
|
|
1892
|
+
if (!patchTarget.id && !patchTarget.selector) {
|
|
1893
|
+
throw new Error("Selected element has no patchable target");
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const removeResponse = await fetch(
|
|
1897
|
+
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
1898
|
+
{
|
|
1899
|
+
method: "POST",
|
|
1900
|
+
headers: { "Content-Type": "application/json" },
|
|
1901
|
+
body: JSON.stringify({ target: patchTarget }),
|
|
1902
|
+
},
|
|
1903
|
+
);
|
|
1904
|
+
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
|
|
1905
|
+
|
|
1906
|
+
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
|
|
1907
|
+
const patchedContent =
|
|
1908
|
+
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
1909
|
+
|
|
1910
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1911
|
+
await saveProjectFilesWithHistory({
|
|
1912
|
+
projectId: pid,
|
|
1913
|
+
label: "Delete element",
|
|
1914
|
+
kind: "timeline",
|
|
1915
|
+
files: { [targetPath]: patchedContent },
|
|
1916
|
+
readFile: async () => originalContent,
|
|
1917
|
+
writeFile: writeProjectFile,
|
|
1918
|
+
recordEdit: editHistory.recordEdit,
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
domEditSelectionRef.current = null;
|
|
1922
|
+
domEditGroupSelectionsRef.current = [];
|
|
1923
|
+
setDomEditSelection(null);
|
|
1924
|
+
setDomEditGroupSelections([]);
|
|
1925
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
1926
|
+
setRefreshKey((k) => k + 1);
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
1929
|
+
showToast(message);
|
|
1930
|
+
}
|
|
1931
|
+
},
|
|
1932
|
+
[activeCompPath, editHistory.recordEdit, showToast, writeProjectFile],
|
|
1933
|
+
);
|
|
1934
|
+
|
|
1935
|
+
// ── Consolidated keyboard shortcuts ────────────────────────────────
|
|
1936
|
+
// All app-level window keydown handlers live here.
|
|
1937
|
+
// Component-scoped shortcuts (playback J/K/L/Space, caption nudge)
|
|
1938
|
+
// stay in their respective hooks.
|
|
1939
|
+
const handleToggleRef = useRef(handleTimelineToggleHotkey);
|
|
1940
|
+
handleToggleRef.current = handleTimelineToggleHotkey;
|
|
1941
|
+
const handleDeleteRef = useRef(handleTimelineElementDelete);
|
|
1942
|
+
handleDeleteRef.current = handleTimelineElementDelete;
|
|
1943
|
+
const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
|
|
1944
|
+
handleDomEditDeleteRef.current = handleDomEditElementDelete;
|
|
1945
|
+
|
|
1946
|
+
handleAppKeyDownRef.current = (event: KeyboardEvent) => {
|
|
1947
|
+
// Shift+T — toggle timeline
|
|
1948
|
+
handleToggleRef.current(event);
|
|
1949
|
+
|
|
1950
|
+
// Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
|
|
1951
|
+
if (event.metaKey || event.ctrlKey) {
|
|
1952
|
+
if (!shouldIgnoreHistoryShortcut(event.target)) {
|
|
1953
|
+
const key = event.key.toLowerCase();
|
|
1954
|
+
if (key === "z" && !event.shiftKey) {
|
|
1955
|
+
event.preventDefault();
|
|
1956
|
+
void handleUndoRef.current();
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
1960
|
+
event.preventDefault();
|
|
1961
|
+
void handleRedoRef.current();
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// Cmd/Ctrl+1 — sidebar: Compositions tab
|
|
1967
|
+
if (event.key === "1") {
|
|
1968
|
+
event.preventDefault();
|
|
1969
|
+
leftSidebarRef.current?.selectTab("compositions");
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// Cmd/Ctrl+2 — sidebar: Assets tab
|
|
1974
|
+
if (event.key === "2") {
|
|
1975
|
+
event.preventDefault();
|
|
1976
|
+
leftSidebarRef.current?.selectTab("assets");
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Delete / Backspace — remove selected element (timeline clip or preview selection)
|
|
1982
|
+
if (
|
|
1983
|
+
(event.key === "Delete" || event.key === "Backspace") &&
|
|
1984
|
+
!event.metaKey &&
|
|
1985
|
+
!event.ctrlKey &&
|
|
1986
|
+
!event.altKey &&
|
|
1987
|
+
!isEditableTarget(event.target)
|
|
1988
|
+
) {
|
|
1989
|
+
const { selectedElementId, elements } = usePlayerStore.getState();
|
|
1990
|
+
if (selectedElementId) {
|
|
1991
|
+
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
1992
|
+
if (element) {
|
|
1993
|
+
event.preventDefault();
|
|
1994
|
+
void handleDeleteRef.current(element);
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
const domSelection = domEditSelectionRef.current;
|
|
1999
|
+
if (domSelection) {
|
|
2000
|
+
event.preventDefault();
|
|
2001
|
+
void handleDomEditDeleteRef.current(domSelection);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2007
|
+
useEffect(() => {
|
|
2008
|
+
function handleAppKeyDown(event: KeyboardEvent) {
|
|
2009
|
+
handleAppKeyDownRef.current?.(event);
|
|
2010
|
+
}
|
|
2011
|
+
window.addEventListener("keydown", handleAppKeyDown, true);
|
|
2012
|
+
return () => window.removeEventListener("keydown", handleAppKeyDown, true);
|
|
2013
|
+
}, []);
|
|
2014
|
+
|
|
2015
|
+
const handleBlockedTimelineEdit = useCallback(
|
|
2016
|
+
(_element: TimelineElement) => {
|
|
2017
|
+
const now = Date.now();
|
|
2018
|
+
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
2019
|
+
lastBlockedTimelineToastAtRef.current = now;
|
|
2020
|
+
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
|
|
2021
|
+
},
|
|
2022
|
+
[showToast],
|
|
2023
|
+
);
|
|
2024
|
+
|
|
2025
|
+
const handleBlockedDomMove = useCallback(
|
|
2026
|
+
(selection: DomEditSelection) => {
|
|
2027
|
+
const now = Date.now();
|
|
2028
|
+
if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
|
|
2029
|
+
lastBlockedDomMoveToastAtRef.current = now;
|
|
2030
|
+
showToast(
|
|
2031
|
+
selection.capabilities.reasonIfDisabled ??
|
|
2032
|
+
"This element can’t be adjusted directly from the preview.",
|
|
2033
|
+
"info",
|
|
2034
|
+
);
|
|
2035
|
+
},
|
|
2036
|
+
[showToast],
|
|
2037
|
+
);
|
|
2038
|
+
|
|
2039
|
+
const applyDomSelection = useCallback(
|
|
2040
|
+
(
|
|
2041
|
+
selection: DomEditSelection | null,
|
|
2042
|
+
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
2043
|
+
) => {
|
|
2044
|
+
setAgentPromptTagSnippet(undefined);
|
|
2045
|
+
setAgentPromptSelectionContext(undefined);
|
|
2046
|
+
setAgentModalAnchorPoint(null);
|
|
2047
|
+
setCopiedAgentPrompt(false);
|
|
2048
|
+
if (!selection) {
|
|
2049
|
+
domEditSelectionRef.current = null;
|
|
2050
|
+
domEditGroupSelectionsRef.current = [];
|
|
2051
|
+
setDomEditSelection(null);
|
|
2052
|
+
setDomEditGroupSelections([]);
|
|
2053
|
+
setSelectedTimelineElementId(null);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
|
|
2057
|
+
domEditSelectionRef.current = null;
|
|
2058
|
+
domEditGroupSelectionsRef.current = [];
|
|
2059
|
+
setDomEditSelection(null);
|
|
2060
|
+
setDomEditGroupSelections([]);
|
|
2061
|
+
setSelectedTimelineElementId(null);
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
const isAdditiveSelection = Boolean(options?.additive);
|
|
2066
|
+
const currentSelection = domEditSelectionRef.current;
|
|
2067
|
+
const previousGroup = domEditGroupSelectionsRef.current;
|
|
2068
|
+
const currentGroup = isAdditiveSelection
|
|
2069
|
+
? seedDomEditGroupWithSelection(previousGroup, currentSelection)
|
|
2070
|
+
: previousGroup;
|
|
2071
|
+
const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
|
|
2072
|
+
const nextGroup = options?.preserveGroup
|
|
2073
|
+
? replaceDomEditGroupSelection(currentGroup, selection)
|
|
2074
|
+
: isAdditiveSelection
|
|
2075
|
+
? toggleDomEditGroupSelection(currentGroup, selection)
|
|
2076
|
+
: [selection];
|
|
2077
|
+
const nextSelection = options?.preserveGroup
|
|
2078
|
+
? selection
|
|
2079
|
+
: isAdditiveSelection && wasInGroup
|
|
2080
|
+
? domEditSelectionsTargetSame(currentSelection, selection)
|
|
2081
|
+
? (nextGroup[0] ?? null)
|
|
2082
|
+
: domEditSelectionInGroup(nextGroup, currentSelection)
|
|
2083
|
+
? currentSelection
|
|
2084
|
+
: (nextGroup[0] ?? null)
|
|
2085
|
+
: selection;
|
|
2086
|
+
|
|
2087
|
+
domEditSelectionRef.current = nextSelection;
|
|
2088
|
+
domEditGroupSelectionsRef.current = nextGroup;
|
|
2089
|
+
setDomEditSelection(nextSelection);
|
|
2090
|
+
setDomEditGroupSelections(nextGroup);
|
|
2091
|
+
|
|
2092
|
+
if (nextSelection) {
|
|
2093
|
+
if (options?.revealPanel !== false) {
|
|
2094
|
+
setRightCollapsed(false);
|
|
2095
|
+
setRightPanelTab("design");
|
|
2096
|
+
}
|
|
2097
|
+
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
|
2098
|
+
nextSelection,
|
|
2099
|
+
timelineElements,
|
|
2100
|
+
);
|
|
2101
|
+
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
setSelectedTimelineElementId(null);
|
|
2106
|
+
},
|
|
2107
|
+
[setSelectedTimelineElementId, timelineElements],
|
|
2108
|
+
);
|
|
2109
|
+
|
|
2110
|
+
const clearDomSelection = useCallback(() => {
|
|
2111
|
+
applyDomSelection(null, { revealPanel: false });
|
|
2112
|
+
}, [applyDomSelection]);
|
|
2113
|
+
|
|
2114
|
+
const readHistoryProjectFile = useCallback(
|
|
2115
|
+
async (path: string): Promise<string> => {
|
|
2116
|
+
return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
|
|
2117
|
+
? readOptionalProjectFile(path)
|
|
2118
|
+
: readProjectFile(path);
|
|
2119
|
+
},
|
|
2120
|
+
[readOptionalProjectFile, readProjectFile],
|
|
2121
|
+
);
|
|
2122
|
+
|
|
2123
|
+
const writeHistoryProjectFile = useCallback(
|
|
2124
|
+
async (path: string, content: string): Promise<void> => {
|
|
2125
|
+
await writeProjectFile(path, content);
|
|
2126
|
+
if (path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH) {
|
|
2127
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2128
|
+
}
|
|
2129
|
+
},
|
|
2130
|
+
[writeProjectFile],
|
|
2131
|
+
);
|
|
2132
|
+
|
|
2133
|
+
const applyCurrentStudioManualEditsToPreview = useCallback(
|
|
2134
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
2135
|
+
if (!iframe) return;
|
|
2136
|
+
let doc: Document | null = null;
|
|
2137
|
+
try {
|
|
2138
|
+
doc = iframe.contentDocument;
|
|
2139
|
+
} catch {
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (!doc) return;
|
|
2143
|
+
const previewDoc = doc;
|
|
2144
|
+
|
|
2145
|
+
const applyManifest = () => {
|
|
2146
|
+
applyStudioManualEditManifest(
|
|
2147
|
+
previewDoc,
|
|
2148
|
+
studioManualEditManifestRef.current,
|
|
2149
|
+
activeCompPathRef.current,
|
|
2150
|
+
);
|
|
2151
|
+
};
|
|
2152
|
+
const applyAndInstallSeekHooks = () => {
|
|
2153
|
+
applyManifest();
|
|
2154
|
+
if (iframe.contentWindow) {
|
|
2155
|
+
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
const win = iframe.contentWindow;
|
|
2160
|
+
applyAndInstallSeekHooks();
|
|
2161
|
+
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
2162
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
|
|
2163
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
|
|
2164
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
|
|
2165
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
|
|
2166
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
|
|
2167
|
+
},
|
|
2168
|
+
[],
|
|
2169
|
+
);
|
|
2170
|
+
|
|
2171
|
+
const applyStudioManualEditsToPreview = useCallback(
|
|
2172
|
+
async (
|
|
2173
|
+
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
2174
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
2175
|
+
) => {
|
|
2176
|
+
const readRevision = studioManualEditRevisionRef.current;
|
|
2177
|
+
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
2178
|
+
if (!readFromDiskFirst) {
|
|
2179
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2180
|
+
}
|
|
2181
|
+
let content: string;
|
|
2182
|
+
try {
|
|
2183
|
+
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
const message =
|
|
2186
|
+
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
|
2187
|
+
showToast(message);
|
|
2188
|
+
if (readFromDiskFirst) {
|
|
2189
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2190
|
+
}
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
|
2194
|
+
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
|
2195
|
+
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
|
2196
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
if (readFromDiskFirst) {
|
|
2200
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
2201
|
+
}
|
|
2202
|
+
},
|
|
2203
|
+
[applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
|
|
2204
|
+
);
|
|
2205
|
+
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
|
2206
|
+
|
|
2207
|
+
const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
|
|
2208
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
|
|
2209
|
+
applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
|
|
2210
|
+
[applyStudioManualEditsToPreview],
|
|
2211
|
+
);
|
|
2212
|
+
|
|
2213
|
+
const applyCurrentStudioMotionToPreview = useCallback(
|
|
2214
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
2215
|
+
if (!iframe) return;
|
|
2216
|
+
let doc: Document | null = null;
|
|
2217
|
+
try {
|
|
2218
|
+
doc = iframe.contentDocument;
|
|
2219
|
+
} catch {
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
if (!doc) return;
|
|
2223
|
+
const previewDoc = doc;
|
|
2224
|
+
|
|
2225
|
+
const applyManifest = () => {
|
|
2226
|
+
applyStudioMotionManifest(
|
|
2227
|
+
previewDoc,
|
|
2228
|
+
studioMotionManifestRef.current,
|
|
2229
|
+
activeCompPathRef.current,
|
|
2230
|
+
);
|
|
2231
|
+
};
|
|
2232
|
+
const applyAndInstallSeekHooks = () => {
|
|
2233
|
+
applyManifest();
|
|
2234
|
+
if (iframe.contentWindow) {
|
|
2235
|
+
installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
|
|
2236
|
+
}
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
const win = iframe.contentWindow;
|
|
2240
|
+
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
2241
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 120);
|
|
2242
|
+
},
|
|
2243
|
+
[],
|
|
2244
|
+
);
|
|
2245
|
+
|
|
2246
|
+
const applyStudioMotionToPreview = useCallback(
|
|
2247
|
+
async (
|
|
2248
|
+
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
2249
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
2250
|
+
) => {
|
|
2251
|
+
const readRevision = studioMotionRevisionRef.current;
|
|
2252
|
+
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
2253
|
+
if (!readFromDiskFirst) {
|
|
2254
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
2255
|
+
}
|
|
2256
|
+
let content: string;
|
|
2257
|
+
try {
|
|
2258
|
+
content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
2259
|
+
} catch (error) {
|
|
2260
|
+
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
|
|
2261
|
+
showToast(message);
|
|
2262
|
+
if (readFromDiskFirst) {
|
|
2263
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
2264
|
+
}
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
|
|
2268
|
+
studioMotionManifestRef.current = parseStudioMotionManifest(content);
|
|
2269
|
+
if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
|
|
2270
|
+
setStudioMotionRevision((revision) => revision + 1);
|
|
2271
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (readFromDiskFirst) {
|
|
2275
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
2276
|
+
}
|
|
2277
|
+
},
|
|
2278
|
+
[applyCurrentStudioMotionToPreview, readOptionalProjectFile, showToast],
|
|
2279
|
+
);
|
|
2280
|
+
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
|
|
2281
|
+
|
|
2282
|
+
const applyStudioMotionToPreviewAfterRefresh = useCallback(
|
|
2283
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
|
|
2284
|
+
applyStudioMotionToPreview(iframe, { readFromDiskFirst: true }),
|
|
2285
|
+
[applyStudioMotionToPreview],
|
|
2286
|
+
);
|
|
2287
|
+
|
|
2288
|
+
const commitStudioManualEditManifestOptimistically = useCallback(
|
|
2289
|
+
(
|
|
2290
|
+
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
|
2291
|
+
options: { label: string; coalesceKey: string },
|
|
2292
|
+
) => {
|
|
2293
|
+
const previousManifest = studioManualEditManifestRef.current;
|
|
2294
|
+
const nextManifest = updateManifest(previousManifest);
|
|
2295
|
+
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
|
2296
|
+
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
|
2297
|
+
if (nextContent === previousContent) {
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
const revision = studioManualEditRevisionRef.current + 1;
|
|
2302
|
+
studioManualEditRevisionRef.current = revision;
|
|
2303
|
+
studioManualEditManifestRef.current = nextManifest;
|
|
2304
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2305
|
+
|
|
2306
|
+
const save = async () => {
|
|
2307
|
+
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
2308
|
+
const diskManifest = parseStudioManualEditManifest(originalContent);
|
|
2309
|
+
const nextDiskManifest = updateManifest(diskManifest);
|
|
2310
|
+
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
|
2311
|
+
if (nextDiskContent === originalContent) {
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const pid = projectIdRef.current;
|
|
2316
|
+
if (!pid) throw new Error("No active project");
|
|
2317
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2318
|
+
await saveProjectFilesWithHistory({
|
|
2319
|
+
projectId: pid,
|
|
2320
|
+
label: options.label,
|
|
2321
|
+
kind: "manual",
|
|
2322
|
+
coalesceKey: options.coalesceKey,
|
|
2323
|
+
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
|
2324
|
+
readFile: async () => originalContent,
|
|
2325
|
+
writeFile: writeProjectFile,
|
|
2326
|
+
recordEdit: editHistory.recordEdit,
|
|
2327
|
+
});
|
|
2328
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2329
|
+
|
|
2330
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
2331
|
+
studioManualEditManifestRef.current = nextDiskManifest;
|
|
2332
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2333
|
+
}
|
|
2334
|
+
};
|
|
2335
|
+
|
|
2336
|
+
void queueDomEditSave(save).catch((error) => {
|
|
2337
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
2338
|
+
studioManualEditRevisionRef.current += 1;
|
|
2339
|
+
studioManualEditManifestRef.current = previousManifest;
|
|
2340
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2341
|
+
}
|
|
2342
|
+
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
|
2343
|
+
showToast(message);
|
|
2344
|
+
});
|
|
2345
|
+
},
|
|
2346
|
+
[
|
|
2347
|
+
applyCurrentStudioManualEditsToPreview,
|
|
2348
|
+
editHistory.recordEdit,
|
|
2349
|
+
queueDomEditSave,
|
|
2350
|
+
readOptionalProjectFile,
|
|
2351
|
+
showToast,
|
|
2352
|
+
writeProjectFile,
|
|
2353
|
+
],
|
|
2354
|
+
);
|
|
2355
|
+
|
|
2356
|
+
const commitStudioMotionManifestOptimistically = useCallback(
|
|
2357
|
+
(
|
|
2358
|
+
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
|
2359
|
+
options: { label: string; coalesceKey: string },
|
|
2360
|
+
) => {
|
|
2361
|
+
const previousManifest = studioMotionManifestRef.current;
|
|
2362
|
+
const nextManifest = updateManifest(previousManifest);
|
|
2363
|
+
const previousContent = serializeStudioMotionManifest(previousManifest);
|
|
2364
|
+
const nextContent = serializeStudioMotionManifest(nextManifest);
|
|
2365
|
+
if (nextContent === previousContent) {
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
const revision = studioMotionRevisionRef.current + 1;
|
|
2370
|
+
studioMotionRevisionRef.current = revision;
|
|
2371
|
+
studioMotionManifestRef.current = nextManifest;
|
|
2372
|
+
setStudioMotionRevision((current) => current + 1);
|
|
2373
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2374
|
+
|
|
2375
|
+
const save = async () => {
|
|
2376
|
+
const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
2377
|
+
const diskManifest = parseStudioMotionManifest(originalContent);
|
|
2378
|
+
const nextDiskManifest = updateManifest(diskManifest);
|
|
2379
|
+
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
|
|
2380
|
+
if (nextDiskContent === originalContent) {
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const pid = projectIdRef.current;
|
|
2385
|
+
if (!pid) throw new Error("No active project");
|
|
2386
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2387
|
+
await saveProjectFilesWithHistory({
|
|
2388
|
+
projectId: pid,
|
|
2389
|
+
label: options.label,
|
|
2390
|
+
kind: "motion",
|
|
2391
|
+
coalesceKey: options.coalesceKey,
|
|
2392
|
+
files: { [STUDIO_MOTION_PATH]: nextDiskContent },
|
|
2393
|
+
readFile: async () => originalContent,
|
|
2394
|
+
writeFile: writeProjectFile,
|
|
2395
|
+
recordEdit: editHistory.recordEdit,
|
|
2396
|
+
});
|
|
2397
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2398
|
+
|
|
2399
|
+
if (studioMotionRevisionRef.current === revision) {
|
|
2400
|
+
studioMotionManifestRef.current = nextDiskManifest;
|
|
2401
|
+
setStudioMotionRevision((current) => current + 1);
|
|
2402
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
void queueDomEditSave(save).catch((error) => {
|
|
2407
|
+
if (studioMotionRevisionRef.current === revision) {
|
|
2408
|
+
studioMotionRevisionRef.current += 1;
|
|
2409
|
+
studioMotionManifestRef.current = previousManifest;
|
|
2410
|
+
setStudioMotionRevision((current) => current + 1);
|
|
2411
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2412
|
+
}
|
|
2413
|
+
const message = error instanceof Error ? error.message : "Failed to save motion edit";
|
|
2414
|
+
showToast(message);
|
|
2415
|
+
});
|
|
2416
|
+
},
|
|
2417
|
+
[
|
|
2418
|
+
applyCurrentStudioMotionToPreview,
|
|
2419
|
+
editHistory.recordEdit,
|
|
2420
|
+
queueDomEditSave,
|
|
2421
|
+
readOptionalProjectFile,
|
|
2422
|
+
showToast,
|
|
2423
|
+
writeProjectFile,
|
|
2424
|
+
],
|
|
2425
|
+
);
|
|
2426
|
+
|
|
2427
|
+
const syncHistoryPreviewAfterApply = useCallback(
|
|
2428
|
+
async (paths: string[] | undefined) => {
|
|
2429
|
+
const changedPaths = paths ?? [];
|
|
2430
|
+
const manualManifestOnly =
|
|
2431
|
+
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
|
2432
|
+
const motionManifestOnly =
|
|
2433
|
+
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
|
|
2434
|
+
|
|
2435
|
+
if (manualManifestOnly) {
|
|
2436
|
+
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
if (motionManifestOnly) {
|
|
2440
|
+
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
setRefreshKey((key) => key + 1);
|
|
2445
|
+
},
|
|
2446
|
+
[applyStudioManualEditsToPreview, applyStudioMotionToPreview],
|
|
2447
|
+
);
|
|
2448
|
+
|
|
2449
|
+
const handleUndo = useCallback(async () => {
|
|
2450
|
+
await waitForPendingDomEditSaves();
|
|
2451
|
+
const result = await editHistory.undo({
|
|
2452
|
+
readFile: readHistoryProjectFile,
|
|
2453
|
+
writeFile: writeHistoryProjectFile,
|
|
2454
|
+
});
|
|
2455
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
2456
|
+
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
if (result.ok && result.label) {
|
|
2460
|
+
clearDomSelection();
|
|
2461
|
+
await syncHistoryPreviewAfterApply(result.paths);
|
|
2462
|
+
showToast(`Undid ${result.label}`, "info");
|
|
2463
|
+
}
|
|
2464
|
+
}, [
|
|
2465
|
+
clearDomSelection,
|
|
2466
|
+
editHistory,
|
|
2467
|
+
readHistoryProjectFile,
|
|
2468
|
+
showToast,
|
|
2469
|
+
syncHistoryPreviewAfterApply,
|
|
2470
|
+
waitForPendingDomEditSaves,
|
|
2471
|
+
writeHistoryProjectFile,
|
|
2472
|
+
]);
|
|
2473
|
+
|
|
2474
|
+
const handleRedo = useCallback(async () => {
|
|
2475
|
+
await waitForPendingDomEditSaves();
|
|
2476
|
+
const result = await editHistory.redo({
|
|
2477
|
+
readFile: readHistoryProjectFile,
|
|
2478
|
+
writeFile: writeHistoryProjectFile,
|
|
2479
|
+
});
|
|
2480
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
2481
|
+
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
if (result.ok && result.label) {
|
|
2485
|
+
clearDomSelection();
|
|
2486
|
+
await syncHistoryPreviewAfterApply(result.paths);
|
|
2487
|
+
showToast(`Redid ${result.label}`, "info");
|
|
2488
|
+
}
|
|
2489
|
+
}, [
|
|
2490
|
+
clearDomSelection,
|
|
2491
|
+
editHistory,
|
|
2492
|
+
readHistoryProjectFile,
|
|
2493
|
+
showToast,
|
|
2494
|
+
syncHistoryPreviewAfterApply,
|
|
2495
|
+
waitForPendingDomEditSaves,
|
|
2496
|
+
writeHistoryProjectFile,
|
|
2497
|
+
]);
|
|
2498
|
+
|
|
2499
|
+
const handleUndoRef = useRef(handleUndo);
|
|
2500
|
+
const handleRedoRef = useRef(handleRedo);
|
|
2501
|
+
handleUndoRef.current = handleUndo;
|
|
2502
|
+
handleRedoRef.current = handleRedo;
|
|
2503
|
+
|
|
2504
|
+
// History hotkey — no longer has its own window listener (consolidated
|
|
2505
|
+
// handler covers it), but kept as a named callback for iframe forwarding.
|
|
2506
|
+
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
|
2507
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
2508
|
+
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
|
2509
|
+
const key = event.key.toLowerCase();
|
|
2510
|
+
if (key === "z" && !event.shiftKey) {
|
|
2511
|
+
event.preventDefault();
|
|
2512
|
+
void handleUndoRef.current();
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
2516
|
+
event.preventDefault();
|
|
2517
|
+
void handleRedoRef.current();
|
|
2518
|
+
}
|
|
2519
|
+
}, []);
|
|
2520
|
+
|
|
2521
|
+
const syncPreviewHistoryHotkey = useCallback(
|
|
2522
|
+
(iframe: HTMLIFrameElement | null) => {
|
|
2523
|
+
previewHistoryHotkeyCleanupRef.current?.();
|
|
2524
|
+
previewHistoryHotkeyCleanupRef.current = null;
|
|
2525
|
+
|
|
2526
|
+
const win = iframe?.contentWindow ?? null;
|
|
2527
|
+
let doc: Document | null = null;
|
|
2528
|
+
try {
|
|
2529
|
+
doc = iframe?.contentDocument ?? null;
|
|
2530
|
+
} catch {
|
|
2531
|
+
doc = null;
|
|
2532
|
+
}
|
|
2533
|
+
if (!win && !doc) return;
|
|
2534
|
+
|
|
2535
|
+
win?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2536
|
+
doc?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2537
|
+
previewHistoryHotkeyCleanupRef.current = () => {
|
|
2538
|
+
win?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2539
|
+
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2540
|
+
};
|
|
2541
|
+
},
|
|
2542
|
+
[handleHistoryHotkey],
|
|
2543
|
+
);
|
|
2544
|
+
|
|
2545
|
+
useEffect(
|
|
2546
|
+
() => () => {
|
|
2547
|
+
previewHistoryHotkeyCleanupRef.current?.();
|
|
2548
|
+
previewHistoryHotkeyCleanupRef.current = null;
|
|
2549
|
+
},
|
|
2550
|
+
[],
|
|
2551
|
+
);
|
|
2552
|
+
|
|
2553
|
+
const buildDomSelectionFromTarget = useCallback(
|
|
2554
|
+
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
2555
|
+
return resolveDomEditSelection(target, {
|
|
2556
|
+
activeCompositionPath: activeCompPath,
|
|
2557
|
+
isMasterView,
|
|
2558
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
2559
|
+
});
|
|
2560
|
+
},
|
|
2561
|
+
[activeCompPath, isMasterView],
|
|
2562
|
+
);
|
|
2563
|
+
|
|
2564
|
+
const resolveDomSelectionFromPreviewPoint = useCallback(
|
|
2565
|
+
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
|
|
2566
|
+
const iframe = previewIframeRef.current;
|
|
2567
|
+
if (!iframe || captionEditMode) return null;
|
|
2568
|
+
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
|
|
2569
|
+
if (!target) return null;
|
|
2570
|
+
return buildDomSelectionFromTarget(target, {
|
|
2571
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
2572
|
+
});
|
|
2573
|
+
},
|
|
2574
|
+
[activeCompPath, buildDomSelectionFromTarget, captionEditMode],
|
|
2575
|
+
);
|
|
2576
|
+
|
|
2577
|
+
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
|
|
2578
|
+
if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
|
|
2579
|
+
domEditHoverSelectionRef.current = selection;
|
|
2580
|
+
setDomEditHoverSelection(selection);
|
|
2581
|
+
}, []);
|
|
2582
|
+
|
|
2583
|
+
const buildDomSelectionForTimelineElement = useCallback(
|
|
2584
|
+
(element: TimelineElement): DomEditSelection | null => {
|
|
2585
|
+
const iframe = previewIframeRef.current;
|
|
2586
|
+
let doc: Document | null = null;
|
|
2587
|
+
try {
|
|
2588
|
+
doc = iframe?.contentDocument ?? null;
|
|
2589
|
+
} catch {
|
|
2590
|
+
return null;
|
|
2591
|
+
}
|
|
2592
|
+
if (!doc) return null;
|
|
2593
|
+
|
|
2594
|
+
const targetElement = findElementForTimelineElement(doc, element, {
|
|
2595
|
+
activeCompositionPath: activeCompPath,
|
|
2596
|
+
compIdToSrc,
|
|
2597
|
+
isMasterView,
|
|
2598
|
+
});
|
|
2599
|
+
return targetElement
|
|
2600
|
+
? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
|
|
2601
|
+
: null;
|
|
2602
|
+
},
|
|
2603
|
+
[activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView],
|
|
2604
|
+
);
|
|
2605
|
+
|
|
2606
|
+
const inspectedTimelineElement = useMemo(
|
|
2607
|
+
() =>
|
|
2608
|
+
timelineElements.find(
|
|
2609
|
+
(element) => getTimelineElementKey(element) === inspectedTimelineElementId,
|
|
2610
|
+
) ?? null,
|
|
2611
|
+
[inspectedTimelineElementId, timelineElements],
|
|
2612
|
+
);
|
|
2613
|
+
|
|
2614
|
+
const timelineLayerChildCounts = useMemo(() => {
|
|
2615
|
+
void previewDocumentVersion;
|
|
2616
|
+
const counts = new Map<string, number>();
|
|
2617
|
+
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return counts;
|
|
2618
|
+
|
|
2619
|
+
const key = getTimelineElementKey(inspectedTimelineElement);
|
|
2620
|
+
if (key) {
|
|
2621
|
+
const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
|
|
2622
|
+
const count = countDomEditChildLayers(selection?.element, {
|
|
2623
|
+
activeCompositionPath: activeCompPath,
|
|
2624
|
+
isMasterView,
|
|
2625
|
+
});
|
|
2626
|
+
if (count > 0) counts.set(key, count);
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
return counts;
|
|
2630
|
+
}, [
|
|
2631
|
+
activeCompPath,
|
|
2632
|
+
buildDomSelectionForTimelineElement,
|
|
2633
|
+
inspectedTimelineElement,
|
|
2634
|
+
isMasterView,
|
|
2635
|
+
previewDocumentVersion,
|
|
2636
|
+
]);
|
|
2637
|
+
|
|
2638
|
+
const inspectedTimelineLayers = useMemo(() => {
|
|
2639
|
+
void previewDocumentVersion;
|
|
2640
|
+
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return [];
|
|
2641
|
+
const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
|
|
2642
|
+
return collectDomEditLayerItems(selection?.element, {
|
|
2643
|
+
activeCompositionPath: activeCompPath,
|
|
2644
|
+
isMasterView,
|
|
2645
|
+
});
|
|
2646
|
+
}, [
|
|
2647
|
+
activeCompPath,
|
|
2648
|
+
buildDomSelectionForTimelineElement,
|
|
2649
|
+
inspectedTimelineElement,
|
|
2650
|
+
isMasterView,
|
|
2651
|
+
previewDocumentVersion,
|
|
2652
|
+
]);
|
|
2653
|
+
|
|
2654
|
+
const selectedTimelineLayerKey = useMemo(
|
|
2655
|
+
() => (domEditSelection ? getDomEditLayerKey(domEditSelection) : null),
|
|
2656
|
+
[domEditSelection],
|
|
2657
|
+
);
|
|
2658
|
+
|
|
2659
|
+
const handleTimelineElementSelect = useCallback(
|
|
2660
|
+
(element: TimelineElement | null) => {
|
|
2661
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2662
|
+
if (!element) {
|
|
2663
|
+
applyDomSelection(null, { revealPanel: false });
|
|
2664
|
+
setInspectedTimelineElementId(null);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
const selection = buildDomSelectionForTimelineElement(element);
|
|
2669
|
+
if (selection) applyDomSelection(selection);
|
|
2670
|
+
|
|
2671
|
+
const key = getTimelineElementKey(element);
|
|
2672
|
+
if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
|
|
2673
|
+
setInspectedTimelineElementId(key);
|
|
2674
|
+
setLeftCollapsed(false);
|
|
2675
|
+
|
|
2676
|
+
const iframe = previewIframeRef.current;
|
|
2677
|
+
if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
|
|
2678
|
+
seekStudioPreview(iframe, element.start);
|
|
2679
|
+
}
|
|
2680
|
+
} else {
|
|
2681
|
+
setInspectedTimelineElementId(null);
|
|
2682
|
+
}
|
|
2683
|
+
},
|
|
2684
|
+
[applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
|
|
2685
|
+
);
|
|
2686
|
+
|
|
2687
|
+
const handleTimelineElementInspect = useCallback(
|
|
2688
|
+
(element: TimelineElement) => {
|
|
2689
|
+
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2690
|
+
if (!canInspectTimelineElement(element)) {
|
|
2691
|
+
showToast("Audio clips do not have visual layers.", "info");
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
const key = getTimelineElementKey(element);
|
|
2696
|
+
if (!key) return;
|
|
2697
|
+
setInspectedTimelineElementId((current) => (current === key ? null : key));
|
|
2698
|
+
setLeftCollapsed(false);
|
|
2699
|
+
|
|
2700
|
+
const iframe = previewIframeRef.current;
|
|
2701
|
+
if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
|
|
2702
|
+
seekStudioPreview(iframe, element.start);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
const selection = buildDomSelectionForTimelineElement(element);
|
|
2706
|
+
if (selection) applyDomSelection(selection);
|
|
2707
|
+
},
|
|
2708
|
+
[applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
|
|
2709
|
+
);
|
|
2710
|
+
|
|
2711
|
+
const handleTimelineLayerSelect = useCallback(
|
|
2712
|
+
(layer: DomEditLayerItem) => {
|
|
2713
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2714
|
+
|
|
2715
|
+
const iframe = previewIframeRef.current;
|
|
2716
|
+
const player = getPreviewPlayer(iframe?.contentWindow);
|
|
2717
|
+
const visibleTime = resolveLayerVisibleSeekTime(
|
|
2718
|
+
layer.element,
|
|
2719
|
+
inspectedTimelineElement,
|
|
2720
|
+
player,
|
|
2721
|
+
);
|
|
2722
|
+
if (visibleTime != null) {
|
|
2723
|
+
seekStudioPreview(iframe, visibleTime);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
const selection = buildDomSelectionFromTarget(layer.element, { preferClipAncestor: false });
|
|
2727
|
+
if (!selection) {
|
|
2728
|
+
showToast("Studio could not resolve this nested layer.", "error");
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
applyDomSelection(selection);
|
|
2733
|
+
requestAnimationFrame(refreshPreviewDocumentVersion);
|
|
2734
|
+
},
|
|
2735
|
+
[
|
|
2736
|
+
applyDomSelection,
|
|
2737
|
+
buildDomSelectionFromTarget,
|
|
2738
|
+
inspectedTimelineElement,
|
|
2739
|
+
refreshPreviewDocumentVersion,
|
|
2740
|
+
showToast,
|
|
2741
|
+
],
|
|
2742
|
+
);
|
|
2743
|
+
|
|
2744
|
+
const handleTimelineLayerPanelClose = useCallback(() => {
|
|
2745
|
+
setInspectedTimelineElementId(null);
|
|
2746
|
+
}, []);
|
|
2747
|
+
|
|
2748
|
+
const preloadAgentPromptSnippet = useCallback(
|
|
2749
|
+
async (selection: DomEditSelection) => {
|
|
2750
|
+
const pid = projectIdRef.current;
|
|
2751
|
+
if (!pid) return;
|
|
2752
|
+
|
|
2753
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
2754
|
+
try {
|
|
2755
|
+
const response = await fetch(
|
|
2756
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
2757
|
+
);
|
|
2758
|
+
if (!response.ok) return;
|
|
2759
|
+
|
|
2760
|
+
const data = (await response.json()) as { content?: string };
|
|
2761
|
+
const html = data.content;
|
|
2762
|
+
const tagSnippet =
|
|
2763
|
+
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
|
2764
|
+
|
|
2765
|
+
setAgentPromptTagSnippet((current) => {
|
|
2766
|
+
if (domEditSelectionRef.current !== selection) return current;
|
|
2767
|
+
return tagSnippet;
|
|
2768
|
+
});
|
|
2769
|
+
} catch {
|
|
2770
|
+
// Runtime outerHTML is still available as a synchronous copy fallback.
|
|
2771
|
+
}
|
|
2772
|
+
},
|
|
2773
|
+
[activeCompPath],
|
|
2774
|
+
);
|
|
2775
|
+
|
|
2776
|
+
const resolveImportedFontAsset = useCallback(
|
|
2777
|
+
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
2778
|
+
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
2779
|
+
if (!family) return null;
|
|
2780
|
+
const imported = importedFontAssetsRef.current.find(
|
|
2781
|
+
(font) => font.family.toLowerCase() === family.toLowerCase(),
|
|
2782
|
+
);
|
|
2783
|
+
if (imported) return imported;
|
|
2784
|
+
const asset = fileTree.find(
|
|
2785
|
+
(path) =>
|
|
2786
|
+
FONT_EXT.test(path) &&
|
|
2787
|
+
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
|
|
2788
|
+
);
|
|
2789
|
+
if (!asset) return null;
|
|
2790
|
+
return {
|
|
2791
|
+
family: fontFamilyFromAssetPath(asset),
|
|
2792
|
+
path: asset,
|
|
2793
|
+
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
2794
|
+
};
|
|
2795
|
+
},
|
|
2796
|
+
[fileTree, projectId],
|
|
2797
|
+
);
|
|
2798
|
+
|
|
2799
|
+
const persistDomEditOperations = useCallback(
|
|
2800
|
+
async (
|
|
2801
|
+
selection: DomEditSelection,
|
|
2802
|
+
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
2803
|
+
options?: {
|
|
2804
|
+
label?: string;
|
|
2805
|
+
coalesceKey?: string;
|
|
2806
|
+
skipRefresh?: boolean;
|
|
2807
|
+
prepareContent?: (html: string, sourceFile: string) => string;
|
|
2808
|
+
shouldSave?: () => boolean;
|
|
2809
|
+
},
|
|
2810
|
+
) => {
|
|
2811
|
+
const pid = projectIdRef.current;
|
|
2812
|
+
if (!pid) throw new Error("No active project");
|
|
2813
|
+
if (options?.shouldSave && !options.shouldSave()) return;
|
|
2814
|
+
|
|
2815
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
2816
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
2817
|
+
if (!response.ok) {
|
|
2818
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
const data = (await response.json()) as { content?: string };
|
|
2822
|
+
const originalContent = data.content;
|
|
2823
|
+
if (typeof originalContent !== "string") {
|
|
2824
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
let patchedContent = originalContent;
|
|
2828
|
+
for (const operation of operations) {
|
|
2829
|
+
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
|
2830
|
+
}
|
|
2831
|
+
if (options?.prepareContent) {
|
|
2832
|
+
patchedContent = options.prepareContent(patchedContent, targetPath);
|
|
2833
|
+
}
|
|
2834
|
+
if (options?.shouldSave && !options.shouldSave()) return;
|
|
2835
|
+
|
|
2836
|
+
if (patchedContent === originalContent) {
|
|
2837
|
+
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
await saveProjectFilesWithHistory({
|
|
2841
|
+
projectId: pid,
|
|
2842
|
+
label: options?.label ?? "Edit layer",
|
|
2843
|
+
kind: "manual",
|
|
2844
|
+
coalesceKey: options?.coalesceKey,
|
|
2845
|
+
files: { [targetPath]: patchedContent },
|
|
2846
|
+
readFile: async () => originalContent,
|
|
2847
|
+
writeFile: writeProjectFile,
|
|
2848
|
+
recordEdit: editHistory.recordEdit,
|
|
2849
|
+
});
|
|
2850
|
+
|
|
2851
|
+
if (options?.skipRefresh) {
|
|
2852
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2853
|
+
} else {
|
|
2854
|
+
setRefreshKey((k) => k + 1);
|
|
2855
|
+
}
|
|
2856
|
+
},
|
|
2857
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
2858
|
+
);
|
|
2859
|
+
|
|
2860
|
+
const refreshDomEditSelectionFromPreview = useCallback(
|
|
2861
|
+
(selection: DomEditSelection) => {
|
|
2862
|
+
const iframe = previewIframeRef.current;
|
|
2863
|
+
let doc: Document | null = null;
|
|
2864
|
+
try {
|
|
2865
|
+
doc = iframe?.contentDocument ?? null;
|
|
2866
|
+
} catch {
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
if (!doc) return;
|
|
2870
|
+
|
|
2871
|
+
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2872
|
+
if (!element) return;
|
|
2873
|
+
|
|
2874
|
+
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2875
|
+
if (nextSelection) {
|
|
2876
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2877
|
+
}
|
|
2878
|
+
},
|
|
2879
|
+
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
|
|
2880
|
+
);
|
|
2881
|
+
|
|
2882
|
+
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
|
2883
|
+
(selections: DomEditSelection[]) => {
|
|
2884
|
+
const iframe = previewIframeRef.current;
|
|
2885
|
+
let doc: Document | null = null;
|
|
2886
|
+
try {
|
|
2887
|
+
doc = iframe?.contentDocument ?? null;
|
|
2888
|
+
} catch {
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
if (!doc) return;
|
|
2892
|
+
|
|
2893
|
+
const nextGroup: DomEditSelection[] = [];
|
|
2894
|
+
for (const selection of selections) {
|
|
2895
|
+
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2896
|
+
if (!element) continue;
|
|
2897
|
+
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2898
|
+
if (nextSelection) nextGroup.push(nextSelection);
|
|
2899
|
+
}
|
|
2900
|
+
if (nextGroup.length === 0) return;
|
|
2901
|
+
|
|
2902
|
+
const currentSelection = domEditSelectionRef.current;
|
|
2903
|
+
const nextSelection =
|
|
2904
|
+
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
|
2905
|
+
nextGroup[0] ??
|
|
2906
|
+
null;
|
|
2907
|
+
|
|
2908
|
+
setAgentPromptTagSnippet(undefined);
|
|
2909
|
+
setCopiedAgentPrompt(false);
|
|
2910
|
+
domEditSelectionRef.current = nextSelection;
|
|
2911
|
+
domEditGroupSelectionsRef.current = nextGroup;
|
|
2912
|
+
setDomEditSelection(nextSelection);
|
|
2913
|
+
setDomEditGroupSelections(nextGroup);
|
|
2914
|
+
|
|
2915
|
+
if (nextSelection) {
|
|
2916
|
+
setSelectedTimelineElementId(
|
|
2917
|
+
findMatchingTimelineElementId(nextSelection, timelineElements),
|
|
2918
|
+
);
|
|
2919
|
+
} else {
|
|
2920
|
+
setSelectedTimelineElementId(null);
|
|
2921
|
+
}
|
|
2922
|
+
},
|
|
2923
|
+
[activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
|
|
2924
|
+
);
|
|
2925
|
+
|
|
2926
|
+
const handleDomManualDragStart = useCallback(() => {
|
|
2927
|
+
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
|
|
2928
|
+
const playerStore = usePlayerStore.getState();
|
|
2929
|
+
playerStore.setIsPlaying(false);
|
|
2930
|
+
if (pausedTime != null) {
|
|
2931
|
+
playerStore.setCurrentTime(pausedTime);
|
|
2932
|
+
liveTime.notify(pausedTime);
|
|
2933
|
+
}
|
|
2934
|
+
}, []);
|
|
2935
|
+
|
|
2936
|
+
const handleDomPathOffsetCommit = useCallback(
|
|
2937
|
+
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
2938
|
+
commitStudioManualEditManifestOptimistically(
|
|
2939
|
+
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
|
|
2940
|
+
{
|
|
2941
|
+
label: "Move layer",
|
|
2942
|
+
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
2943
|
+
},
|
|
2944
|
+
);
|
|
2945
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2946
|
+
},
|
|
2947
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2948
|
+
);
|
|
979
2949
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
2950
|
+
const handleDomGroupPathOffsetCommit = useCallback(
|
|
2951
|
+
(updates: DomEditGroupPathOffsetCommit[]) => {
|
|
2952
|
+
if (updates.length === 0) return;
|
|
2953
|
+
const coalesceKey = updates
|
|
2954
|
+
.map((update) => getDomEditTargetKey(update.selection))
|
|
2955
|
+
.sort()
|
|
2956
|
+
.join(":");
|
|
2957
|
+
commitStudioManualEditManifestOptimistically(
|
|
2958
|
+
(manifest) =>
|
|
2959
|
+
updates.reduce(
|
|
2960
|
+
(nextManifest, update) =>
|
|
2961
|
+
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
|
|
2962
|
+
manifest,
|
|
2963
|
+
),
|
|
2964
|
+
{
|
|
2965
|
+
label: `Move ${updates.length} layers`,
|
|
2966
|
+
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
2967
|
+
},
|
|
2968
|
+
);
|
|
2969
|
+
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
|
|
2970
|
+
},
|
|
2971
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
|
|
2972
|
+
);
|
|
2973
|
+
|
|
2974
|
+
const handleDomBoxSizeCommit = useCallback(
|
|
2975
|
+
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
2976
|
+
commitStudioManualEditManifestOptimistically(
|
|
2977
|
+
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
|
|
2978
|
+
{
|
|
2979
|
+
label: "Resize layer box",
|
|
2980
|
+
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
2981
|
+
},
|
|
2982
|
+
);
|
|
2983
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2984
|
+
},
|
|
2985
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2986
|
+
);
|
|
2987
|
+
|
|
2988
|
+
const handleDomRotationCommit = useCallback(
|
|
2989
|
+
(selection: DomEditSelection, next: { angle: number }) => {
|
|
2990
|
+
commitStudioManualEditManifestOptimistically(
|
|
2991
|
+
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
|
|
2992
|
+
{
|
|
2993
|
+
label: "Rotate layer",
|
|
2994
|
+
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
2995
|
+
},
|
|
2996
|
+
);
|
|
2997
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2998
|
+
},
|
|
2999
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
3000
|
+
);
|
|
3001
|
+
|
|
3002
|
+
const handleDomManualEditsReset = useCallback(
|
|
3003
|
+
(selection: DomEditSelection) => {
|
|
3004
|
+
commitStudioManualEditManifestOptimistically(
|
|
3005
|
+
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
|
|
3006
|
+
{
|
|
3007
|
+
label: "Reset layer edits",
|
|
3008
|
+
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
|
3009
|
+
},
|
|
3010
|
+
);
|
|
3011
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
3012
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
3013
|
+
},
|
|
3014
|
+
[
|
|
3015
|
+
applyCurrentStudioManualEditsToPreview,
|
|
3016
|
+
commitStudioManualEditManifestOptimistically,
|
|
3017
|
+
refreshDomEditSelectionFromPreview,
|
|
3018
|
+
],
|
|
3019
|
+
);
|
|
3020
|
+
|
|
3021
|
+
const handleDomMotionCommit = useCallback(
|
|
3022
|
+
(
|
|
3023
|
+
selection: DomEditSelection,
|
|
3024
|
+
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
|
3025
|
+
) => {
|
|
3026
|
+
commitStudioMotionManifestOptimistically(
|
|
3027
|
+
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
|
|
3028
|
+
{
|
|
3029
|
+
label: "Set GSAP motion",
|
|
3030
|
+
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
3031
|
+
},
|
|
3032
|
+
);
|
|
3033
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
3034
|
+
},
|
|
3035
|
+
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
3036
|
+
);
|
|
3037
|
+
|
|
3038
|
+
const handleDomMotionClear = useCallback(
|
|
3039
|
+
(selection: DomEditSelection) => {
|
|
3040
|
+
commitStudioMotionManifestOptimistically(
|
|
3041
|
+
(manifest) => removeStudioMotionForSelection(manifest, selection),
|
|
3042
|
+
{
|
|
3043
|
+
label: "Clear GSAP motion",
|
|
3044
|
+
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
3045
|
+
},
|
|
3046
|
+
);
|
|
3047
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
3048
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
3049
|
+
},
|
|
3050
|
+
[
|
|
3051
|
+
applyCurrentStudioMotionToPreview,
|
|
3052
|
+
commitStudioMotionManifestOptimistically,
|
|
3053
|
+
refreshDomEditSelectionFromPreview,
|
|
3054
|
+
],
|
|
3055
|
+
);
|
|
3056
|
+
|
|
3057
|
+
const handleDomStyleCommit = useCallback(
|
|
3058
|
+
async (property: string, value: string) => {
|
|
3059
|
+
if (!domEditSelection) return;
|
|
3060
|
+
if (isManualGeometryStyleProperty(property)) return;
|
|
3061
|
+
if (!domEditSelection.capabilities.canEditStyles) return;
|
|
3062
|
+
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
3063
|
+
const iframe = previewIframeRef.current;
|
|
3064
|
+
const doc = iframe?.contentDocument;
|
|
3065
|
+
if (doc) {
|
|
3066
|
+
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
3067
|
+
if (el) {
|
|
3068
|
+
el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
|
|
3069
|
+
if (property === "font-family") {
|
|
3070
|
+
injectPreviewGoogleFont(doc, value);
|
|
3071
|
+
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
3072
|
+
}
|
|
3073
|
+
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
3074
|
+
el.style.setProperty("background-position", "center");
|
|
3075
|
+
el.style.setProperty("background-repeat", "no-repeat");
|
|
3076
|
+
el.style.setProperty("background-size", "contain");
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
993
3079
|
}
|
|
3080
|
+
const operations: PatchOperation[] = [
|
|
3081
|
+
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
|
3082
|
+
];
|
|
3083
|
+
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
3084
|
+
operations.push(
|
|
3085
|
+
buildDomEditStylePatchOperation("background-position", "center"),
|
|
3086
|
+
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
|
3087
|
+
buildDomEditStylePatchOperation("background-size", "contain"),
|
|
3088
|
+
);
|
|
3089
|
+
}
|
|
3090
|
+
try {
|
|
3091
|
+
await persistDomEditOperations(domEditSelection, operations, {
|
|
3092
|
+
label: "Edit layer style",
|
|
3093
|
+
skipRefresh: true,
|
|
3094
|
+
prepareContent: importedFont
|
|
3095
|
+
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
3096
|
+
: undefined,
|
|
3097
|
+
});
|
|
3098
|
+
} catch (err) {
|
|
3099
|
+
console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
|
|
3100
|
+
}
|
|
3101
|
+
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
994
3102
|
},
|
|
995
|
-
[
|
|
3103
|
+
[
|
|
3104
|
+
activeCompPath,
|
|
3105
|
+
domEditSelection,
|
|
3106
|
+
persistDomEditOperations,
|
|
3107
|
+
refreshDomEditSelectionFromPreview,
|
|
3108
|
+
resolveImportedFontAsset,
|
|
3109
|
+
],
|
|
996
3110
|
);
|
|
997
3111
|
|
|
998
|
-
const
|
|
999
|
-
(
|
|
1000
|
-
|
|
1001
|
-
if (
|
|
1002
|
-
|
|
1003
|
-
|
|
3112
|
+
const handleDomTextCommit = useCallback(
|
|
3113
|
+
async (value: string, fieldKey?: string) => {
|
|
3114
|
+
if (!domEditSelection) return;
|
|
3115
|
+
if (!isTextEditableSelection(domEditSelection)) return;
|
|
3116
|
+
const commitVersion = domTextCommitVersionRef.current + 1;
|
|
3117
|
+
domTextCommitVersionRef.current = commitVersion;
|
|
3118
|
+
const nextTextFields =
|
|
3119
|
+
domEditSelection.textFields.length > 0
|
|
3120
|
+
? domEditSelection.textFields.map((field) =>
|
|
3121
|
+
field.key === fieldKey ? { ...field, value } : field,
|
|
3122
|
+
)
|
|
3123
|
+
: [];
|
|
3124
|
+
const nextContent =
|
|
3125
|
+
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
3126
|
+
? serializeDomEditTextFields(nextTextFields)
|
|
3127
|
+
: value;
|
|
3128
|
+
const iframe = previewIframeRef.current;
|
|
3129
|
+
const doc = iframe?.contentDocument;
|
|
3130
|
+
if (doc) {
|
|
3131
|
+
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
3132
|
+
if (el) {
|
|
3133
|
+
if (
|
|
3134
|
+
nextTextFields.length > 1 ||
|
|
3135
|
+
nextTextFields.some((field) => field.source === "child")
|
|
3136
|
+
) {
|
|
3137
|
+
el.innerHTML = nextContent;
|
|
3138
|
+
} else {
|
|
3139
|
+
el.textContent = value;
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
await persistDomEditOperations(
|
|
3144
|
+
domEditSelection,
|
|
3145
|
+
[buildDomEditTextPatchOperation(nextContent)],
|
|
3146
|
+
{
|
|
3147
|
+
label: "Edit text",
|
|
3148
|
+
skipRefresh: true,
|
|
3149
|
+
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
3150
|
+
},
|
|
3151
|
+
);
|
|
3152
|
+
if (domTextCommitVersionRef.current !== commitVersion) return;
|
|
3153
|
+
|
|
3154
|
+
if (doc) {
|
|
3155
|
+
const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
3156
|
+
if (refreshed) {
|
|
3157
|
+
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
3158
|
+
if (nextSelection) {
|
|
3159
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
1004
3163
|
},
|
|
1005
|
-
[
|
|
3164
|
+
[
|
|
3165
|
+
activeCompPath,
|
|
3166
|
+
applyDomSelection,
|
|
3167
|
+
buildDomSelectionFromTarget,
|
|
3168
|
+
domEditSelection,
|
|
3169
|
+
persistDomEditOperations,
|
|
3170
|
+
],
|
|
3171
|
+
);
|
|
3172
|
+
|
|
3173
|
+
const commitDomTextFields = useCallback(
|
|
3174
|
+
async (
|
|
3175
|
+
selection: DomEditSelection,
|
|
3176
|
+
nextTextFields: DomEditTextField[],
|
|
3177
|
+
options?: { importedFont?: ImportedFontAsset | null },
|
|
3178
|
+
) => {
|
|
3179
|
+
const nextContent =
|
|
3180
|
+
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
3181
|
+
? serializeDomEditTextFields(nextTextFields)
|
|
3182
|
+
: (nextTextFields[0]?.value ?? "");
|
|
3183
|
+
|
|
3184
|
+
const iframe = previewIframeRef.current;
|
|
3185
|
+
const doc = iframe?.contentDocument;
|
|
3186
|
+
if (doc) {
|
|
3187
|
+
const el = findElementForSelection(doc, selection, activeCompPath);
|
|
3188
|
+
if (el) {
|
|
3189
|
+
if (
|
|
3190
|
+
nextTextFields.length > 1 ||
|
|
3191
|
+
nextTextFields.some((field) => field.source === "child")
|
|
3192
|
+
) {
|
|
3193
|
+
el.innerHTML = nextContent;
|
|
3194
|
+
} else {
|
|
3195
|
+
el.textContent = nextContent;
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
const importedFont = options?.importedFont ?? null;
|
|
3201
|
+
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
|
3202
|
+
label: "Edit text",
|
|
3203
|
+
skipRefresh: true,
|
|
3204
|
+
prepareContent: importedFont
|
|
3205
|
+
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
3206
|
+
: undefined,
|
|
3207
|
+
});
|
|
3208
|
+
|
|
3209
|
+
if (doc) {
|
|
3210
|
+
const refreshed = findElementForSelection(doc, selection, activeCompPath);
|
|
3211
|
+
if (refreshed) {
|
|
3212
|
+
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
3213
|
+
if (nextSelection) {
|
|
3214
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
},
|
|
3219
|
+
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
|
|
3220
|
+
);
|
|
3221
|
+
|
|
3222
|
+
const handleDomTextFieldStyleCommit = useCallback(
|
|
3223
|
+
async (fieldKey: string, property: string, value: string) => {
|
|
3224
|
+
if (!domEditSelection) return;
|
|
3225
|
+
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
3226
|
+
if (!field) return;
|
|
3227
|
+
|
|
3228
|
+
if (field.source === "self") {
|
|
3229
|
+
await handleDomStyleCommit(property, value);
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
const normalizedValue = normalizeDomEditStyleValue(property, value);
|
|
3234
|
+
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
3235
|
+
if (property === "font-family") {
|
|
3236
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
3237
|
+
if (doc) {
|
|
3238
|
+
injectPreviewGoogleFont(doc, normalizedValue);
|
|
3239
|
+
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
const nextTextFields = domEditSelection.textFields.map((entry) =>
|
|
3243
|
+
entry.key === fieldKey
|
|
3244
|
+
? {
|
|
3245
|
+
...entry,
|
|
3246
|
+
inlineStyles: {
|
|
3247
|
+
...entry.inlineStyles,
|
|
3248
|
+
[property]: normalizedValue,
|
|
3249
|
+
},
|
|
3250
|
+
computedStyles: {
|
|
3251
|
+
...entry.computedStyles,
|
|
3252
|
+
[property]: normalizedValue,
|
|
3253
|
+
},
|
|
3254
|
+
}
|
|
3255
|
+
: entry,
|
|
3256
|
+
);
|
|
3257
|
+
|
|
3258
|
+
await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
|
|
3259
|
+
},
|
|
3260
|
+
[commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
|
|
3261
|
+
);
|
|
3262
|
+
|
|
3263
|
+
const handleDomAddTextField = useCallback(
|
|
3264
|
+
async (afterFieldKey?: string) => {
|
|
3265
|
+
if (!domEditSelection) return null;
|
|
3266
|
+
if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
|
|
3267
|
+
|
|
3268
|
+
const insertionIndex = domEditSelection.textFields.findIndex(
|
|
3269
|
+
(field) => field.key === afterFieldKey,
|
|
3270
|
+
);
|
|
3271
|
+
const baseField =
|
|
3272
|
+
domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
|
|
3273
|
+
domEditSelection.textFields[0];
|
|
3274
|
+
const nextField = buildDefaultDomEditTextField(baseField);
|
|
3275
|
+
const nextTextFields = [...domEditSelection.textFields];
|
|
3276
|
+
nextTextFields.splice(
|
|
3277
|
+
insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
|
|
3278
|
+
0,
|
|
3279
|
+
nextField,
|
|
3280
|
+
);
|
|
3281
|
+
|
|
3282
|
+
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
3283
|
+
return nextField.key;
|
|
3284
|
+
},
|
|
3285
|
+
[commitDomTextFields, domEditSelection],
|
|
3286
|
+
);
|
|
3287
|
+
|
|
3288
|
+
const handleDomRemoveTextField = useCallback(
|
|
3289
|
+
async (fieldKey: string) => {
|
|
3290
|
+
if (!domEditSelection) return;
|
|
3291
|
+
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
3292
|
+
if (!field) return;
|
|
3293
|
+
|
|
3294
|
+
if (field.source === "self") {
|
|
3295
|
+
await handleDomTextCommit("", fieldKey);
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
|
|
3300
|
+
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
3301
|
+
},
|
|
3302
|
+
[commitDomTextFields, domEditSelection, handleDomTextCommit],
|
|
3303
|
+
);
|
|
3304
|
+
|
|
3305
|
+
const handleAskAgent = useCallback(() => {
|
|
3306
|
+
if (!domEditSelection) return;
|
|
3307
|
+
setAgentPromptTagSnippet(undefined);
|
|
3308
|
+
setAgentPromptSelectionContext(undefined);
|
|
3309
|
+
setAgentModalAnchorPoint(null);
|
|
3310
|
+
void preloadAgentPromptSnippet(domEditSelection);
|
|
3311
|
+
setAgentModalOpen(true);
|
|
3312
|
+
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
3313
|
+
|
|
3314
|
+
const handleAgentModalSubmit = useCallback(
|
|
3315
|
+
async (userInstruction: string) => {
|
|
3316
|
+
if (!domEditSelection) return;
|
|
3317
|
+
|
|
3318
|
+
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
3319
|
+
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
|
3320
|
+
const prompt = buildElementAgentPrompt({
|
|
3321
|
+
selection: domEditSelection,
|
|
3322
|
+
currentTime,
|
|
3323
|
+
tagSnippet,
|
|
3324
|
+
selectionContext: agentPromptSelectionContext,
|
|
3325
|
+
userInstruction,
|
|
3326
|
+
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
3327
|
+
});
|
|
3328
|
+
|
|
3329
|
+
const copied = await copyTextToClipboard(prompt);
|
|
3330
|
+
if (!copied) {
|
|
3331
|
+
showToast("Could not copy prompt to clipboard.", "error");
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
setAgentModalOpen(false);
|
|
3336
|
+
setAgentPromptSelectionContext(undefined);
|
|
3337
|
+
setAgentModalAnchorPoint(null);
|
|
3338
|
+
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
3339
|
+
setCopiedAgentPrompt(true);
|
|
3340
|
+
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
3341
|
+
},
|
|
3342
|
+
[
|
|
3343
|
+
activeCompPath,
|
|
3344
|
+
agentPromptSelectionContext,
|
|
3345
|
+
agentPromptTagSnippet,
|
|
3346
|
+
currentTime,
|
|
3347
|
+
domEditSelection,
|
|
3348
|
+
projectDir,
|
|
3349
|
+
showToast,
|
|
3350
|
+
],
|
|
3351
|
+
);
|
|
3352
|
+
|
|
3353
|
+
const handlePreviewIframeRef = useCallback(
|
|
3354
|
+
(iframe: HTMLIFrameElement | null) => {
|
|
3355
|
+
previewIframeRef.current = iframe;
|
|
3356
|
+
setPreviewIframe(iframe);
|
|
3357
|
+
syncPreviewTimelineHotkey(iframe);
|
|
3358
|
+
syncPreviewHistoryHotkey(iframe);
|
|
3359
|
+
consoleErrorsRef.current = [];
|
|
3360
|
+
setConsoleErrors(null);
|
|
3361
|
+
refreshPreviewDocumentVersion();
|
|
3362
|
+
},
|
|
3363
|
+
[refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
|
|
3364
|
+
);
|
|
3365
|
+
|
|
3366
|
+
const handlePreviewCanvasMouseDown = useCallback(
|
|
3367
|
+
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
3368
|
+
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
|
|
3369
|
+
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
3370
|
+
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
3371
|
+
});
|
|
3372
|
+
if (!nextSelection) {
|
|
3373
|
+
if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
|
|
3374
|
+
return;
|
|
3375
|
+
}
|
|
3376
|
+
e.preventDefault();
|
|
3377
|
+
e.stopPropagation();
|
|
3378
|
+
const localPointer = previewIframeRef.current
|
|
3379
|
+
? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
|
|
3380
|
+
: null;
|
|
3381
|
+
applyDomSelection(nextSelection, { additive: e.shiftKey });
|
|
3382
|
+
if (
|
|
3383
|
+
!e.shiftKey &&
|
|
3384
|
+
localPointer &&
|
|
3385
|
+
isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
|
|
3386
|
+
) {
|
|
3387
|
+
setAgentPromptSelectionContext(
|
|
3388
|
+
buildRasterClickSelectionContext(nextSelection, localPointer),
|
|
3389
|
+
);
|
|
3390
|
+
setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
|
|
3391
|
+
void preloadAgentPromptSnippet(nextSelection);
|
|
3392
|
+
setAgentModalOpen(true);
|
|
3393
|
+
}
|
|
3394
|
+
},
|
|
3395
|
+
[
|
|
3396
|
+
applyDomSelection,
|
|
3397
|
+
captionEditMode,
|
|
3398
|
+
compositionLoading,
|
|
3399
|
+
preloadAgentPromptSnippet,
|
|
3400
|
+
resolveDomSelectionFromPreviewPoint,
|
|
3401
|
+
],
|
|
3402
|
+
);
|
|
3403
|
+
|
|
3404
|
+
const handlePreviewCanvasPointerMove = useCallback(
|
|
3405
|
+
(e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
3406
|
+
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
|
|
3407
|
+
updateDomEditHoverSelection(null);
|
|
3408
|
+
return null;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
3412
|
+
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
3413
|
+
});
|
|
3414
|
+
updateDomEditHoverSelection(nextSelection);
|
|
3415
|
+
return nextSelection;
|
|
3416
|
+
},
|
|
3417
|
+
[
|
|
3418
|
+
captionEditMode,
|
|
3419
|
+
compositionLoading,
|
|
3420
|
+
resolveDomSelectionFromPreviewPoint,
|
|
3421
|
+
updateDomEditHoverSelection,
|
|
3422
|
+
],
|
|
3423
|
+
);
|
|
3424
|
+
|
|
3425
|
+
const handlePreviewCanvasPointerLeave = useCallback(() => {
|
|
3426
|
+
updateDomEditHoverSelection(null);
|
|
3427
|
+
}, [updateDomEditHoverSelection]);
|
|
3428
|
+
|
|
3429
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3430
|
+
useEffect(() => {
|
|
3431
|
+
if (captionEditMode) updateDomEditHoverSelection(null);
|
|
3432
|
+
}, [captionEditMode, updateDomEditHoverSelection]);
|
|
3433
|
+
|
|
3434
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3435
|
+
useEffect(() => {
|
|
3436
|
+
updateDomEditHoverSelection(null);
|
|
3437
|
+
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
|
|
3438
|
+
|
|
3439
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3440
|
+
useEffect(() => {
|
|
3441
|
+
if (!domEditHoverSelection) return;
|
|
3442
|
+
const hoverMatchesSelection = domEditSelectionsTargetSame(
|
|
3443
|
+
domEditHoverSelection,
|
|
3444
|
+
domEditSelection,
|
|
3445
|
+
);
|
|
3446
|
+
const hoverMatchesGroup = domEditSelectionInGroup(
|
|
3447
|
+
domEditGroupSelections,
|
|
3448
|
+
domEditHoverSelection,
|
|
3449
|
+
);
|
|
3450
|
+
if (!hoverMatchesSelection && !hoverMatchesGroup) return;
|
|
3451
|
+
updateDomEditHoverSelection(null);
|
|
3452
|
+
}, [
|
|
3453
|
+
domEditGroupSelections,
|
|
3454
|
+
domEditHoverSelection,
|
|
3455
|
+
domEditSelection,
|
|
3456
|
+
updateDomEditHoverSelection,
|
|
3457
|
+
]);
|
|
3458
|
+
|
|
3459
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3460
|
+
useEffect(() => {
|
|
3461
|
+
if (!domEditHoverSelection) return;
|
|
3462
|
+
if (domEditHoverSelection.element.isConnected) return;
|
|
3463
|
+
updateDomEditHoverSelection(null);
|
|
3464
|
+
}, [domEditHoverSelection, updateDomEditHoverSelection]);
|
|
3465
|
+
|
|
3466
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3467
|
+
useEffect(() => {
|
|
3468
|
+
if (!previewIframe) return;
|
|
3469
|
+
|
|
3470
|
+
const syncSelectionFromDocument = () => {
|
|
3471
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
|
|
3472
|
+
const currentSelection = domEditSelectionRef.current;
|
|
3473
|
+
if (!currentSelection) return;
|
|
3474
|
+
let doc: Document | null = null;
|
|
3475
|
+
try {
|
|
3476
|
+
doc = previewIframe.contentDocument;
|
|
3477
|
+
} catch {
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
if (!doc) return;
|
|
3481
|
+
|
|
3482
|
+
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
|
3483
|
+
if (!nextElement) {
|
|
3484
|
+
applyDomSelection(null, { revealPanel: false });
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
const nextSelection = buildDomSelectionFromTarget(nextElement);
|
|
3489
|
+
if (nextSelection) {
|
|
3490
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
3491
|
+
}
|
|
3492
|
+
};
|
|
3493
|
+
|
|
3494
|
+
const attachErrorCapture = () => {
|
|
3495
|
+
try {
|
|
3496
|
+
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
|
|
3497
|
+
if (!win) return;
|
|
3498
|
+
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
3499
|
+
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
3500
|
+
const origError = win.console.error.bind(win.console);
|
|
3501
|
+
win.console.error = function (...args: unknown[]) {
|
|
3502
|
+
origError(...args);
|
|
3503
|
+
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
|
3504
|
+
if (text.includes("favicon")) return;
|
|
3505
|
+
consoleErrorsRef.current = [
|
|
3506
|
+
...consoleErrorsRef.current,
|
|
3507
|
+
{ severity: "error", message: text },
|
|
3508
|
+
];
|
|
3509
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
3510
|
+
};
|
|
3511
|
+
win.addEventListener("error", (e: ErrorEvent) => {
|
|
3512
|
+
const text = e.message || String(e);
|
|
3513
|
+
consoleErrorsRef.current = [
|
|
3514
|
+
...consoleErrorsRef.current,
|
|
3515
|
+
{ severity: "error", message: text },
|
|
3516
|
+
];
|
|
3517
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
3518
|
+
});
|
|
3519
|
+
} catch {
|
|
3520
|
+
// same-origin only
|
|
3521
|
+
}
|
|
3522
|
+
};
|
|
3523
|
+
|
|
3524
|
+
attachErrorCapture();
|
|
3525
|
+
syncPreviewHistoryHotkey(previewIframe);
|
|
3526
|
+
void (async () => {
|
|
3527
|
+
await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
3528
|
+
await applyStudioMotionToPreviewAfterRefresh(previewIframe);
|
|
3529
|
+
})();
|
|
3530
|
+
syncSelectionFromDocument();
|
|
3531
|
+
refreshPreviewDocumentVersion();
|
|
3532
|
+
|
|
3533
|
+
const handleLoad = () => {
|
|
3534
|
+
consoleErrorsRef.current = [];
|
|
3535
|
+
setConsoleErrors(null);
|
|
3536
|
+
attachErrorCapture();
|
|
3537
|
+
syncPreviewHistoryHotkey(previewIframe);
|
|
3538
|
+
void (async () => {
|
|
3539
|
+
await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
3540
|
+
await applyStudioMotionToPreviewAfterRefresh(previewIframe);
|
|
3541
|
+
})();
|
|
3542
|
+
syncSelectionFromDocument();
|
|
3543
|
+
refreshPreviewDocumentVersion();
|
|
3544
|
+
};
|
|
3545
|
+
|
|
3546
|
+
previewIframe.addEventListener("load", handleLoad);
|
|
3547
|
+
return () => {
|
|
3548
|
+
previewIframe.removeEventListener("load", handleLoad);
|
|
3549
|
+
};
|
|
3550
|
+
}, [
|
|
3551
|
+
activeCompPath,
|
|
3552
|
+
applyDomSelection,
|
|
3553
|
+
applyStudioManualEditsToPreviewAfterRefresh,
|
|
3554
|
+
applyStudioMotionToPreviewAfterRefresh,
|
|
3555
|
+
buildDomSelectionFromTarget,
|
|
3556
|
+
captionEditMode,
|
|
3557
|
+
previewIframe,
|
|
3558
|
+
refreshPreviewDocumentVersion,
|
|
3559
|
+
syncPreviewHistoryHotkey,
|
|
3560
|
+
]);
|
|
3561
|
+
|
|
3562
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3563
|
+
useEffect(() => {
|
|
3564
|
+
if (!captionEditMode) return;
|
|
3565
|
+
applyDomSelection(null, { revealPanel: false });
|
|
3566
|
+
}, [applyDomSelection, captionEditMode]);
|
|
3567
|
+
|
|
3568
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3569
|
+
useEffect(() => {
|
|
3570
|
+
if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
3571
|
+
updateDomEditHoverSelection(null);
|
|
3572
|
+
applyDomSelection(null, { revealPanel: false });
|
|
3573
|
+
if (rightPanelTab !== "renders") setRightPanelTab("renders");
|
|
3574
|
+
}, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection]);
|
|
3575
|
+
|
|
3576
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3577
|
+
useEffect(
|
|
3578
|
+
() => () => {
|
|
3579
|
+
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
3580
|
+
},
|
|
3581
|
+
[],
|
|
1006
3582
|
);
|
|
1007
3583
|
|
|
1008
3584
|
const refreshFileTree = useCallback(async () => {
|
|
@@ -1138,24 +3714,20 @@ export function StudioApp() {
|
|
|
1138
3714
|
duration: normalizedDuration,
|
|
1139
3715
|
track: placement.track,
|
|
1140
3716
|
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
3717
|
+
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
|
1141
3718
|
}),
|
|
1142
3719
|
);
|
|
1143
3720
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
if (editingPathRef.current === targetPath) {
|
|
1157
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1158
|
-
}
|
|
3721
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
3722
|
+
await saveProjectFilesWithHistory({
|
|
3723
|
+
projectId: pid,
|
|
3724
|
+
label: "Add timeline asset",
|
|
3725
|
+
kind: "timeline",
|
|
3726
|
+
files: { [targetPath]: patchedContent },
|
|
3727
|
+
readFile: async () => originalContent,
|
|
3728
|
+
writeFile: writeProjectFile,
|
|
3729
|
+
recordEdit: editHistory.recordEdit,
|
|
3730
|
+
});
|
|
1159
3731
|
|
|
1160
3732
|
setRefreshKey((k) => k + 1);
|
|
1161
3733
|
} catch (error) {
|
|
@@ -1164,7 +3736,7 @@ export function StudioApp() {
|
|
|
1164
3736
|
showToast(message);
|
|
1165
3737
|
}
|
|
1166
3738
|
},
|
|
1167
|
-
[activeCompPath, showToast, timelineElements],
|
|
3739
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
1168
3740
|
);
|
|
1169
3741
|
|
|
1170
3742
|
const handleTimelineFileDrop = useCallback(
|
|
@@ -1322,7 +3894,33 @@ export function StudioApp() {
|
|
|
1322
3894
|
|
|
1323
3895
|
const handleImportFiles = useCallback(
|
|
1324
3896
|
async (files: FileList | File[], dir?: string) => {
|
|
1325
|
-
|
|
3897
|
+
return uploadProjectFiles(Array.from(files), dir);
|
|
3898
|
+
},
|
|
3899
|
+
[uploadProjectFiles],
|
|
3900
|
+
);
|
|
3901
|
+
|
|
3902
|
+
const handleImportFonts = useCallback(
|
|
3903
|
+
async (files: FileList | File[]) => {
|
|
3904
|
+
const uploaded = await uploadProjectFiles(
|
|
3905
|
+
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
|
3906
|
+
"assets/fonts",
|
|
3907
|
+
);
|
|
3908
|
+
const pid = projectIdRef.current;
|
|
3909
|
+
const imported = uploaded
|
|
3910
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
3911
|
+
.map((asset) => ({
|
|
3912
|
+
family: fontFamilyFromAssetPath(asset),
|
|
3913
|
+
path: asset,
|
|
3914
|
+
url: `/api/projects/${pid}/preview/${asset}`,
|
|
3915
|
+
}));
|
|
3916
|
+
importedFontAssetsRef.current = [
|
|
3917
|
+
...imported,
|
|
3918
|
+
...importedFontAssetsRef.current.filter(
|
|
3919
|
+
(existing) =>
|
|
3920
|
+
!imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
|
|
3921
|
+
),
|
|
3922
|
+
];
|
|
3923
|
+
return imported;
|
|
1326
3924
|
},
|
|
1327
3925
|
[uploadProjectFiles],
|
|
1328
3926
|
);
|
|
@@ -1394,6 +3992,53 @@ export function StudioApp() {
|
|
|
1394
3992
|
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
1395
3993
|
[fileTree],
|
|
1396
3994
|
);
|
|
3995
|
+
const fontAssets = useMemo<ImportedFontAsset[]>(
|
|
3996
|
+
() =>
|
|
3997
|
+
assets
|
|
3998
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
3999
|
+
.map((asset) => ({
|
|
4000
|
+
family: fontFamilyFromAssetPath(asset),
|
|
4001
|
+
path: asset,
|
|
4002
|
+
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
4003
|
+
})),
|
|
4004
|
+
[assets, projectId],
|
|
4005
|
+
);
|
|
4006
|
+
const selectedStudioMotion =
|
|
4007
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
|
|
4008
|
+
? getStudioMotionForSelection(studioMotionManifestRef.current, domEditSelection)
|
|
4009
|
+
: null;
|
|
4010
|
+
const selectedTimelineElement = useMemo(
|
|
4011
|
+
() =>
|
|
4012
|
+
selectedTimelineElementId
|
|
4013
|
+
? (timelineElements.find(
|
|
4014
|
+
(element) => getTimelineElementKey(element) === selectedTimelineElementId,
|
|
4015
|
+
) ?? null)
|
|
4016
|
+
: null,
|
|
4017
|
+
[selectedTimelineElementId, timelineElements],
|
|
4018
|
+
);
|
|
4019
|
+
const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
|
|
4020
|
+
const motionPanelActive =
|
|
4021
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion";
|
|
4022
|
+
const inspectorPanelActive = designPanelActive || motionPanelActive;
|
|
4023
|
+
const shouldShowSelectedDomBounds =
|
|
4024
|
+
inspectorPanelActive &&
|
|
4025
|
+
!rightCollapsed &&
|
|
4026
|
+
(!selectedTimelineElement ||
|
|
4027
|
+
isTimelineElementActiveAtTime(currentTime, selectedTimelineElement));
|
|
4028
|
+
const inspectorButtonActive =
|
|
4029
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive;
|
|
4030
|
+
const timelineLayerPanel =
|
|
4031
|
+
STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED &&
|
|
4032
|
+
inspectedTimelineElement &&
|
|
4033
|
+
inspectedTimelineLayers.length > 0 ? (
|
|
4034
|
+
<TimelineLayerPanel
|
|
4035
|
+
clipLabel={getTimelineElementLabel(inspectedTimelineElement)}
|
|
4036
|
+
layers={inspectedTimelineLayers}
|
|
4037
|
+
selectedLayerKey={selectedTimelineLayerKey}
|
|
4038
|
+
onSelectLayer={handleTimelineLayerSelect}
|
|
4039
|
+
onClose={handleTimelineLayerPanelClose}
|
|
4040
|
+
/>
|
|
4041
|
+
) : null;
|
|
1397
4042
|
|
|
1398
4043
|
if (resolving || !projectId) {
|
|
1399
4044
|
return (
|
|
@@ -1439,6 +4084,42 @@ export function StudioApp() {
|
|
|
1439
4084
|
</div>
|
|
1440
4085
|
{/* Right: toolbar buttons */}
|
|
1441
4086
|
<div className="flex items-center gap-1.5">
|
|
4087
|
+
<button
|
|
4088
|
+
type="button"
|
|
4089
|
+
onClick={() => void handleUndo()}
|
|
4090
|
+
disabled={!editHistory.canUndo}
|
|
4091
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
4092
|
+
editHistory.canUndo
|
|
4093
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
4094
|
+
: "border-neutral-900 text-neutral-700"
|
|
4095
|
+
}`}
|
|
4096
|
+
title={
|
|
4097
|
+
editHistory.undoLabel
|
|
4098
|
+
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
|
|
4099
|
+
: `Undo (${getHistoryShortcutLabel("undo")})`
|
|
4100
|
+
}
|
|
4101
|
+
aria-label="Undo"
|
|
4102
|
+
>
|
|
4103
|
+
<RotateCcw size={14} />
|
|
4104
|
+
</button>
|
|
4105
|
+
<button
|
|
4106
|
+
type="button"
|
|
4107
|
+
onClick={() => void handleRedo()}
|
|
4108
|
+
disabled={!editHistory.canRedo}
|
|
4109
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
4110
|
+
editHistory.canRedo
|
|
4111
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
4112
|
+
: "border-neutral-900 text-neutral-700"
|
|
4113
|
+
}`}
|
|
4114
|
+
title={
|
|
4115
|
+
editHistory.redoLabel
|
|
4116
|
+
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
|
|
4117
|
+
: `Redo (${getHistoryShortcutLabel("redo")})`
|
|
4118
|
+
}
|
|
4119
|
+
aria-label="Redo"
|
|
4120
|
+
>
|
|
4121
|
+
<RotateCw size={14} />
|
|
4122
|
+
</button>
|
|
1442
4123
|
<a
|
|
1443
4124
|
href={captureFrameHref}
|
|
1444
4125
|
download={captureFrameFilename}
|
|
@@ -1453,12 +4134,31 @@ export function StudioApp() {
|
|
|
1453
4134
|
<span>Capture</span>
|
|
1454
4135
|
</a>
|
|
1455
4136
|
<button
|
|
1456
|
-
|
|
4137
|
+
type="button"
|
|
4138
|
+
onClick={() => {
|
|
4139
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
4140
|
+
if (rightCollapsed || !inspectorPanelActive) {
|
|
4141
|
+
setRightPanelTab("design");
|
|
4142
|
+
setRightCollapsed(false);
|
|
4143
|
+
return;
|
|
4144
|
+
}
|
|
4145
|
+
clearDomSelection();
|
|
4146
|
+
setRightCollapsed(true);
|
|
4147
|
+
}}
|
|
4148
|
+
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
|
|
1457
4149
|
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
1458
|
-
|
|
4150
|
+
inspectorButtonActive
|
|
1459
4151
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
1460
|
-
:
|
|
4152
|
+
: STUDIO_INSPECTOR_PANELS_ENABLED
|
|
4153
|
+
? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
|
|
4154
|
+
: "cursor-not-allowed border-transparent text-neutral-700"
|
|
1461
4155
|
}`}
|
|
4156
|
+
title={
|
|
4157
|
+
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
|
4158
|
+
}
|
|
4159
|
+
aria-label={
|
|
4160
|
+
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
|
4161
|
+
}
|
|
1462
4162
|
>
|
|
1463
4163
|
<svg
|
|
1464
4164
|
width="12"
|
|
@@ -1471,8 +4171,7 @@ export function StudioApp() {
|
|
|
1471
4171
|
<circle cx="12" cy="12" r="10" />
|
|
1472
4172
|
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
1473
4173
|
</svg>
|
|
1474
|
-
|
|
1475
|
-
{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
4174
|
+
Inspector
|
|
1476
4175
|
</button>
|
|
1477
4176
|
</div>
|
|
1478
4177
|
</div>
|
|
@@ -1507,6 +4206,7 @@ export function StudioApp() {
|
|
|
1507
4206
|
</div>
|
|
1508
4207
|
) : (
|
|
1509
4208
|
<LeftSidebar
|
|
4209
|
+
ref={leftSidebarRef}
|
|
1510
4210
|
width={leftWidth}
|
|
1511
4211
|
projectId={projectId}
|
|
1512
4212
|
compositions={compositions}
|
|
@@ -1553,6 +4253,7 @@ export function StudioApp() {
|
|
|
1553
4253
|
onLint={handleLint}
|
|
1554
4254
|
linting={linting}
|
|
1555
4255
|
onToggleCollapse={toggleLeftSidebar}
|
|
4256
|
+
takeoverContent={timelineLayerPanel}
|
|
1556
4257
|
/>
|
|
1557
4258
|
)}
|
|
1558
4259
|
|
|
@@ -1583,62 +4284,47 @@ export function StudioApp() {
|
|
|
1583
4284
|
onMoveElement={handleTimelineElementMove}
|
|
1584
4285
|
onResizeElement={handleTimelineElementResize}
|
|
1585
4286
|
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
|
4287
|
+
onSelectTimelineElement={handleTimelineElementSelect}
|
|
4288
|
+
onInspectTimelineElement={handleTimelineElementInspect}
|
|
4289
|
+
inspectedTimelineElementId={inspectedTimelineElementId}
|
|
4290
|
+
timelineLayerChildCounts={timelineLayerChildCounts}
|
|
1586
4291
|
onCompIdToSrcChange={setCompIdToSrc}
|
|
4292
|
+
onCompositionLoadingChange={setCompositionLoading}
|
|
1587
4293
|
onCompositionChange={(compPath) => {
|
|
1588
4294
|
// Sync activeCompPath when user drills down via timeline double-click
|
|
1589
4295
|
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
|
1590
4296
|
setActiveCompPath(compPath);
|
|
4297
|
+
setInspectedTimelineElementId(null);
|
|
4298
|
+
refreshPreviewDocumentVersion();
|
|
1591
4299
|
}}
|
|
1592
|
-
onIframeRef={
|
|
1593
|
-
previewIframeRef.current = iframe;
|
|
1594
|
-
syncPreviewTimelineHotkey(iframe);
|
|
1595
|
-
consoleErrorsRef.current = [];
|
|
1596
|
-
setConsoleErrors(null);
|
|
1597
|
-
if (!iframe) return;
|
|
1598
|
-
|
|
1599
|
-
// Attach error capture after each iframe load (content resets on navigation)
|
|
1600
|
-
const attachErrorCapture = () => {
|
|
1601
|
-
try {
|
|
1602
|
-
const win = iframe.contentWindow as (Window & typeof globalThis) | null;
|
|
1603
|
-
if (!win) return;
|
|
1604
|
-
// Guard against double-patching
|
|
1605
|
-
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
1606
|
-
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
1607
|
-
const origError = win.console.error.bind(win.console);
|
|
1608
|
-
win.console.error = function (...args: unknown[]) {
|
|
1609
|
-
origError(...args);
|
|
1610
|
-
const text = args
|
|
1611
|
-
.map((a) => (a instanceof Error ? a.message : String(a)))
|
|
1612
|
-
.join(" ");
|
|
1613
|
-
if (text.includes("favicon")) return;
|
|
1614
|
-
consoleErrorsRef.current = [
|
|
1615
|
-
...consoleErrorsRef.current,
|
|
1616
|
-
{ severity: "error", message: text },
|
|
1617
|
-
];
|
|
1618
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
1619
|
-
};
|
|
1620
|
-
win.addEventListener("error", (e: ErrorEvent) => {
|
|
1621
|
-
const text = e.message || String(e);
|
|
1622
|
-
consoleErrorsRef.current = [
|
|
1623
|
-
...consoleErrorsRef.current,
|
|
1624
|
-
{ severity: "error", message: text },
|
|
1625
|
-
];
|
|
1626
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
1627
|
-
});
|
|
1628
|
-
} catch {
|
|
1629
|
-
// cross-origin — can't attach
|
|
1630
|
-
}
|
|
1631
|
-
};
|
|
1632
|
-
// Attach now (iframe may already be loaded) and on future loads
|
|
1633
|
-
attachErrorCapture();
|
|
1634
|
-
iframe.addEventListener("load", () => {
|
|
1635
|
-
consoleErrorsRef.current = [];
|
|
1636
|
-
setConsoleErrors(null);
|
|
1637
|
-
attachErrorCapture();
|
|
1638
|
-
});
|
|
1639
|
-
}}
|
|
4300
|
+
onIframeRef={handlePreviewIframeRef}
|
|
1640
4301
|
previewOverlay={
|
|
1641
|
-
captionEditMode ?
|
|
4302
|
+
captionEditMode ? (
|
|
4303
|
+
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
4304
|
+
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
|
4305
|
+
<DomEditOverlay
|
|
4306
|
+
iframeRef={previewIframeRef}
|
|
4307
|
+
activeCompositionPath={activeCompPath}
|
|
4308
|
+
hoverSelection={
|
|
4309
|
+
STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
|
|
4310
|
+
? domEditHoverSelection
|
|
4311
|
+
: null
|
|
4312
|
+
}
|
|
4313
|
+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
4314
|
+
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
4315
|
+
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
|
4316
|
+
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
4317
|
+
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
4318
|
+
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
4319
|
+
onSelectionChange={applyDomSelection}
|
|
4320
|
+
onBlockedMove={handleBlockedDomMove}
|
|
4321
|
+
onManualDragStart={handleDomManualDragStart}
|
|
4322
|
+
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
4323
|
+
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
4324
|
+
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
4325
|
+
onRotationCommit={handleDomRotationCommit}
|
|
4326
|
+
/>
|
|
4327
|
+
) : null
|
|
1642
4328
|
}
|
|
1643
4329
|
timelineFooter={
|
|
1644
4330
|
captionEditMode ? (
|
|
@@ -1679,16 +4365,95 @@ export function StudioApp() {
|
|
|
1679
4365
|
{captionEditMode ? (
|
|
1680
4366
|
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
1681
4367
|
) : (
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
4368
|
+
<>
|
|
4369
|
+
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
|
4370
|
+
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
|
4371
|
+
<>
|
|
4372
|
+
<button
|
|
4373
|
+
type="button"
|
|
4374
|
+
onClick={() => setRightPanelTab("design")}
|
|
4375
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4376
|
+
rightPanelTab === "design"
|
|
4377
|
+
? "bg-neutral-800 text-white"
|
|
4378
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4379
|
+
}`}
|
|
4380
|
+
>
|
|
4381
|
+
Design
|
|
4382
|
+
</button>
|
|
4383
|
+
{STUDIO_MOTION_PANEL_ENABLED && (
|
|
4384
|
+
<button
|
|
4385
|
+
type="button"
|
|
4386
|
+
onClick={() => setRightPanelTab("motion")}
|
|
4387
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4388
|
+
rightPanelTab === "motion"
|
|
4389
|
+
? "bg-neutral-800 text-white"
|
|
4390
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4391
|
+
}`}
|
|
4392
|
+
>
|
|
4393
|
+
Motion
|
|
4394
|
+
</button>
|
|
4395
|
+
)}
|
|
4396
|
+
</>
|
|
4397
|
+
)}
|
|
4398
|
+
<button
|
|
4399
|
+
type="button"
|
|
4400
|
+
onClick={() => setRightPanelTab("renders")}
|
|
4401
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4402
|
+
rightPanelTab === "renders"
|
|
4403
|
+
? "bg-neutral-800 text-white"
|
|
4404
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4405
|
+
}`}
|
|
4406
|
+
>
|
|
4407
|
+
{renderQueue.jobs.length > 0
|
|
4408
|
+
? `Renders (${renderQueue.jobs.length})`
|
|
4409
|
+
: "Renders"}
|
|
4410
|
+
</button>
|
|
4411
|
+
</div>
|
|
4412
|
+
<div className="min-h-0 flex-1">
|
|
4413
|
+
{designPanelActive ? (
|
|
4414
|
+
<PropertyPanel
|
|
4415
|
+
projectId={projectId}
|
|
4416
|
+
assets={assets}
|
|
4417
|
+
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
4418
|
+
multiSelectCount={domEditGroupSelections.length}
|
|
4419
|
+
copiedAgentPrompt={copiedAgentPrompt}
|
|
4420
|
+
onClearSelection={clearDomSelection}
|
|
4421
|
+
onSetStyle={handleDomStyleCommit}
|
|
4422
|
+
onSetManualOffset={handleDomPathOffsetCommit}
|
|
4423
|
+
onSetManualSize={handleDomBoxSizeCommit}
|
|
4424
|
+
onSetText={handleDomTextCommit}
|
|
4425
|
+
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
|
4426
|
+
onAddTextField={handleDomAddTextField}
|
|
4427
|
+
onRemoveTextField={handleDomRemoveTextField}
|
|
4428
|
+
onResetManualEdits={handleDomManualEditsReset}
|
|
4429
|
+
onAskAgent={handleAskAgent}
|
|
4430
|
+
onImportAssets={handleImportFiles}
|
|
4431
|
+
fontAssets={fontAssets}
|
|
4432
|
+
onImportFonts={handleImportFonts}
|
|
4433
|
+
/>
|
|
4434
|
+
) : motionPanelActive ? (
|
|
4435
|
+
<MotionPanel
|
|
4436
|
+
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
4437
|
+
motion={selectedStudioMotion}
|
|
4438
|
+
onClearSelection={clearDomSelection}
|
|
4439
|
+
onSetMotion={handleDomMotionCommit}
|
|
4440
|
+
onClearMotion={handleDomMotionClear}
|
|
4441
|
+
/>
|
|
4442
|
+
) : (
|
|
4443
|
+
<RenderQueue
|
|
4444
|
+
jobs={renderQueue.jobs}
|
|
4445
|
+
projectId={projectId}
|
|
4446
|
+
onDelete={renderQueue.deleteRender}
|
|
4447
|
+
onClearCompleted={renderQueue.clearCompleted}
|
|
4448
|
+
onStartRender={async (format, quality, resolution, fps) => {
|
|
4449
|
+
await waitForPendingDomEditSaves();
|
|
4450
|
+
await renderQueue.startRender({ fps, quality, format, resolution });
|
|
4451
|
+
}}
|
|
4452
|
+
isRendering={renderQueue.isRendering}
|
|
4453
|
+
/>
|
|
4454
|
+
)}
|
|
4455
|
+
</div>
|
|
4456
|
+
</>
|
|
1692
4457
|
)}
|
|
1693
4458
|
</div>
|
|
1694
4459
|
</>
|
|
@@ -1709,6 +4474,20 @@ export function StudioApp() {
|
|
|
1709
4474
|
/>
|
|
1710
4475
|
)}
|
|
1711
4476
|
|
|
4477
|
+
{/* Ask agent modal */}
|
|
4478
|
+
{agentModalOpen && domEditSelection && (
|
|
4479
|
+
<AskAgentModal
|
|
4480
|
+
selectionLabel={domEditSelection.label}
|
|
4481
|
+
anchorPoint={agentModalAnchorPoint}
|
|
4482
|
+
onSubmit={handleAgentModalSubmit}
|
|
4483
|
+
onClose={() => {
|
|
4484
|
+
setAgentModalOpen(false);
|
|
4485
|
+
setAgentPromptSelectionContext(undefined);
|
|
4486
|
+
setAgentModalAnchorPoint(null);
|
|
4487
|
+
}}
|
|
4488
|
+
/>
|
|
4489
|
+
)}
|
|
4490
|
+
|
|
1712
4491
|
{/* Global drag-drop overlay */}
|
|
1713
4492
|
{globalDragOver && (
|
|
1714
4493
|
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|