@hyperframes/studio 0.5.5 → 0.6.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
- package/dist/assets/index-D04_ZoMm.js +107 -0
- package/dist/assets/index-UWFaHilT.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2621 -170
- 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 +67 -0
- package/src/components/editor/PropertyPanel.tsx +2891 -207
- 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 +872 -0
- package/src/components/editor/domEditing.ts +993 -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 +120 -0
- package/src/components/editor/manualEditingAvailability.ts +60 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -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.tsx +27 -4
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/renders/RenderQueue.tsx +13 -62
- package/src/components/renders/useRenderQueue.ts +6 -30
- 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 +140 -125
- 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.tsx +18 -2
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +103 -21
- 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/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-960mgQMI.js +0 -93
package/src/App.tsx
CHANGED
|
@@ -19,13 +19,15 @@ import type { TimelineElement } from "./player";
|
|
|
19
19
|
import { LintModal } from "./components/LintModal";
|
|
20
20
|
import type { LintFinding } from "./components/LintModal";
|
|
21
21
|
import { MediaPreview } from "./components/MediaPreview";
|
|
22
|
-
import {
|
|
22
|
+
import { RotateCcw, RotateCw } from "./icons/SystemIcons";
|
|
23
|
+
import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
|
|
23
24
|
import {
|
|
24
25
|
buildTimelineAssetId,
|
|
25
26
|
buildTimelineAssetInsertHtml,
|
|
26
27
|
buildTimelineFileDropPlacements,
|
|
27
28
|
getTimelineAssetKind,
|
|
28
29
|
insertTimelineAssetIntoSource,
|
|
30
|
+
resolveTimelineAssetInitialGeometry,
|
|
29
31
|
resolveTimelineAssetSrc,
|
|
30
32
|
type TimelineAssetKind,
|
|
31
33
|
} from "./utils/timelineAssetDrop";
|
|
@@ -35,7 +37,14 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
35
37
|
import { useCaptionStore } from "./captions/store";
|
|
36
38
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
37
39
|
import { parseCaptionComposition } from "./captions/parser";
|
|
38
|
-
import {
|
|
40
|
+
import { copyTextToClipboard } from "./utils/clipboard";
|
|
41
|
+
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
42
|
+
import {
|
|
43
|
+
applyPatchByTarget,
|
|
44
|
+
readAttributeByTarget,
|
|
45
|
+
readTagSnippetByTarget,
|
|
46
|
+
type PatchOperation,
|
|
47
|
+
} from "./utils/sourcePatcher";
|
|
39
48
|
import {
|
|
40
49
|
buildTrackZIndexMap,
|
|
41
50
|
formatTimelineAttributeNumber,
|
|
@@ -51,6 +60,82 @@ import {
|
|
|
51
60
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
|
52
61
|
import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
|
|
53
62
|
import { Camera } from "./icons/SystemIcons";
|
|
63
|
+
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
64
|
+
import { MotionPanel } from "./components/editor/MotionPanel";
|
|
65
|
+
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
66
|
+
import {
|
|
67
|
+
fontFamilyFromAssetPath,
|
|
68
|
+
importedFontFaceCss,
|
|
69
|
+
type ImportedFontAsset,
|
|
70
|
+
} from "./components/editor/fontAssets";
|
|
71
|
+
import {
|
|
72
|
+
DomEditOverlay,
|
|
73
|
+
type DomEditGroupPathOffsetCommit,
|
|
74
|
+
} from "./components/editor/DomEditOverlay";
|
|
75
|
+
import { TimelineLayerPanel } from "./components/editor/TimelineLayerPanel";
|
|
76
|
+
import {
|
|
77
|
+
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
78
|
+
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
|
|
79
|
+
STUDIO_MOTION_PANEL_ENABLED,
|
|
80
|
+
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
|
|
81
|
+
STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
|
|
82
|
+
} from "./components/editor/manualEditingAvailability";
|
|
83
|
+
import {
|
|
84
|
+
buildDefaultDomEditTextField,
|
|
85
|
+
buildDomEditStylePatchOperation,
|
|
86
|
+
buildDomEditTextPatchOperation,
|
|
87
|
+
buildElementAgentPrompt,
|
|
88
|
+
collectDomEditLayerItems,
|
|
89
|
+
countDomEditChildLayers,
|
|
90
|
+
findElementForSelection,
|
|
91
|
+
findElementForTimelineElement,
|
|
92
|
+
getDomEditLayerKey,
|
|
93
|
+
getDomEditTargetKey,
|
|
94
|
+
isTextEditableSelection,
|
|
95
|
+
serializeDomEditTextFields,
|
|
96
|
+
resolveDomEditSelection,
|
|
97
|
+
type DomEditLayerItem,
|
|
98
|
+
type DomEditTextField,
|
|
99
|
+
type DomEditSelection,
|
|
100
|
+
} from "./components/editor/domEditing";
|
|
101
|
+
import {
|
|
102
|
+
STUDIO_MANUAL_EDITS_PATH,
|
|
103
|
+
applyStudioManualEditManifest,
|
|
104
|
+
emptyStudioManualEditManifest,
|
|
105
|
+
installStudioManualEditSeekReapply,
|
|
106
|
+
isStudioManualEditManifestPath,
|
|
107
|
+
parseStudioManualEditManifest,
|
|
108
|
+
readStudioFileChangePath,
|
|
109
|
+
removeStudioManualEditsForSelection,
|
|
110
|
+
serializeStudioManualEditManifest,
|
|
111
|
+
type StudioManualEditManifest,
|
|
112
|
+
upsertStudioBoxSizeEdit,
|
|
113
|
+
upsertStudioPathOffsetEdit,
|
|
114
|
+
upsertStudioRotationEdit,
|
|
115
|
+
} from "./components/editor/manualEdits";
|
|
116
|
+
import {
|
|
117
|
+
STUDIO_MOTION_PATH,
|
|
118
|
+
applyStudioMotionManifest,
|
|
119
|
+
emptyStudioMotionManifest,
|
|
120
|
+
getStudioMotionForSelection,
|
|
121
|
+
installStudioMotionSeekReapply,
|
|
122
|
+
isStudioMotionManifestPath,
|
|
123
|
+
parseStudioMotionManifest,
|
|
124
|
+
removeStudioMotionForSelection,
|
|
125
|
+
serializeStudioMotionManifest,
|
|
126
|
+
type StudioGsapMotion,
|
|
127
|
+
type StudioMotionManifest,
|
|
128
|
+
upsertStudioGsapMotion,
|
|
129
|
+
} from "./components/editor/studioMotion";
|
|
130
|
+
import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
|
|
131
|
+
import {
|
|
132
|
+
canInspectTimelineElement,
|
|
133
|
+
getTimelineElementKey,
|
|
134
|
+
getTimelineLayerVisibilityInPreview,
|
|
135
|
+
isTimelineElementActiveAtTime,
|
|
136
|
+
isTimelineLayerVisibleInPreview,
|
|
137
|
+
shouldShowTimelineInspectorBounds,
|
|
138
|
+
} from "./utils/timelineInspector";
|
|
54
139
|
|
|
55
140
|
interface EditingFile {
|
|
56
141
|
path: string;
|
|
@@ -66,6 +151,532 @@ function getTimelineElementLabel(element: TimelineElement): string {
|
|
|
66
151
|
return element.label || element.id || element.tag;
|
|
67
152
|
}
|
|
68
153
|
|
|
154
|
+
type RightPanelTab = "design" | "motion" | "renders";
|
|
155
|
+
|
|
156
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
157
|
+
"inherit",
|
|
158
|
+
"initial",
|
|
159
|
+
"revert",
|
|
160
|
+
"revert-layer",
|
|
161
|
+
"serif",
|
|
162
|
+
"sans-serif",
|
|
163
|
+
"monospace",
|
|
164
|
+
"cursive",
|
|
165
|
+
"fantasy",
|
|
166
|
+
"system-ui",
|
|
167
|
+
"ui-sans-serif",
|
|
168
|
+
"ui-serif",
|
|
169
|
+
"ui-monospace",
|
|
170
|
+
"ui-rounded",
|
|
171
|
+
"emoji",
|
|
172
|
+
"math",
|
|
173
|
+
"fangsong",
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
function primaryFontFamilyFromCss(value: string): string {
|
|
177
|
+
const first = value.split(",")[0] ?? "";
|
|
178
|
+
return first.trim().replace(/^["']|["']$/g, "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
|
182
|
+
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
|
183
|
+
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
|
184
|
+
|
|
185
|
+
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
186
|
+
if (doc.getElementById(id)) return;
|
|
187
|
+
|
|
188
|
+
const link = doc.createElement("link");
|
|
189
|
+
link.id = id;
|
|
190
|
+
link.rel = "stylesheet";
|
|
191
|
+
link.href = googleFontStylesheetUrl(family);
|
|
192
|
+
doc.head.appendChild(link);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function primaryFontFamilyValue(value: string): string {
|
|
196
|
+
return (
|
|
197
|
+
value
|
|
198
|
+
.split(",")[0]
|
|
199
|
+
?.trim()
|
|
200
|
+
.replace(/^["']|["']$/g, "")
|
|
201
|
+
.trim() ?? ""
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
|
206
|
+
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
207
|
+
if (doc.getElementById(id)) return;
|
|
208
|
+
const style = doc.createElement("style");
|
|
209
|
+
style.id = id;
|
|
210
|
+
style.textContent = importedFontFaceCss(asset);
|
|
211
|
+
doc.head.appendChild(style);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeProjectAssetPath(value: string): string {
|
|
215
|
+
const trimmed = value.trim();
|
|
216
|
+
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
217
|
+
return decodeURIComponent(maybeUrl)
|
|
218
|
+
.replace(/\\/g, "/")
|
|
219
|
+
.replace(/^\.?\//, "");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
223
|
+
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
|
224
|
+
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
|
225
|
+
|
|
226
|
+
fromParts.pop();
|
|
227
|
+
|
|
228
|
+
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
229
|
+
fromParts.shift();
|
|
230
|
+
targetParts.shift();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
237
|
+
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
|
|
241
|
+
const trimmedSource = sourceFile.trim();
|
|
242
|
+
if (!trimmedSource) return undefined;
|
|
243
|
+
|
|
244
|
+
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
245
|
+
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
246
|
+
|
|
247
|
+
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
248
|
+
if (!normalizedRoot) return undefined;
|
|
249
|
+
|
|
250
|
+
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function ensureImportedFontFace(
|
|
254
|
+
html: string,
|
|
255
|
+
asset: ImportedFontAsset,
|
|
256
|
+
sourceFile: string,
|
|
257
|
+
): string {
|
|
258
|
+
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
259
|
+
if (html.includes(css)) return html;
|
|
260
|
+
|
|
261
|
+
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
262
|
+
const styleMatch = styleRe.exec(html);
|
|
263
|
+
if (styleMatch) {
|
|
264
|
+
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
265
|
+
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
269
|
+
if (/<\/head>/i.test(html)) {
|
|
270
|
+
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
|
271
|
+
}
|
|
272
|
+
return `${styleTag}\n${html}`;
|
|
273
|
+
}
|
|
274
|
+
function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
275
|
+
const trimmed = value.trim();
|
|
276
|
+
if (!trimmed) return trimmed;
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
|
|
280
|
+
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
281
|
+
) {
|
|
282
|
+
return `${trimmed}px`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return trimmed;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isImageBackgroundValue(value: string): boolean {
|
|
289
|
+
return /^url\(/i.test(value.trim());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
293
|
+
if (!target || typeof target !== "object") return null;
|
|
294
|
+
const maybeNode = target as {
|
|
295
|
+
nodeType?: number;
|
|
296
|
+
parentElement?: Element | null;
|
|
297
|
+
};
|
|
298
|
+
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
|
299
|
+
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
|
300
|
+
return maybeNode.parentElement as HTMLElement;
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
|
306
|
+
const el = getEventTargetElement(target);
|
|
307
|
+
if (!el) return false;
|
|
308
|
+
return Boolean(
|
|
309
|
+
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
314
|
+
const isMac =
|
|
315
|
+
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
316
|
+
const modifier = isMac ? "Cmd" : "Ctrl";
|
|
317
|
+
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function findMatchingTimelineElementId(
|
|
321
|
+
selection: Pick<
|
|
322
|
+
DomEditSelection,
|
|
323
|
+
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
324
|
+
>,
|
|
325
|
+
elements: TimelineElement[],
|
|
326
|
+
): string | null {
|
|
327
|
+
const selectionSourceFile = selection.sourceFile || "index.html";
|
|
328
|
+
for (const element of elements) {
|
|
329
|
+
const elementSourceFile = element.sourceFile || "index.html";
|
|
330
|
+
if (
|
|
331
|
+
selection.id &&
|
|
332
|
+
element.domId === selection.id &&
|
|
333
|
+
elementSourceFile === selectionSourceFile
|
|
334
|
+
) {
|
|
335
|
+
return element.key ?? element.id;
|
|
336
|
+
}
|
|
337
|
+
if (
|
|
338
|
+
selection.isCompositionHost &&
|
|
339
|
+
selection.compositionSrc &&
|
|
340
|
+
element.compositionSrc === selection.compositionSrc
|
|
341
|
+
) {
|
|
342
|
+
return element.key ?? element.id;
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
selection.selector &&
|
|
346
|
+
element.selector === selection.selector &&
|
|
347
|
+
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
348
|
+
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
349
|
+
) {
|
|
350
|
+
return element.key ?? element.id;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isManualGeometryStyleProperty(property: string): boolean {
|
|
358
|
+
return property === "left" || property === "top" || property === "width" || property === "height";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function getPreviewTargetFromPointer(
|
|
362
|
+
iframe: HTMLIFrameElement,
|
|
363
|
+
clientX: number,
|
|
364
|
+
clientY: number,
|
|
365
|
+
): HTMLElement | null {
|
|
366
|
+
let doc: Document | null = null;
|
|
367
|
+
let win: Window | null = null;
|
|
368
|
+
try {
|
|
369
|
+
doc = iframe.contentDocument;
|
|
370
|
+
win = iframe.contentWindow;
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
if (!doc || !win) return null;
|
|
375
|
+
|
|
376
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
377
|
+
const root =
|
|
378
|
+
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
|
379
|
+
const rootRect = root?.getBoundingClientRect();
|
|
380
|
+
const rootWidth = rootRect?.width || win.innerWidth;
|
|
381
|
+
const rootHeight = rootRect?.height || win.innerHeight;
|
|
382
|
+
if (!rootWidth || !rootHeight) return null;
|
|
383
|
+
|
|
384
|
+
const scaleX = iframeRect.width / rootWidth;
|
|
385
|
+
const scaleY = iframeRect.height / rootHeight;
|
|
386
|
+
const localX = (clientX - iframeRect.left) / scaleX;
|
|
387
|
+
const localY = (clientY - iframeRect.top) / scaleY;
|
|
388
|
+
|
|
389
|
+
return getEventTargetElement(doc.elementFromPoint(localX, localY));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function domEditSelectionsTargetSame(
|
|
393
|
+
a: DomEditSelection | null,
|
|
394
|
+
b: DomEditSelection | null,
|
|
395
|
+
): boolean {
|
|
396
|
+
if (a === b) return true;
|
|
397
|
+
if (!a || !b) return false;
|
|
398
|
+
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function domEditSelectionInGroup(
|
|
402
|
+
group: DomEditSelection[],
|
|
403
|
+
selection: DomEditSelection | null,
|
|
404
|
+
): boolean {
|
|
405
|
+
if (!selection) return false;
|
|
406
|
+
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function toggleDomEditGroupSelection(
|
|
410
|
+
group: DomEditSelection[],
|
|
411
|
+
selection: DomEditSelection,
|
|
412
|
+
): DomEditSelection[] {
|
|
413
|
+
if (domEditSelectionInGroup(group, selection)) {
|
|
414
|
+
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
|
|
415
|
+
}
|
|
416
|
+
return [...group, selection];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function replaceDomEditGroupSelection(
|
|
420
|
+
group: DomEditSelection[],
|
|
421
|
+
selection: DomEditSelection,
|
|
422
|
+
): DomEditSelection[] {
|
|
423
|
+
let replaced = false;
|
|
424
|
+
const nextGroup = group.map((entry) => {
|
|
425
|
+
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
|
|
426
|
+
replaced = true;
|
|
427
|
+
return selection;
|
|
428
|
+
});
|
|
429
|
+
return replaced ? nextGroup : [...group, selection];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function seedDomEditGroupWithSelection(
|
|
433
|
+
group: DomEditSelection[],
|
|
434
|
+
selection: DomEditSelection | null,
|
|
435
|
+
): DomEditSelection[] {
|
|
436
|
+
if (!selection || domEditSelectionInGroup(group, selection)) return group;
|
|
437
|
+
return [selection, ...group];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function objectLike(value: unknown): object | null {
|
|
441
|
+
return value && (typeof value === "object" || typeof value === "function") ? value : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function callPlaybackMethod(target: object | null, key: string): void {
|
|
445
|
+
const method = target ? Reflect.get(target, key) : null;
|
|
446
|
+
if (typeof method !== "function") return;
|
|
447
|
+
try {
|
|
448
|
+
method.call(target);
|
|
449
|
+
} catch {
|
|
450
|
+
// Best-effort playback freeze; drag should still work if playback control is unavailable.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function readPlaybackTime(target: object | null, key: string): number | null {
|
|
455
|
+
const method = target ? Reflect.get(target, key) : null;
|
|
456
|
+
if (typeof method !== "function") return null;
|
|
457
|
+
try {
|
|
458
|
+
const value = method.call(target);
|
|
459
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
interface PreviewPlayerCompat {
|
|
466
|
+
getTime: () => number;
|
|
467
|
+
renderSeek: (timeSeconds: number) => void;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
|
|
471
|
+
const player = objectLike(win ? Reflect.get(win, "__player") : null);
|
|
472
|
+
if (!player) return null;
|
|
473
|
+
const getTime = Reflect.get(player, "getTime");
|
|
474
|
+
const renderSeek = Reflect.get(player, "renderSeek");
|
|
475
|
+
if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
|
|
476
|
+
return {
|
|
477
|
+
getTime: () => {
|
|
478
|
+
const value = getTime.call(player);
|
|
479
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
480
|
+
},
|
|
481
|
+
renderSeek: (timeSeconds: number) => {
|
|
482
|
+
renderSeek.call(player, timeSeconds);
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
|
|
488
|
+
const player = getPreviewPlayer(iframe?.contentWindow);
|
|
489
|
+
if (!player) return false;
|
|
490
|
+
const nextTime = Math.max(0, timeSeconds);
|
|
491
|
+
player.renderSeek(nextTime);
|
|
492
|
+
usePlayerStore.getState().setCurrentTime(nextTime);
|
|
493
|
+
liveTime.notify(nextTime);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function parseFiniteSeconds(value: string | null): number | null {
|
|
498
|
+
if (value == null || value.trim() === "") return null;
|
|
499
|
+
const parsed = Number.parseFloat(value);
|
|
500
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function resolveLayerVisibleSeekTime(
|
|
504
|
+
layerElement: HTMLElement,
|
|
505
|
+
timelineElement: TimelineElement | null,
|
|
506
|
+
player: PreviewPlayerCompat | null,
|
|
507
|
+
): number | null {
|
|
508
|
+
if (!timelineElement || !player) return null;
|
|
509
|
+
const originalTime = player.getTime();
|
|
510
|
+
|
|
511
|
+
const clipStart = Math.max(0, timelineElement.start);
|
|
512
|
+
const clipEnd = Math.max(clipStart, clipStart + Math.max(0, timelineElement.duration));
|
|
513
|
+
const authoredStart = parseFiniteSeconds(
|
|
514
|
+
layerElement.getAttribute("data-start") ??
|
|
515
|
+
layerElement.closest<HTMLElement>("[data-start]")?.getAttribute("data-start") ??
|
|
516
|
+
null,
|
|
517
|
+
);
|
|
518
|
+
const preferredTime =
|
|
519
|
+
authoredStart == null
|
|
520
|
+
? clipStart
|
|
521
|
+
: Math.min(clipEnd, Math.max(clipStart, clipStart + authoredStart));
|
|
522
|
+
const candidates = [preferredTime, clipStart];
|
|
523
|
+
const duration = clipEnd - clipStart;
|
|
524
|
+
if (duration > 0) {
|
|
525
|
+
const maxSamples = 24;
|
|
526
|
+
const frameStep = 1 / 24;
|
|
527
|
+
const step = Math.max(frameStep, duration / maxSamples);
|
|
528
|
+
for (let time = clipStart; time <= clipEnd + 0.0001; time += step) {
|
|
529
|
+
candidates.push(Math.min(clipEnd, time));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
candidates.push(clipEnd);
|
|
533
|
+
|
|
534
|
+
let lastTried = preferredTime;
|
|
535
|
+
let clearestVisibleTime: number | null = null;
|
|
536
|
+
let clearestVisibleOpacity = 0;
|
|
537
|
+
let resolvedTime: number | null = null;
|
|
538
|
+
const seen = new Set<string>();
|
|
539
|
+
try {
|
|
540
|
+
for (const candidate of candidates) {
|
|
541
|
+
const time = Math.min(clipEnd, Math.max(clipStart, candidate));
|
|
542
|
+
const key = time.toFixed(4);
|
|
543
|
+
if (seen.has(key)) continue;
|
|
544
|
+
seen.add(key);
|
|
545
|
+
lastTried = time;
|
|
546
|
+
player.renderSeek(time);
|
|
547
|
+
const visibility = getTimelineLayerVisibilityInPreview(layerElement);
|
|
548
|
+
if (visibility.visible && visibility.compositeOpacity > clearestVisibleOpacity) {
|
|
549
|
+
clearestVisibleTime = time;
|
|
550
|
+
clearestVisibleOpacity = visibility.compositeOpacity;
|
|
551
|
+
}
|
|
552
|
+
if (isTimelineLayerVisibleInPreview(layerElement, { minCompositeOpacity: 0.9 })) {
|
|
553
|
+
resolvedTime = time;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} finally {
|
|
558
|
+
player.renderSeek(originalTime);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return resolvedTime ?? clearestVisibleTime ?? lastTried;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
|
|
565
|
+
const win = iframe?.contentWindow;
|
|
566
|
+
if (!win) return null;
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
let pausedTime: number | null = null;
|
|
570
|
+
const player = objectLike(Reflect.get(win, "__player"));
|
|
571
|
+
pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
|
|
572
|
+
callPlaybackMethod(player, "pause");
|
|
573
|
+
|
|
574
|
+
const timeline = objectLike(Reflect.get(win, "__timeline"));
|
|
575
|
+
pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
|
|
576
|
+
callPlaybackMethod(timeline, "pause");
|
|
577
|
+
|
|
578
|
+
const timelines = objectLike(Reflect.get(win, "__timelines"));
|
|
579
|
+
if (timelines) {
|
|
580
|
+
for (const value of Object.values(timelines)) {
|
|
581
|
+
const timelineRecord = objectLike(value);
|
|
582
|
+
pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
|
|
583
|
+
callPlaybackMethod(timelineRecord, "pause");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return pausedTime;
|
|
588
|
+
} catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── Ask Agent Modal ──
|
|
594
|
+
|
|
595
|
+
function AskAgentModal({
|
|
596
|
+
selectionLabel,
|
|
597
|
+
onSubmit,
|
|
598
|
+
onClose,
|
|
599
|
+
}: {
|
|
600
|
+
selectionLabel: string;
|
|
601
|
+
onSubmit: (instruction: string) => void;
|
|
602
|
+
onClose: () => void;
|
|
603
|
+
}) {
|
|
604
|
+
const [value, setValue] = useState("");
|
|
605
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
606
|
+
|
|
607
|
+
useMountEffect(() => {
|
|
608
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const handleSubmit = () => {
|
|
612
|
+
if (!value.trim()) return;
|
|
613
|
+
onSubmit(value.trim());
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
return (
|
|
617
|
+
<div
|
|
618
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
619
|
+
onClick={onClose}
|
|
620
|
+
>
|
|
621
|
+
<div
|
|
622
|
+
className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
|
|
623
|
+
onClick={(e) => e.stopPropagation()}
|
|
624
|
+
>
|
|
625
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
|
626
|
+
<div>
|
|
627
|
+
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
|
628
|
+
<p className="text-xs text-neutral-500 mt-0.5">
|
|
629
|
+
{selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
|
|
630
|
+
</p>
|
|
631
|
+
</div>
|
|
632
|
+
<button
|
|
633
|
+
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
|
634
|
+
onClick={onClose}
|
|
635
|
+
>
|
|
636
|
+
<svg
|
|
637
|
+
width="14"
|
|
638
|
+
height="14"
|
|
639
|
+
viewBox="0 0 24 24"
|
|
640
|
+
fill="none"
|
|
641
|
+
stroke="currentColor"
|
|
642
|
+
strokeWidth="2"
|
|
643
|
+
strokeLinecap="round"
|
|
644
|
+
>
|
|
645
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
646
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
647
|
+
</svg>
|
|
648
|
+
</button>
|
|
649
|
+
</div>
|
|
650
|
+
<div className="px-5 py-4">
|
|
651
|
+
<textarea
|
|
652
|
+
ref={inputRef}
|
|
653
|
+
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"
|
|
654
|
+
placeholder="Describe what you want to change…"
|
|
655
|
+
value={value}
|
|
656
|
+
onChange={(e) => setValue(e.target.value)}
|
|
657
|
+
onKeyDown={(e) => {
|
|
658
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
|
|
659
|
+
if (e.key === "Escape") onClose();
|
|
660
|
+
}}
|
|
661
|
+
/>
|
|
662
|
+
</div>
|
|
663
|
+
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
|
664
|
+
<span className="text-[11px] text-neutral-600">
|
|
665
|
+
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
|
666
|
+
</span>
|
|
667
|
+
<button
|
|
668
|
+
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"
|
|
669
|
+
disabled={!value.trim()}
|
|
670
|
+
onClick={handleSubmit}
|
|
671
|
+
>
|
|
672
|
+
Copy prompt
|
|
673
|
+
</button>
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
</div>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
69
680
|
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
70
681
|
image: 3,
|
|
71
682
|
video: 5,
|
|
@@ -144,6 +755,7 @@ export function StudioApp() {
|
|
|
144
755
|
});
|
|
145
756
|
|
|
146
757
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
758
|
+
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
147
759
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
148
760
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
149
761
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -157,6 +769,24 @@ export function StudioApp() {
|
|
|
157
769
|
const [rightWidth, setRightWidth] = useState(400);
|
|
158
770
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
159
771
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
772
|
+
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
773
|
+
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
774
|
+
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
|
775
|
+
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
|
776
|
+
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
777
|
+
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
778
|
+
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
779
|
+
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
780
|
+
const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
|
|
781
|
+
const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState<
|
|
782
|
+
ReadonlySet<string>
|
|
783
|
+
>(() => new Set());
|
|
784
|
+
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
|
785
|
+
const refreshPreviewDocumentVersion = useCallback(() => {
|
|
786
|
+
setPreviewDocumentVersion((version) => version + 1);
|
|
787
|
+
window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 80);
|
|
788
|
+
window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 300);
|
|
789
|
+
}, []);
|
|
160
790
|
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
|
|
161
791
|
// This is a subscription to external events (postMessage from runtime) — useEffect
|
|
162
792
|
// is appropriate here. The runtime fires "state"/"timeline" messages after all
|
|
@@ -283,7 +913,10 @@ export function StudioApp() {
|
|
|
283
913
|
const dragCounterRef = useRef(0);
|
|
284
914
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
285
915
|
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
916
|
+
const lastBlockedDomMoveToastAtRef = useRef(0);
|
|
917
|
+
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
286
918
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
919
|
+
const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
|
|
287
920
|
const panelDragRef = useRef<{
|
|
288
921
|
side: "left" | "right";
|
|
289
922
|
startX: number;
|
|
@@ -294,11 +927,15 @@ export function StudioApp() {
|
|
|
294
927
|
const activePreviewUrl = activeCompPath
|
|
295
928
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
296
929
|
: null;
|
|
930
|
+
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
297
931
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
298
932
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
299
933
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
300
934
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
935
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
301
936
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
937
|
+
const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
|
|
938
|
+
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
302
939
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
303
940
|
const effectiveTimelineDuration = useMemo(() => {
|
|
304
941
|
const maxEnd =
|
|
@@ -400,7 +1037,6 @@ export function StudioApp() {
|
|
|
400
1037
|
label={getTimelineElementLabel(el)}
|
|
401
1038
|
labelColor={style.label}
|
|
402
1039
|
accentColor={style.clip}
|
|
403
|
-
selector={el.selector}
|
|
404
1040
|
seekTime={0}
|
|
405
1041
|
duration={el.duration}
|
|
406
1042
|
/>
|
|
@@ -417,6 +1053,7 @@ export function StudioApp() {
|
|
|
417
1053
|
labelColor={style.label}
|
|
418
1054
|
accentColor={style.clip}
|
|
419
1055
|
selector={el.selector}
|
|
1056
|
+
selectorIndex={el.selectorIndex}
|
|
420
1057
|
seekTime={el.start}
|
|
421
1058
|
duration={el.duration}
|
|
422
1059
|
/>
|
|
@@ -478,6 +1115,7 @@ export function StudioApp() {
|
|
|
478
1115
|
labelColor={style.label}
|
|
479
1116
|
accentColor={style.clip}
|
|
480
1117
|
selector={el.selector}
|
|
1118
|
+
selectorIndex={el.selectorIndex}
|
|
481
1119
|
seekTime={el.start}
|
|
482
1120
|
duration={el.duration}
|
|
483
1121
|
/>
|
|
@@ -562,16 +1200,80 @@ export function StudioApp() {
|
|
|
562
1200
|
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
|
563
1201
|
const [linting, setLinting] = useState(false);
|
|
564
1202
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
1203
|
+
const [, setStudioMotionRevision] = useState(0);
|
|
565
1204
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
566
1205
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
567
1206
|
const projectIdRef = useRef(projectId);
|
|
568
1207
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
569
1208
|
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
|
1209
|
+
const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1210
|
+
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
1211
|
+
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
|
1212
|
+
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
|
1213
|
+
const domEditSaveTimestampRef = useRef(0);
|
|
1214
|
+
const domTextCommitVersionRef = useRef(0);
|
|
1215
|
+
const domEditSaveQueueRef = useRef(Promise.resolve());
|
|
1216
|
+
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
|
|
1217
|
+
emptyStudioManualEditManifest(),
|
|
1218
|
+
);
|
|
1219
|
+
const studioManualEditRevisionRef = useRef(0);
|
|
1220
|
+
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
|
|
1221
|
+
const studioMotionRevisionRef = useRef(0);
|
|
1222
|
+
const applyStudioManualEditsToPreviewRef = useRef<
|
|
1223
|
+
(
|
|
1224
|
+
iframe?: HTMLIFrameElement | null,
|
|
1225
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1226
|
+
) => Promise<void>
|
|
1227
|
+
>(async () => {});
|
|
1228
|
+
const applyStudioMotionToPreviewRef = useRef<
|
|
1229
|
+
(
|
|
1230
|
+
iframe?: HTMLIFrameElement | null,
|
|
1231
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1232
|
+
) => Promise<void>
|
|
1233
|
+
>(async () => {});
|
|
1234
|
+
const studioManualEditProjectRef = useRef<string | null>(projectId);
|
|
1235
|
+
const activeCompPathRef = useRef(activeCompPath);
|
|
1236
|
+
activeCompPathRef.current = activeCompPath;
|
|
1237
|
+
|
|
1238
|
+
const queueDomEditSave = useCallback((save: () => Promise<void>) => {
|
|
1239
|
+
const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
|
|
1240
|
+
domEditSaveQueueRef.current = queuedSave.then(
|
|
1241
|
+
() => undefined,
|
|
1242
|
+
() => undefined,
|
|
1243
|
+
);
|
|
1244
|
+
return queuedSave;
|
|
1245
|
+
}, []);
|
|
1246
|
+
|
|
1247
|
+
const waitForPendingDomEditSaves = useCallback(async () => {
|
|
1248
|
+
await domEditSaveQueueRef.current.catch(() => undefined);
|
|
1249
|
+
}, []);
|
|
570
1250
|
|
|
571
1251
|
// Listen for external file changes (user editing HTML outside the editor).
|
|
572
1252
|
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
1253
|
+
// Suppress file-change events that echo back from a recent DOM edit save —
|
|
1254
|
+
// those changes are already applied to the iframe DOM and a full reload
|
|
1255
|
+
// would flash the preview.
|
|
573
1256
|
useMountEffect(() => {
|
|
574
|
-
const handler = () => {
|
|
1257
|
+
const handler = (payload?: unknown) => {
|
|
1258
|
+
const changedPath = readStudioFileChangePath(payload);
|
|
1259
|
+
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
|
1260
|
+
if (isStudioManualEditManifestPath(changedPath)) {
|
|
1261
|
+
if (!recentDomEditSave) {
|
|
1262
|
+
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
|
|
1263
|
+
forceFromDisk: true,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (isStudioMotionManifestPath(changedPath)) {
|
|
1269
|
+
if (!recentDomEditSave) {
|
|
1270
|
+
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
|
|
1271
|
+
forceFromDisk: true,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (recentDomEditSave) return;
|
|
575
1277
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
576
1278
|
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
577
1279
|
};
|
|
@@ -585,6 +1287,21 @@ export function StudioApp() {
|
|
|
585
1287
|
return () => es.close();
|
|
586
1288
|
});
|
|
587
1289
|
projectIdRef.current = projectId;
|
|
1290
|
+
domEditSelectionRef.current = domEditSelection;
|
|
1291
|
+
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
1292
|
+
domEditHoverSelectionRef.current = domEditHoverSelection;
|
|
1293
|
+
|
|
1294
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1295
|
+
useEffect(() => {
|
|
1296
|
+
const previousProjectId = studioManualEditProjectRef.current;
|
|
1297
|
+
studioManualEditProjectRef.current = projectId;
|
|
1298
|
+
if (!previousProjectId || previousProjectId === projectId) return;
|
|
1299
|
+
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
|
|
1300
|
+
studioManualEditRevisionRef.current += 1;
|
|
1301
|
+
studioMotionManifestRef.current = emptyStudioMotionManifest();
|
|
1302
|
+
studioMotionRevisionRef.current += 1;
|
|
1303
|
+
setStudioMotionRevision((revision) => revision + 1);
|
|
1304
|
+
}, [projectId]);
|
|
588
1305
|
|
|
589
1306
|
// Load file tree when projectId changes.
|
|
590
1307
|
// Note: This is one of the few places where useEffect with deps is acceptable —
|
|
@@ -596,10 +1313,13 @@ export function StudioApp() {
|
|
|
596
1313
|
let cancelled = false;
|
|
597
1314
|
fetch(`/api/projects/${projectId}`)
|
|
598
1315
|
.then((r) => r.json())
|
|
599
|
-
.then((data: { files?: string[] }) => {
|
|
1316
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
600
1317
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1318
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
601
1319
|
})
|
|
602
|
-
.catch(() => {
|
|
1320
|
+
.catch(() => {
|
|
1321
|
+
if (!cancelled) setProjectDir(null);
|
|
1322
|
+
});
|
|
603
1323
|
return () => {
|
|
604
1324
|
cancelled = true;
|
|
605
1325
|
};
|
|
@@ -627,29 +1347,72 @@ export function StudioApp() {
|
|
|
627
1347
|
|
|
628
1348
|
const editingPathRef = useRef(editingFile?.path);
|
|
629
1349
|
editingPathRef.current = editingFile?.path;
|
|
1350
|
+
const editHistory = usePersistentEditHistory({ projectId });
|
|
630
1351
|
|
|
631
|
-
const
|
|
1352
|
+
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
632
1353
|
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
|
-
|
|
1354
|
+
if (!pid) throw new Error("No active project");
|
|
1355
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
1356
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
1357
|
+
const data = (await response.json()) as { content?: string };
|
|
1358
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
1359
|
+
return data.content;
|
|
1360
|
+
}, []);
|
|
1361
|
+
|
|
1362
|
+
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
|
1363
|
+
const pid = projectIdRef.current;
|
|
1364
|
+
if (!pid) throw new Error("No active project");
|
|
1365
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
1366
|
+
method: "PUT",
|
|
1367
|
+
headers: { "Content-Type": "text/plain" },
|
|
1368
|
+
body: content,
|
|
1369
|
+
});
|
|
1370
|
+
if (!response.ok) throw new Error(`Failed to save ${path}`);
|
|
1371
|
+
if (editingPathRef.current === path) {
|
|
1372
|
+
setEditingFile({ path, content });
|
|
1373
|
+
}
|
|
1374
|
+
}, []);
|
|
1375
|
+
|
|
1376
|
+
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
|
|
1377
|
+
const pid = projectIdRef.current;
|
|
1378
|
+
if (!pid) throw new Error("No active project");
|
|
1379
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
1380
|
+
if (response.status === 404) return "";
|
|
1381
|
+
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
|
1382
|
+
const data = (await response.json()) as { content?: string };
|
|
1383
|
+
return typeof data.content === "string" ? data.content : "";
|
|
651
1384
|
}, []);
|
|
652
1385
|
|
|
1386
|
+
const handleContentChange = useCallback(
|
|
1387
|
+
(content: string) => {
|
|
1388
|
+
const pid = projectIdRef.current;
|
|
1389
|
+
if (!pid) return;
|
|
1390
|
+
const path = editingPathRef.current;
|
|
1391
|
+
if (!path) return;
|
|
1392
|
+
|
|
1393
|
+
// Debounce the server write (600ms)
|
|
1394
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
1395
|
+
saveTimerRef.current = setTimeout(() => {
|
|
1396
|
+
saveProjectFilesWithHistory({
|
|
1397
|
+
projectId: pid,
|
|
1398
|
+
label: "Edit source",
|
|
1399
|
+
kind: "source",
|
|
1400
|
+
coalesceKey: `source:${path}`,
|
|
1401
|
+
files: { [path]: content },
|
|
1402
|
+
readFile: readProjectFile,
|
|
1403
|
+
writeFile: writeProjectFile,
|
|
1404
|
+
recordEdit: editHistory.recordEdit,
|
|
1405
|
+
})
|
|
1406
|
+
.then(() => {
|
|
1407
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1408
|
+
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
|
1409
|
+
})
|
|
1410
|
+
.catch(() => {});
|
|
1411
|
+
}, 600);
|
|
1412
|
+
},
|
|
1413
|
+
[editHistory.recordEdit, readProjectFile, writeProjectFile],
|
|
1414
|
+
);
|
|
1415
|
+
|
|
653
1416
|
const handleTimelineElementMove = useCallback(
|
|
654
1417
|
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
655
1418
|
const pid = projectIdRef.current;
|
|
@@ -728,25 +1491,19 @@ export function StudioApp() {
|
|
|
728
1491
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
729
1492
|
}
|
|
730
1493
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (editingPathRef.current === targetPath) {
|
|
744
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
745
|
-
}
|
|
1494
|
+
await saveProjectFilesWithHistory({
|
|
1495
|
+
projectId: pid,
|
|
1496
|
+
label: "Move timeline clip",
|
|
1497
|
+
kind: "timeline",
|
|
1498
|
+
files: { [targetPath]: patchedContent },
|
|
1499
|
+
readFile: async () => originalContent,
|
|
1500
|
+
writeFile: writeProjectFile,
|
|
1501
|
+
recordEdit: editHistory.recordEdit,
|
|
1502
|
+
});
|
|
746
1503
|
|
|
747
1504
|
setRefreshKey((k) => k + 1);
|
|
748
1505
|
},
|
|
749
|
-
[activeCompPath, timelineElements],
|
|
1506
|
+
[activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
|
|
750
1507
|
);
|
|
751
1508
|
|
|
752
1509
|
const handleTimelineElementResize = useCallback(
|
|
@@ -818,25 +1575,19 @@ export function StudioApp() {
|
|
|
818
1575
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
819
1576
|
}
|
|
820
1577
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
if (editingPathRef.current === targetPath) {
|
|
834
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
835
|
-
}
|
|
1578
|
+
await saveProjectFilesWithHistory({
|
|
1579
|
+
projectId: pid,
|
|
1580
|
+
label: "Resize timeline clip",
|
|
1581
|
+
kind: "timeline",
|
|
1582
|
+
files: { [targetPath]: patchedContent },
|
|
1583
|
+
readFile: async () => originalContent,
|
|
1584
|
+
writeFile: writeProjectFile,
|
|
1585
|
+
recordEdit: editHistory.recordEdit,
|
|
1586
|
+
});
|
|
836
1587
|
|
|
837
1588
|
setRefreshKey((k) => k + 1);
|
|
838
1589
|
},
|
|
839
|
-
[activeCompPath],
|
|
1590
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
840
1591
|
);
|
|
841
1592
|
|
|
842
1593
|
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
@@ -852,6 +1603,7 @@ export function StudioApp() {
|
|
|
852
1603
|
|
|
853
1604
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
854
1605
|
setCaptureFrameTime(currentTime);
|
|
1606
|
+
await waitForPendingDomEditSaves();
|
|
855
1607
|
const href = buildFrameCaptureUrl({
|
|
856
1608
|
projectId,
|
|
857
1609
|
compositionPath: activeCompPath,
|
|
@@ -878,7 +1630,7 @@ export function StudioApp() {
|
|
|
878
1630
|
showToast(message);
|
|
879
1631
|
}
|
|
880
1632
|
},
|
|
881
|
-
[activeCompPath, projectId, showToast],
|
|
1633
|
+
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
882
1634
|
);
|
|
883
1635
|
|
|
884
1636
|
const handleTimelineElementDelete = useCallback(
|
|
@@ -961,21 +1713,15 @@ export function StudioApp() {
|
|
|
961
1713
|
});
|
|
962
1714
|
}
|
|
963
1715
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
if (editingPathRef.current === targetPath) {
|
|
977
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
978
|
-
}
|
|
1716
|
+
await saveProjectFilesWithHistory({
|
|
1717
|
+
projectId: pid,
|
|
1718
|
+
label: "Delete timeline clip",
|
|
1719
|
+
kind: "timeline",
|
|
1720
|
+
files: { [targetPath]: patchedContent },
|
|
1721
|
+
readFile: async () => originalContent,
|
|
1722
|
+
writeFile: writeProjectFile,
|
|
1723
|
+
recordEdit: editHistory.recordEdit,
|
|
1724
|
+
});
|
|
979
1725
|
|
|
980
1726
|
usePlayerStore
|
|
981
1727
|
.getState()
|
|
@@ -992,7 +1738,7 @@ export function StudioApp() {
|
|
|
992
1738
|
showToast(message);
|
|
993
1739
|
}
|
|
994
1740
|
},
|
|
995
|
-
[activeCompPath, showToast, timelineElements],
|
|
1741
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
996
1742
|
);
|
|
997
1743
|
|
|
998
1744
|
const handleBlockedTimelineEdit = useCallback(
|
|
@@ -1005,23 +1751,1532 @@ export function StudioApp() {
|
|
|
1005
1751
|
[showToast],
|
|
1006
1752
|
);
|
|
1007
1753
|
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1754
|
+
const handleBlockedDomMove = useCallback(
|
|
1755
|
+
(selection: DomEditSelection) => {
|
|
1756
|
+
const now = Date.now();
|
|
1757
|
+
if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
|
|
1758
|
+
lastBlockedDomMoveToastAtRef.current = now;
|
|
1759
|
+
showToast(
|
|
1760
|
+
selection.capabilities.reasonIfDisabled ??
|
|
1761
|
+
"This element can’t be adjusted directly from the preview.",
|
|
1762
|
+
"info",
|
|
1763
|
+
);
|
|
1764
|
+
},
|
|
1765
|
+
[showToast],
|
|
1766
|
+
);
|
|
1015
1767
|
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1768
|
+
const applyDomSelection = useCallback(
|
|
1769
|
+
(
|
|
1770
|
+
selection: DomEditSelection | null,
|
|
1771
|
+
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
1772
|
+
) => {
|
|
1773
|
+
setAgentPromptTagSnippet(undefined);
|
|
1774
|
+
setCopiedAgentPrompt(false);
|
|
1775
|
+
if (!selection) {
|
|
1776
|
+
domEditSelectionRef.current = null;
|
|
1777
|
+
domEditGroupSelectionsRef.current = [];
|
|
1778
|
+
setDomEditSelection(null);
|
|
1779
|
+
setDomEditGroupSelections([]);
|
|
1780
|
+
setSelectedTimelineElementId(null);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
|
|
1784
|
+
domEditSelectionRef.current = null;
|
|
1785
|
+
domEditGroupSelectionsRef.current = [];
|
|
1786
|
+
setDomEditSelection(null);
|
|
1787
|
+
setDomEditGroupSelections([]);
|
|
1788
|
+
setSelectedTimelineElementId(null);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1021
1791
|
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1792
|
+
const isAdditiveSelection = Boolean(options?.additive);
|
|
1793
|
+
const currentSelection = domEditSelectionRef.current;
|
|
1794
|
+
const previousGroup = domEditGroupSelectionsRef.current;
|
|
1795
|
+
const currentGroup = isAdditiveSelection
|
|
1796
|
+
? seedDomEditGroupWithSelection(previousGroup, currentSelection)
|
|
1797
|
+
: previousGroup;
|
|
1798
|
+
const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
|
|
1799
|
+
const nextGroup = options?.preserveGroup
|
|
1800
|
+
? replaceDomEditGroupSelection(currentGroup, selection)
|
|
1801
|
+
: isAdditiveSelection
|
|
1802
|
+
? toggleDomEditGroupSelection(currentGroup, selection)
|
|
1803
|
+
: [selection];
|
|
1804
|
+
const nextSelection = options?.preserveGroup
|
|
1805
|
+
? selection
|
|
1806
|
+
: isAdditiveSelection && wasInGroup
|
|
1807
|
+
? domEditSelectionsTargetSame(currentSelection, selection)
|
|
1808
|
+
? (nextGroup[0] ?? null)
|
|
1809
|
+
: domEditSelectionInGroup(nextGroup, currentSelection)
|
|
1810
|
+
? currentSelection
|
|
1811
|
+
: (nextGroup[0] ?? null)
|
|
1812
|
+
: selection;
|
|
1813
|
+
|
|
1814
|
+
domEditSelectionRef.current = nextSelection;
|
|
1815
|
+
domEditGroupSelectionsRef.current = nextGroup;
|
|
1816
|
+
setDomEditSelection(nextSelection);
|
|
1817
|
+
setDomEditGroupSelections(nextGroup);
|
|
1818
|
+
|
|
1819
|
+
if (nextSelection) {
|
|
1820
|
+
if (options?.revealPanel !== false) {
|
|
1821
|
+
setRightCollapsed(false);
|
|
1822
|
+
setRightPanelTab("design");
|
|
1823
|
+
}
|
|
1824
|
+
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
|
1825
|
+
nextSelection,
|
|
1826
|
+
timelineElements,
|
|
1827
|
+
);
|
|
1828
|
+
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
setSelectedTimelineElementId(null);
|
|
1833
|
+
},
|
|
1834
|
+
[setSelectedTimelineElementId, timelineElements],
|
|
1835
|
+
);
|
|
1836
|
+
|
|
1837
|
+
const clearDomSelection = useCallback(() => {
|
|
1838
|
+
applyDomSelection(null, { revealPanel: false });
|
|
1839
|
+
}, [applyDomSelection]);
|
|
1840
|
+
|
|
1841
|
+
const readHistoryProjectFile = useCallback(
|
|
1842
|
+
async (path: string): Promise<string> => {
|
|
1843
|
+
return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
|
|
1844
|
+
? readOptionalProjectFile(path)
|
|
1845
|
+
: readProjectFile(path);
|
|
1846
|
+
},
|
|
1847
|
+
[readOptionalProjectFile, readProjectFile],
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
const writeHistoryProjectFile = useCallback(
|
|
1851
|
+
async (path: string, content: string): Promise<void> => {
|
|
1852
|
+
await writeProjectFile(path, content);
|
|
1853
|
+
if (path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH) {
|
|
1854
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1855
|
+
}
|
|
1856
|
+
},
|
|
1857
|
+
[writeProjectFile],
|
|
1858
|
+
);
|
|
1859
|
+
|
|
1860
|
+
const applyCurrentStudioManualEditsToPreview = useCallback(
|
|
1861
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
1862
|
+
if (!iframe) return;
|
|
1863
|
+
let doc: Document | null = null;
|
|
1864
|
+
try {
|
|
1865
|
+
doc = iframe.contentDocument;
|
|
1866
|
+
} catch {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
if (!doc) return;
|
|
1870
|
+
const previewDoc = doc;
|
|
1871
|
+
|
|
1872
|
+
const applyManifest = () => {
|
|
1873
|
+
applyStudioManualEditManifest(
|
|
1874
|
+
previewDoc,
|
|
1875
|
+
studioManualEditManifestRef.current,
|
|
1876
|
+
activeCompPathRef.current,
|
|
1877
|
+
);
|
|
1878
|
+
};
|
|
1879
|
+
const applyAndInstallSeekHooks = () => {
|
|
1880
|
+
applyManifest();
|
|
1881
|
+
if (iframe.contentWindow) {
|
|
1882
|
+
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
const win = iframe.contentWindow;
|
|
1887
|
+
applyAndInstallSeekHooks();
|
|
1888
|
+
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
1889
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
|
|
1890
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
|
|
1891
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
|
|
1892
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
|
|
1893
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
|
|
1894
|
+
},
|
|
1895
|
+
[],
|
|
1896
|
+
);
|
|
1897
|
+
|
|
1898
|
+
const applyStudioManualEditsToPreview = useCallback(
|
|
1899
|
+
async (
|
|
1900
|
+
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
1901
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1902
|
+
) => {
|
|
1903
|
+
const readRevision = studioManualEditRevisionRef.current;
|
|
1904
|
+
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
1905
|
+
if (!readFromDiskFirst) {
|
|
1906
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1907
|
+
}
|
|
1908
|
+
let content: string;
|
|
1909
|
+
try {
|
|
1910
|
+
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
const message =
|
|
1913
|
+
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
|
1914
|
+
showToast(message);
|
|
1915
|
+
if (readFromDiskFirst) {
|
|
1916
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1917
|
+
}
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
|
1921
|
+
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
|
1922
|
+
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
|
1923
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
if (readFromDiskFirst) {
|
|
1927
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
1928
|
+
}
|
|
1929
|
+
},
|
|
1930
|
+
[applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
|
|
1931
|
+
);
|
|
1932
|
+
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
|
1933
|
+
|
|
1934
|
+
const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
|
|
1935
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
|
|
1936
|
+
applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
|
|
1937
|
+
[applyStudioManualEditsToPreview],
|
|
1938
|
+
);
|
|
1939
|
+
|
|
1940
|
+
const applyCurrentStudioMotionToPreview = useCallback(
|
|
1941
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
1942
|
+
if (!iframe) return;
|
|
1943
|
+
let doc: Document | null = null;
|
|
1944
|
+
try {
|
|
1945
|
+
doc = iframe.contentDocument;
|
|
1946
|
+
} catch {
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
if (!doc) return;
|
|
1950
|
+
const previewDoc = doc;
|
|
1951
|
+
|
|
1952
|
+
const applyManifest = () => {
|
|
1953
|
+
applyStudioMotionManifest(
|
|
1954
|
+
previewDoc,
|
|
1955
|
+
studioMotionManifestRef.current,
|
|
1956
|
+
activeCompPathRef.current,
|
|
1957
|
+
);
|
|
1958
|
+
};
|
|
1959
|
+
const applyAndInstallSeekHooks = () => {
|
|
1960
|
+
applyManifest();
|
|
1961
|
+
if (iframe.contentWindow) {
|
|
1962
|
+
installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
const win = iframe.contentWindow;
|
|
1967
|
+
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
1968
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 120);
|
|
1969
|
+
},
|
|
1970
|
+
[],
|
|
1971
|
+
);
|
|
1972
|
+
|
|
1973
|
+
const applyStudioMotionToPreview = useCallback(
|
|
1974
|
+
async (
|
|
1975
|
+
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
1976
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
1977
|
+
) => {
|
|
1978
|
+
const readRevision = studioMotionRevisionRef.current;
|
|
1979
|
+
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
1980
|
+
if (!readFromDiskFirst) {
|
|
1981
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
1982
|
+
}
|
|
1983
|
+
let content: string;
|
|
1984
|
+
try {
|
|
1985
|
+
content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
1986
|
+
} catch (error) {
|
|
1987
|
+
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
|
|
1988
|
+
showToast(message);
|
|
1989
|
+
if (readFromDiskFirst) {
|
|
1990
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
1991
|
+
}
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
|
|
1995
|
+
studioMotionManifestRef.current = parseStudioMotionManifest(content);
|
|
1996
|
+
if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
|
|
1997
|
+
setStudioMotionRevision((revision) => revision + 1);
|
|
1998
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (readFromDiskFirst) {
|
|
2002
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
2003
|
+
}
|
|
2004
|
+
},
|
|
2005
|
+
[applyCurrentStudioMotionToPreview, readOptionalProjectFile, showToast],
|
|
2006
|
+
);
|
|
2007
|
+
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
|
|
2008
|
+
|
|
2009
|
+
const applyStudioMotionToPreviewAfterRefresh = useCallback(
|
|
2010
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
|
|
2011
|
+
applyStudioMotionToPreview(iframe, { readFromDiskFirst: true }),
|
|
2012
|
+
[applyStudioMotionToPreview],
|
|
2013
|
+
);
|
|
2014
|
+
|
|
2015
|
+
const commitStudioManualEditManifestOptimistically = useCallback(
|
|
2016
|
+
(
|
|
2017
|
+
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
|
2018
|
+
options: { label: string; coalesceKey: string },
|
|
2019
|
+
) => {
|
|
2020
|
+
const previousManifest = studioManualEditManifestRef.current;
|
|
2021
|
+
const nextManifest = updateManifest(previousManifest);
|
|
2022
|
+
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
|
2023
|
+
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
|
2024
|
+
if (nextContent === previousContent) {
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const revision = studioManualEditRevisionRef.current + 1;
|
|
2029
|
+
studioManualEditRevisionRef.current = revision;
|
|
2030
|
+
studioManualEditManifestRef.current = nextManifest;
|
|
2031
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2032
|
+
|
|
2033
|
+
const save = async () => {
|
|
2034
|
+
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
2035
|
+
const diskManifest = parseStudioManualEditManifest(originalContent);
|
|
2036
|
+
const nextDiskManifest = updateManifest(diskManifest);
|
|
2037
|
+
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
|
2038
|
+
if (nextDiskContent === originalContent) {
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const pid = projectIdRef.current;
|
|
2043
|
+
if (!pid) throw new Error("No active project");
|
|
2044
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2045
|
+
await saveProjectFilesWithHistory({
|
|
2046
|
+
projectId: pid,
|
|
2047
|
+
label: options.label,
|
|
2048
|
+
kind: "manual",
|
|
2049
|
+
coalesceKey: options.coalesceKey,
|
|
2050
|
+
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
|
2051
|
+
readFile: async () => originalContent,
|
|
2052
|
+
writeFile: writeProjectFile,
|
|
2053
|
+
recordEdit: editHistory.recordEdit,
|
|
2054
|
+
});
|
|
2055
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2056
|
+
|
|
2057
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
2058
|
+
studioManualEditManifestRef.current = nextDiskManifest;
|
|
2059
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2060
|
+
}
|
|
2061
|
+
};
|
|
2062
|
+
|
|
2063
|
+
void queueDomEditSave(save).catch((error) => {
|
|
2064
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
2065
|
+
studioManualEditRevisionRef.current += 1;
|
|
2066
|
+
studioManualEditManifestRef.current = previousManifest;
|
|
2067
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2068
|
+
}
|
|
2069
|
+
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
|
2070
|
+
showToast(message);
|
|
2071
|
+
});
|
|
2072
|
+
},
|
|
2073
|
+
[
|
|
2074
|
+
applyCurrentStudioManualEditsToPreview,
|
|
2075
|
+
editHistory.recordEdit,
|
|
2076
|
+
queueDomEditSave,
|
|
2077
|
+
readOptionalProjectFile,
|
|
2078
|
+
showToast,
|
|
2079
|
+
writeProjectFile,
|
|
2080
|
+
],
|
|
2081
|
+
);
|
|
2082
|
+
|
|
2083
|
+
const commitStudioMotionManifestOptimistically = useCallback(
|
|
2084
|
+
(
|
|
2085
|
+
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
|
2086
|
+
options: { label: string; coalesceKey: string },
|
|
2087
|
+
) => {
|
|
2088
|
+
const previousManifest = studioMotionManifestRef.current;
|
|
2089
|
+
const nextManifest = updateManifest(previousManifest);
|
|
2090
|
+
const previousContent = serializeStudioMotionManifest(previousManifest);
|
|
2091
|
+
const nextContent = serializeStudioMotionManifest(nextManifest);
|
|
2092
|
+
if (nextContent === previousContent) {
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const revision = studioMotionRevisionRef.current + 1;
|
|
2097
|
+
studioMotionRevisionRef.current = revision;
|
|
2098
|
+
studioMotionManifestRef.current = nextManifest;
|
|
2099
|
+
setStudioMotionRevision((current) => current + 1);
|
|
2100
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2101
|
+
|
|
2102
|
+
const save = async () => {
|
|
2103
|
+
const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
2104
|
+
const diskManifest = parseStudioMotionManifest(originalContent);
|
|
2105
|
+
const nextDiskManifest = updateManifest(diskManifest);
|
|
2106
|
+
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
|
|
2107
|
+
if (nextDiskContent === originalContent) {
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
const pid = projectIdRef.current;
|
|
2112
|
+
if (!pid) throw new Error("No active project");
|
|
2113
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2114
|
+
await saveProjectFilesWithHistory({
|
|
2115
|
+
projectId: pid,
|
|
2116
|
+
label: options.label,
|
|
2117
|
+
kind: "motion",
|
|
2118
|
+
coalesceKey: options.coalesceKey,
|
|
2119
|
+
files: { [STUDIO_MOTION_PATH]: nextDiskContent },
|
|
2120
|
+
readFile: async () => originalContent,
|
|
2121
|
+
writeFile: writeProjectFile,
|
|
2122
|
+
recordEdit: editHistory.recordEdit,
|
|
2123
|
+
});
|
|
2124
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2125
|
+
|
|
2126
|
+
if (studioMotionRevisionRef.current === revision) {
|
|
2127
|
+
studioMotionManifestRef.current = nextDiskManifest;
|
|
2128
|
+
setStudioMotionRevision((current) => current + 1);
|
|
2129
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
|
|
2133
|
+
void queueDomEditSave(save).catch((error) => {
|
|
2134
|
+
if (studioMotionRevisionRef.current === revision) {
|
|
2135
|
+
studioMotionRevisionRef.current += 1;
|
|
2136
|
+
studioMotionManifestRef.current = previousManifest;
|
|
2137
|
+
setStudioMotionRevision((current) => current + 1);
|
|
2138
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2139
|
+
}
|
|
2140
|
+
const message = error instanceof Error ? error.message : "Failed to save motion edit";
|
|
2141
|
+
showToast(message);
|
|
2142
|
+
});
|
|
2143
|
+
},
|
|
2144
|
+
[
|
|
2145
|
+
applyCurrentStudioMotionToPreview,
|
|
2146
|
+
editHistory.recordEdit,
|
|
2147
|
+
queueDomEditSave,
|
|
2148
|
+
readOptionalProjectFile,
|
|
2149
|
+
showToast,
|
|
2150
|
+
writeProjectFile,
|
|
2151
|
+
],
|
|
2152
|
+
);
|
|
2153
|
+
|
|
2154
|
+
const syncHistoryPreviewAfterApply = useCallback(
|
|
2155
|
+
async (paths: string[] | undefined) => {
|
|
2156
|
+
const changedPaths = paths ?? [];
|
|
2157
|
+
const manualManifestOnly =
|
|
2158
|
+
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
|
2159
|
+
const motionManifestOnly =
|
|
2160
|
+
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
|
|
2161
|
+
|
|
2162
|
+
if (manualManifestOnly) {
|
|
2163
|
+
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
if (motionManifestOnly) {
|
|
2167
|
+
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
setRefreshKey((key) => key + 1);
|
|
2172
|
+
},
|
|
2173
|
+
[applyStudioManualEditsToPreview, applyStudioMotionToPreview],
|
|
2174
|
+
);
|
|
2175
|
+
|
|
2176
|
+
const handleUndo = useCallback(async () => {
|
|
2177
|
+
await waitForPendingDomEditSaves();
|
|
2178
|
+
const result = await editHistory.undo({
|
|
2179
|
+
readFile: readHistoryProjectFile,
|
|
2180
|
+
writeFile: writeHistoryProjectFile,
|
|
2181
|
+
});
|
|
2182
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
2183
|
+
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (result.ok && result.label) {
|
|
2187
|
+
clearDomSelection();
|
|
2188
|
+
await syncHistoryPreviewAfterApply(result.paths);
|
|
2189
|
+
showToast(`Undid ${result.label}`, "info");
|
|
2190
|
+
}
|
|
2191
|
+
}, [
|
|
2192
|
+
clearDomSelection,
|
|
2193
|
+
editHistory,
|
|
2194
|
+
readHistoryProjectFile,
|
|
2195
|
+
showToast,
|
|
2196
|
+
syncHistoryPreviewAfterApply,
|
|
2197
|
+
waitForPendingDomEditSaves,
|
|
2198
|
+
writeHistoryProjectFile,
|
|
2199
|
+
]);
|
|
2200
|
+
|
|
2201
|
+
const handleRedo = useCallback(async () => {
|
|
2202
|
+
await waitForPendingDomEditSaves();
|
|
2203
|
+
const result = await editHistory.redo({
|
|
2204
|
+
readFile: readHistoryProjectFile,
|
|
2205
|
+
writeFile: writeHistoryProjectFile,
|
|
2206
|
+
});
|
|
2207
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
2208
|
+
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
if (result.ok && result.label) {
|
|
2212
|
+
clearDomSelection();
|
|
2213
|
+
await syncHistoryPreviewAfterApply(result.paths);
|
|
2214
|
+
showToast(`Redid ${result.label}`, "info");
|
|
2215
|
+
}
|
|
2216
|
+
}, [
|
|
2217
|
+
clearDomSelection,
|
|
2218
|
+
editHistory,
|
|
2219
|
+
readHistoryProjectFile,
|
|
2220
|
+
showToast,
|
|
2221
|
+
syncHistoryPreviewAfterApply,
|
|
2222
|
+
waitForPendingDomEditSaves,
|
|
2223
|
+
writeHistoryProjectFile,
|
|
2224
|
+
]);
|
|
2225
|
+
|
|
2226
|
+
const handleUndoRef = useRef(handleUndo);
|
|
2227
|
+
const handleRedoRef = useRef(handleRedo);
|
|
2228
|
+
handleUndoRef.current = handleUndo;
|
|
2229
|
+
handleRedoRef.current = handleRedo;
|
|
2230
|
+
|
|
2231
|
+
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
|
2232
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
2233
|
+
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
|
2234
|
+
const key = event.key.toLowerCase();
|
|
2235
|
+
if (key === "z" && !event.shiftKey) {
|
|
2236
|
+
event.preventDefault();
|
|
2237
|
+
void handleUndoRef.current();
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
|
2241
|
+
event.preventDefault();
|
|
2242
|
+
void handleRedoRef.current();
|
|
2243
|
+
}
|
|
2244
|
+
}, []);
|
|
2245
|
+
|
|
2246
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2247
|
+
useEffect(() => {
|
|
2248
|
+
window.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2249
|
+
return () => window.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2250
|
+
}, [handleHistoryHotkey]);
|
|
2251
|
+
|
|
2252
|
+
const syncPreviewHistoryHotkey = useCallback(
|
|
2253
|
+
(iframe: HTMLIFrameElement | null) => {
|
|
2254
|
+
previewHistoryHotkeyCleanupRef.current?.();
|
|
2255
|
+
previewHistoryHotkeyCleanupRef.current = null;
|
|
2256
|
+
|
|
2257
|
+
const win = iframe?.contentWindow ?? null;
|
|
2258
|
+
let doc: Document | null = null;
|
|
2259
|
+
try {
|
|
2260
|
+
doc = iframe?.contentDocument ?? null;
|
|
2261
|
+
} catch {
|
|
2262
|
+
doc = null;
|
|
2263
|
+
}
|
|
2264
|
+
if (!win && !doc) return;
|
|
2265
|
+
|
|
2266
|
+
win?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2267
|
+
doc?.addEventListener("keydown", handleHistoryHotkey, true);
|
|
2268
|
+
previewHistoryHotkeyCleanupRef.current = () => {
|
|
2269
|
+
win?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2270
|
+
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
2271
|
+
};
|
|
2272
|
+
},
|
|
2273
|
+
[handleHistoryHotkey],
|
|
2274
|
+
);
|
|
2275
|
+
|
|
2276
|
+
useEffect(
|
|
2277
|
+
() => () => {
|
|
2278
|
+
previewHistoryHotkeyCleanupRef.current?.();
|
|
2279
|
+
previewHistoryHotkeyCleanupRef.current = null;
|
|
2280
|
+
},
|
|
2281
|
+
[],
|
|
2282
|
+
);
|
|
2283
|
+
|
|
2284
|
+
const buildDomSelectionFromTarget = useCallback(
|
|
2285
|
+
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
2286
|
+
return resolveDomEditSelection(target, {
|
|
2287
|
+
activeCompositionPath: activeCompPath,
|
|
2288
|
+
isMasterView,
|
|
2289
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
2290
|
+
});
|
|
2291
|
+
},
|
|
2292
|
+
[activeCompPath, isMasterView],
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
const resolveDomSelectionFromPreviewPoint = useCallback(
|
|
2296
|
+
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
|
|
2297
|
+
const iframe = previewIframeRef.current;
|
|
2298
|
+
if (!iframe || captionEditMode) return null;
|
|
2299
|
+
const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
|
|
2300
|
+
if (!target) return null;
|
|
2301
|
+
return buildDomSelectionFromTarget(target, {
|
|
2302
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
2303
|
+
});
|
|
2304
|
+
},
|
|
2305
|
+
[buildDomSelectionFromTarget, captionEditMode],
|
|
2306
|
+
);
|
|
2307
|
+
|
|
2308
|
+
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
|
|
2309
|
+
if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
|
|
2310
|
+
domEditHoverSelectionRef.current = selection;
|
|
2311
|
+
setDomEditHoverSelection(selection);
|
|
2312
|
+
}, []);
|
|
2313
|
+
|
|
2314
|
+
const buildDomSelectionForTimelineElement = useCallback(
|
|
2315
|
+
(element: TimelineElement): DomEditSelection | null => {
|
|
2316
|
+
const iframe = previewIframeRef.current;
|
|
2317
|
+
let doc: Document | null = null;
|
|
2318
|
+
try {
|
|
2319
|
+
doc = iframe?.contentDocument ?? null;
|
|
2320
|
+
} catch {
|
|
2321
|
+
return null;
|
|
2322
|
+
}
|
|
2323
|
+
if (!doc) return null;
|
|
2324
|
+
|
|
2325
|
+
const targetElement = findElementForTimelineElement(doc, element, {
|
|
2326
|
+
activeCompositionPath: activeCompPath,
|
|
2327
|
+
compIdToSrc,
|
|
2328
|
+
isMasterView,
|
|
2329
|
+
});
|
|
2330
|
+
return targetElement
|
|
2331
|
+
? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
|
|
2332
|
+
: null;
|
|
2333
|
+
},
|
|
2334
|
+
[activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView],
|
|
2335
|
+
);
|
|
2336
|
+
|
|
2337
|
+
const inspectedTimelineElement = useMemo(
|
|
2338
|
+
() =>
|
|
2339
|
+
timelineElements.find(
|
|
2340
|
+
(element) => getTimelineElementKey(element) === inspectedTimelineElementId,
|
|
2341
|
+
) ?? null,
|
|
2342
|
+
[inspectedTimelineElementId, timelineElements],
|
|
2343
|
+
);
|
|
2344
|
+
|
|
2345
|
+
const timelineLayerChildCounts = useMemo(() => {
|
|
2346
|
+
void previewDocumentVersion;
|
|
2347
|
+
const counts = new Map<string, number>();
|
|
2348
|
+
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return counts;
|
|
2349
|
+
|
|
2350
|
+
const key = getTimelineElementKey(inspectedTimelineElement);
|
|
2351
|
+
if (key) {
|
|
2352
|
+
const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
|
|
2353
|
+
const count = countDomEditChildLayers(selection?.element, {
|
|
2354
|
+
activeCompositionPath: activeCompPath,
|
|
2355
|
+
isMasterView,
|
|
2356
|
+
});
|
|
2357
|
+
if (count > 0) counts.set(key, count);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
return counts;
|
|
2361
|
+
}, [
|
|
2362
|
+
activeCompPath,
|
|
2363
|
+
buildDomSelectionForTimelineElement,
|
|
2364
|
+
inspectedTimelineElement,
|
|
2365
|
+
isMasterView,
|
|
2366
|
+
previewDocumentVersion,
|
|
2367
|
+
]);
|
|
2368
|
+
|
|
2369
|
+
const inspectedTimelineLayers = useMemo(() => {
|
|
2370
|
+
void previewDocumentVersion;
|
|
2371
|
+
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return [];
|
|
2372
|
+
const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
|
|
2373
|
+
return collectDomEditLayerItems(selection?.element, {
|
|
2374
|
+
activeCompositionPath: activeCompPath,
|
|
2375
|
+
isMasterView,
|
|
2376
|
+
});
|
|
2377
|
+
}, [
|
|
2378
|
+
activeCompPath,
|
|
2379
|
+
buildDomSelectionForTimelineElement,
|
|
2380
|
+
inspectedTimelineElement,
|
|
2381
|
+
isMasterView,
|
|
2382
|
+
previewDocumentVersion,
|
|
2383
|
+
]);
|
|
2384
|
+
|
|
2385
|
+
const selectedTimelineLayerKey = useMemo(
|
|
2386
|
+
() => (domEditSelection ? getDomEditLayerKey(domEditSelection) : null),
|
|
2387
|
+
[domEditSelection],
|
|
2388
|
+
);
|
|
2389
|
+
|
|
2390
|
+
const handleTimelineElementSelect = useCallback(
|
|
2391
|
+
(element: TimelineElement | null) => {
|
|
2392
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2393
|
+
if (!element) {
|
|
2394
|
+
applyDomSelection(null, { revealPanel: false });
|
|
2395
|
+
setInspectedTimelineElementId(null);
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const selection = buildDomSelectionForTimelineElement(element);
|
|
2400
|
+
if (selection) applyDomSelection(selection);
|
|
2401
|
+
},
|
|
2402
|
+
[applyDomSelection, buildDomSelectionForTimelineElement],
|
|
2403
|
+
);
|
|
2404
|
+
|
|
2405
|
+
const handleTimelineElementInspect = useCallback(
|
|
2406
|
+
(element: TimelineElement) => {
|
|
2407
|
+
if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2408
|
+
if (!canInspectTimelineElement(element)) {
|
|
2409
|
+
showToast("Audio clips do not have visual layers.", "info");
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
const key = getTimelineElementKey(element);
|
|
2414
|
+
if (!key) return;
|
|
2415
|
+
setInspectedTimelineElementId((current) => (current === key ? null : key));
|
|
2416
|
+
setLeftCollapsed(false);
|
|
2417
|
+
|
|
2418
|
+
const iframe = previewIframeRef.current;
|
|
2419
|
+
if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
|
|
2420
|
+
seekStudioPreview(iframe, element.start);
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
const selection = buildDomSelectionForTimelineElement(element);
|
|
2424
|
+
if (selection) applyDomSelection(selection);
|
|
2425
|
+
},
|
|
2426
|
+
[applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
|
|
2427
|
+
);
|
|
2428
|
+
|
|
2429
|
+
const handleToggleTimelineElementThumbnail = useCallback((element: TimelineElement) => {
|
|
2430
|
+
const key = getTimelineElementKey(element);
|
|
2431
|
+
if (!key) return;
|
|
2432
|
+
setThumbnailedTimelineElementIds((current) => {
|
|
2433
|
+
const next = new Set(current);
|
|
2434
|
+
if (next.has(key)) next.delete(key);
|
|
2435
|
+
else next.add(key);
|
|
2436
|
+
return next;
|
|
2437
|
+
});
|
|
2438
|
+
}, []);
|
|
2439
|
+
|
|
2440
|
+
const handleTimelineLayerSelect = useCallback(
|
|
2441
|
+
(layer: DomEditLayerItem) => {
|
|
2442
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
2443
|
+
|
|
2444
|
+
const iframe = previewIframeRef.current;
|
|
2445
|
+
const player = getPreviewPlayer(iframe?.contentWindow);
|
|
2446
|
+
const visibleTime = resolveLayerVisibleSeekTime(
|
|
2447
|
+
layer.element,
|
|
2448
|
+
inspectedTimelineElement,
|
|
2449
|
+
player,
|
|
2450
|
+
);
|
|
2451
|
+
if (visibleTime != null) {
|
|
2452
|
+
seekStudioPreview(iframe, visibleTime);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
const selection = buildDomSelectionFromTarget(layer.element, { preferClipAncestor: false });
|
|
2456
|
+
if (!selection) {
|
|
2457
|
+
showToast("Studio could not resolve this nested layer.", "error");
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
applyDomSelection(selection);
|
|
2462
|
+
requestAnimationFrame(refreshPreviewDocumentVersion);
|
|
2463
|
+
},
|
|
2464
|
+
[
|
|
2465
|
+
applyDomSelection,
|
|
2466
|
+
buildDomSelectionFromTarget,
|
|
2467
|
+
inspectedTimelineElement,
|
|
2468
|
+
refreshPreviewDocumentVersion,
|
|
2469
|
+
showToast,
|
|
2470
|
+
],
|
|
2471
|
+
);
|
|
2472
|
+
|
|
2473
|
+
const handleTimelineLayerPanelClose = useCallback(() => {
|
|
2474
|
+
setInspectedTimelineElementId(null);
|
|
2475
|
+
}, []);
|
|
2476
|
+
|
|
2477
|
+
const preloadAgentPromptSnippet = useCallback(
|
|
2478
|
+
async (selection: DomEditSelection) => {
|
|
2479
|
+
const pid = projectIdRef.current;
|
|
2480
|
+
if (!pid) return;
|
|
2481
|
+
|
|
2482
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
2483
|
+
try {
|
|
2484
|
+
const response = await fetch(
|
|
2485
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
2486
|
+
);
|
|
2487
|
+
if (!response.ok) return;
|
|
2488
|
+
|
|
2489
|
+
const data = (await response.json()) as { content?: string };
|
|
2490
|
+
const html = data.content;
|
|
2491
|
+
const tagSnippet =
|
|
2492
|
+
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
|
2493
|
+
|
|
2494
|
+
setAgentPromptTagSnippet((current) => {
|
|
2495
|
+
if (domEditSelectionRef.current !== selection) return current;
|
|
2496
|
+
return tagSnippet;
|
|
2497
|
+
});
|
|
2498
|
+
} catch {
|
|
2499
|
+
// Runtime outerHTML is still available as a synchronous copy fallback.
|
|
2500
|
+
}
|
|
2501
|
+
},
|
|
2502
|
+
[activeCompPath],
|
|
2503
|
+
);
|
|
2504
|
+
|
|
2505
|
+
const resolveImportedFontAsset = useCallback(
|
|
2506
|
+
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
2507
|
+
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
2508
|
+
if (!family) return null;
|
|
2509
|
+
const imported = importedFontAssetsRef.current.find(
|
|
2510
|
+
(font) => font.family.toLowerCase() === family.toLowerCase(),
|
|
2511
|
+
);
|
|
2512
|
+
if (imported) return imported;
|
|
2513
|
+
const asset = fileTree.find(
|
|
2514
|
+
(path) =>
|
|
2515
|
+
FONT_EXT.test(path) &&
|
|
2516
|
+
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
|
|
2517
|
+
);
|
|
2518
|
+
if (!asset) return null;
|
|
2519
|
+
return {
|
|
2520
|
+
family: fontFamilyFromAssetPath(asset),
|
|
2521
|
+
path: asset,
|
|
2522
|
+
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
2523
|
+
};
|
|
2524
|
+
},
|
|
2525
|
+
[fileTree, projectId],
|
|
2526
|
+
);
|
|
2527
|
+
|
|
2528
|
+
const persistDomEditOperations = useCallback(
|
|
2529
|
+
async (
|
|
2530
|
+
selection: DomEditSelection,
|
|
2531
|
+
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
2532
|
+
options?: {
|
|
2533
|
+
label?: string;
|
|
2534
|
+
coalesceKey?: string;
|
|
2535
|
+
skipRefresh?: boolean;
|
|
2536
|
+
prepareContent?: (html: string, sourceFile: string) => string;
|
|
2537
|
+
shouldSave?: () => boolean;
|
|
2538
|
+
},
|
|
2539
|
+
) => {
|
|
2540
|
+
const pid = projectIdRef.current;
|
|
2541
|
+
if (!pid) throw new Error("No active project");
|
|
2542
|
+
if (options?.shouldSave && !options.shouldSave()) return;
|
|
2543
|
+
|
|
2544
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
2545
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
2546
|
+
if (!response.ok) {
|
|
2547
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
const data = (await response.json()) as { content?: string };
|
|
2551
|
+
const originalContent = data.content;
|
|
2552
|
+
if (typeof originalContent !== "string") {
|
|
2553
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
let patchedContent = originalContent;
|
|
2557
|
+
for (const operation of operations) {
|
|
2558
|
+
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
|
2559
|
+
}
|
|
2560
|
+
if (options?.prepareContent) {
|
|
2561
|
+
patchedContent = options.prepareContent(patchedContent, targetPath);
|
|
2562
|
+
}
|
|
2563
|
+
if (options?.shouldSave && !options.shouldSave()) return;
|
|
2564
|
+
|
|
2565
|
+
if (patchedContent === originalContent) {
|
|
2566
|
+
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
await saveProjectFilesWithHistory({
|
|
2570
|
+
projectId: pid,
|
|
2571
|
+
label: options?.label ?? "Edit layer",
|
|
2572
|
+
kind: "manual",
|
|
2573
|
+
coalesceKey: options?.coalesceKey,
|
|
2574
|
+
files: { [targetPath]: patchedContent },
|
|
2575
|
+
readFile: async () => originalContent,
|
|
2576
|
+
writeFile: writeProjectFile,
|
|
2577
|
+
recordEdit: editHistory.recordEdit,
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
if (options?.skipRefresh) {
|
|
2581
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
2582
|
+
} else {
|
|
2583
|
+
setRefreshKey((k) => k + 1);
|
|
2584
|
+
}
|
|
2585
|
+
},
|
|
2586
|
+
[activeCompPath, editHistory.recordEdit, writeProjectFile],
|
|
2587
|
+
);
|
|
2588
|
+
|
|
2589
|
+
const refreshDomEditSelectionFromPreview = useCallback(
|
|
2590
|
+
(selection: DomEditSelection) => {
|
|
2591
|
+
const iframe = previewIframeRef.current;
|
|
2592
|
+
let doc: Document | null = null;
|
|
2593
|
+
try {
|
|
2594
|
+
doc = iframe?.contentDocument ?? null;
|
|
2595
|
+
} catch {
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
if (!doc) return;
|
|
2599
|
+
|
|
2600
|
+
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2601
|
+
if (!element) return;
|
|
2602
|
+
|
|
2603
|
+
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2604
|
+
if (nextSelection) {
|
|
2605
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2606
|
+
}
|
|
2607
|
+
},
|
|
2608
|
+
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
|
|
2609
|
+
);
|
|
2610
|
+
|
|
2611
|
+
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
|
2612
|
+
(selections: DomEditSelection[]) => {
|
|
2613
|
+
const iframe = previewIframeRef.current;
|
|
2614
|
+
let doc: Document | null = null;
|
|
2615
|
+
try {
|
|
2616
|
+
doc = iframe?.contentDocument ?? null;
|
|
2617
|
+
} catch {
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
if (!doc) return;
|
|
2621
|
+
|
|
2622
|
+
const nextGroup: DomEditSelection[] = [];
|
|
2623
|
+
for (const selection of selections) {
|
|
2624
|
+
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
2625
|
+
if (!element) continue;
|
|
2626
|
+
const nextSelection = buildDomSelectionFromTarget(element);
|
|
2627
|
+
if (nextSelection) nextGroup.push(nextSelection);
|
|
2628
|
+
}
|
|
2629
|
+
if (nextGroup.length === 0) return;
|
|
2630
|
+
|
|
2631
|
+
const currentSelection = domEditSelectionRef.current;
|
|
2632
|
+
const nextSelection =
|
|
2633
|
+
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
|
2634
|
+
nextGroup[0] ??
|
|
2635
|
+
null;
|
|
2636
|
+
|
|
2637
|
+
setAgentPromptTagSnippet(undefined);
|
|
2638
|
+
setCopiedAgentPrompt(false);
|
|
2639
|
+
domEditSelectionRef.current = nextSelection;
|
|
2640
|
+
domEditGroupSelectionsRef.current = nextGroup;
|
|
2641
|
+
setDomEditSelection(nextSelection);
|
|
2642
|
+
setDomEditGroupSelections(nextGroup);
|
|
2643
|
+
|
|
2644
|
+
if (nextSelection) {
|
|
2645
|
+
setSelectedTimelineElementId(
|
|
2646
|
+
findMatchingTimelineElementId(nextSelection, timelineElements),
|
|
2647
|
+
);
|
|
2648
|
+
} else {
|
|
2649
|
+
setSelectedTimelineElementId(null);
|
|
2650
|
+
}
|
|
2651
|
+
},
|
|
2652
|
+
[activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
|
|
2653
|
+
);
|
|
2654
|
+
|
|
2655
|
+
const handleDomManualDragStart = useCallback(() => {
|
|
2656
|
+
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
|
|
2657
|
+
const playerStore = usePlayerStore.getState();
|
|
2658
|
+
playerStore.setIsPlaying(false);
|
|
2659
|
+
if (pausedTime != null) {
|
|
2660
|
+
playerStore.setCurrentTime(pausedTime);
|
|
2661
|
+
liveTime.notify(pausedTime);
|
|
2662
|
+
}
|
|
2663
|
+
}, []);
|
|
2664
|
+
|
|
2665
|
+
const handleDomPathOffsetCommit = useCallback(
|
|
2666
|
+
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
2667
|
+
commitStudioManualEditManifestOptimistically(
|
|
2668
|
+
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
|
|
2669
|
+
{
|
|
2670
|
+
label: "Move layer",
|
|
2671
|
+
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
2672
|
+
},
|
|
2673
|
+
);
|
|
2674
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2675
|
+
},
|
|
2676
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2677
|
+
);
|
|
2678
|
+
|
|
2679
|
+
const handleDomGroupPathOffsetCommit = useCallback(
|
|
2680
|
+
(updates: DomEditGroupPathOffsetCommit[]) => {
|
|
2681
|
+
if (updates.length === 0) return;
|
|
2682
|
+
const coalesceKey = updates
|
|
2683
|
+
.map((update) => getDomEditTargetKey(update.selection))
|
|
2684
|
+
.sort()
|
|
2685
|
+
.join(":");
|
|
2686
|
+
commitStudioManualEditManifestOptimistically(
|
|
2687
|
+
(manifest) =>
|
|
2688
|
+
updates.reduce(
|
|
2689
|
+
(nextManifest, update) =>
|
|
2690
|
+
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
|
|
2691
|
+
manifest,
|
|
2692
|
+
),
|
|
2693
|
+
{
|
|
2694
|
+
label: `Move ${updates.length} layers`,
|
|
2695
|
+
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
2696
|
+
},
|
|
2697
|
+
);
|
|
2698
|
+
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
|
|
2699
|
+
},
|
|
2700
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
|
|
2701
|
+
);
|
|
2702
|
+
|
|
2703
|
+
const handleDomBoxSizeCommit = useCallback(
|
|
2704
|
+
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
2705
|
+
commitStudioManualEditManifestOptimistically(
|
|
2706
|
+
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
|
|
2707
|
+
{
|
|
2708
|
+
label: "Resize layer box",
|
|
2709
|
+
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
2710
|
+
},
|
|
2711
|
+
);
|
|
2712
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2713
|
+
},
|
|
2714
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2715
|
+
);
|
|
2716
|
+
|
|
2717
|
+
const handleDomRotationCommit = useCallback(
|
|
2718
|
+
(selection: DomEditSelection, next: { angle: number }) => {
|
|
2719
|
+
commitStudioManualEditManifestOptimistically(
|
|
2720
|
+
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
|
|
2721
|
+
{
|
|
2722
|
+
label: "Rotate layer",
|
|
2723
|
+
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
2724
|
+
},
|
|
2725
|
+
);
|
|
2726
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2727
|
+
},
|
|
2728
|
+
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2729
|
+
);
|
|
2730
|
+
|
|
2731
|
+
const handleDomManualEditsReset = useCallback(
|
|
2732
|
+
(selection: DomEditSelection) => {
|
|
2733
|
+
commitStudioManualEditManifestOptimistically(
|
|
2734
|
+
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
|
|
2735
|
+
{
|
|
2736
|
+
label: "Reset layer edits",
|
|
2737
|
+
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
|
2738
|
+
},
|
|
2739
|
+
);
|
|
2740
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
2741
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2742
|
+
},
|
|
2743
|
+
[
|
|
2744
|
+
applyCurrentStudioManualEditsToPreview,
|
|
2745
|
+
commitStudioManualEditManifestOptimistically,
|
|
2746
|
+
refreshDomEditSelectionFromPreview,
|
|
2747
|
+
],
|
|
2748
|
+
);
|
|
2749
|
+
|
|
2750
|
+
const handleDomMotionCommit = useCallback(
|
|
2751
|
+
(
|
|
2752
|
+
selection: DomEditSelection,
|
|
2753
|
+
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
|
2754
|
+
) => {
|
|
2755
|
+
commitStudioMotionManifestOptimistically(
|
|
2756
|
+
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
|
|
2757
|
+
{
|
|
2758
|
+
label: "Set GSAP motion",
|
|
2759
|
+
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
2760
|
+
},
|
|
2761
|
+
);
|
|
2762
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2763
|
+
},
|
|
2764
|
+
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
|
|
2765
|
+
);
|
|
2766
|
+
|
|
2767
|
+
const handleDomMotionClear = useCallback(
|
|
2768
|
+
(selection: DomEditSelection) => {
|
|
2769
|
+
commitStudioMotionManifestOptimistically(
|
|
2770
|
+
(manifest) => removeStudioMotionForSelection(manifest, selection),
|
|
2771
|
+
{
|
|
2772
|
+
label: "Clear GSAP motion",
|
|
2773
|
+
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
2774
|
+
},
|
|
2775
|
+
);
|
|
2776
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
2777
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
2778
|
+
},
|
|
2779
|
+
[
|
|
2780
|
+
applyCurrentStudioMotionToPreview,
|
|
2781
|
+
commitStudioMotionManifestOptimistically,
|
|
2782
|
+
refreshDomEditSelectionFromPreview,
|
|
2783
|
+
],
|
|
2784
|
+
);
|
|
2785
|
+
|
|
2786
|
+
const handleDomStyleCommit = useCallback(
|
|
2787
|
+
async (property: string, value: string) => {
|
|
2788
|
+
if (!domEditSelection) return;
|
|
2789
|
+
if (isManualGeometryStyleProperty(property)) return;
|
|
2790
|
+
if (!domEditSelection.capabilities.canEditStyles) return;
|
|
2791
|
+
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
2792
|
+
const iframe = previewIframeRef.current;
|
|
2793
|
+
const doc = iframe?.contentDocument;
|
|
2794
|
+
if (doc) {
|
|
2795
|
+
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
2796
|
+
if (el) {
|
|
2797
|
+
el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
|
|
2798
|
+
if (property === "font-family") {
|
|
2799
|
+
injectPreviewGoogleFont(doc, value);
|
|
2800
|
+
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
2801
|
+
}
|
|
2802
|
+
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
2803
|
+
el.style.setProperty("background-position", "center");
|
|
2804
|
+
el.style.setProperty("background-repeat", "no-repeat");
|
|
2805
|
+
el.style.setProperty("background-size", "contain");
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
const operations: PatchOperation[] = [
|
|
2810
|
+
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
|
2811
|
+
];
|
|
2812
|
+
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
2813
|
+
operations.push(
|
|
2814
|
+
buildDomEditStylePatchOperation("background-position", "center"),
|
|
2815
|
+
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
|
2816
|
+
buildDomEditStylePatchOperation("background-size", "contain"),
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
await persistDomEditOperations(domEditSelection, operations, {
|
|
2820
|
+
label: "Edit layer style",
|
|
2821
|
+
skipRefresh: true,
|
|
2822
|
+
prepareContent: importedFont
|
|
2823
|
+
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
2824
|
+
: undefined,
|
|
2825
|
+
});
|
|
2826
|
+
},
|
|
2827
|
+
[activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
|
|
2828
|
+
);
|
|
2829
|
+
|
|
2830
|
+
const handleDomTextCommit = useCallback(
|
|
2831
|
+
async (value: string, fieldKey?: string) => {
|
|
2832
|
+
if (!domEditSelection) return;
|
|
2833
|
+
if (!isTextEditableSelection(domEditSelection)) return;
|
|
2834
|
+
const commitVersion = domTextCommitVersionRef.current + 1;
|
|
2835
|
+
domTextCommitVersionRef.current = commitVersion;
|
|
2836
|
+
const nextTextFields =
|
|
2837
|
+
domEditSelection.textFields.length > 0
|
|
2838
|
+
? domEditSelection.textFields.map((field) =>
|
|
2839
|
+
field.key === fieldKey ? { ...field, value } : field,
|
|
2840
|
+
)
|
|
2841
|
+
: [];
|
|
2842
|
+
const nextContent =
|
|
2843
|
+
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
2844
|
+
? serializeDomEditTextFields(nextTextFields)
|
|
2845
|
+
: value;
|
|
2846
|
+
const iframe = previewIframeRef.current;
|
|
2847
|
+
const doc = iframe?.contentDocument;
|
|
2848
|
+
if (doc) {
|
|
2849
|
+
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
2850
|
+
if (el) {
|
|
2851
|
+
if (
|
|
2852
|
+
nextTextFields.length > 1 ||
|
|
2853
|
+
nextTextFields.some((field) => field.source === "child")
|
|
2854
|
+
) {
|
|
2855
|
+
el.innerHTML = nextContent;
|
|
2856
|
+
} else {
|
|
2857
|
+
el.textContent = value;
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
await persistDomEditOperations(
|
|
2862
|
+
domEditSelection,
|
|
2863
|
+
[buildDomEditTextPatchOperation(nextContent)],
|
|
2864
|
+
{
|
|
2865
|
+
label: "Edit text",
|
|
2866
|
+
skipRefresh: true,
|
|
2867
|
+
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
2868
|
+
},
|
|
2869
|
+
);
|
|
2870
|
+
if (domTextCommitVersionRef.current !== commitVersion) return;
|
|
2871
|
+
|
|
2872
|
+
if (doc) {
|
|
2873
|
+
const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
|
|
2874
|
+
if (refreshed) {
|
|
2875
|
+
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
2876
|
+
if (nextSelection) {
|
|
2877
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
},
|
|
2882
|
+
[
|
|
2883
|
+
activeCompPath,
|
|
2884
|
+
applyDomSelection,
|
|
2885
|
+
buildDomSelectionFromTarget,
|
|
2886
|
+
domEditSelection,
|
|
2887
|
+
persistDomEditOperations,
|
|
2888
|
+
],
|
|
2889
|
+
);
|
|
2890
|
+
|
|
2891
|
+
const commitDomTextFields = useCallback(
|
|
2892
|
+
async (
|
|
2893
|
+
selection: DomEditSelection,
|
|
2894
|
+
nextTextFields: DomEditTextField[],
|
|
2895
|
+
options?: { importedFont?: ImportedFontAsset | null },
|
|
2896
|
+
) => {
|
|
2897
|
+
const nextContent =
|
|
2898
|
+
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
2899
|
+
? serializeDomEditTextFields(nextTextFields)
|
|
2900
|
+
: (nextTextFields[0]?.value ?? "");
|
|
2901
|
+
|
|
2902
|
+
const iframe = previewIframeRef.current;
|
|
2903
|
+
const doc = iframe?.contentDocument;
|
|
2904
|
+
if (doc) {
|
|
2905
|
+
const el = findElementForSelection(doc, selection, activeCompPath);
|
|
2906
|
+
if (el) {
|
|
2907
|
+
if (
|
|
2908
|
+
nextTextFields.length > 1 ||
|
|
2909
|
+
nextTextFields.some((field) => field.source === "child")
|
|
2910
|
+
) {
|
|
2911
|
+
el.innerHTML = nextContent;
|
|
2912
|
+
} else {
|
|
2913
|
+
el.textContent = nextContent;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
const importedFont = options?.importedFont ?? null;
|
|
2919
|
+
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
|
2920
|
+
label: "Edit text",
|
|
2921
|
+
skipRefresh: true,
|
|
2922
|
+
prepareContent: importedFont
|
|
2923
|
+
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
2924
|
+
: undefined,
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
if (doc) {
|
|
2928
|
+
const refreshed = findElementForSelection(doc, selection, activeCompPath);
|
|
2929
|
+
if (refreshed) {
|
|
2930
|
+
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
2931
|
+
if (nextSelection) {
|
|
2932
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
},
|
|
2937
|
+
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
|
|
2938
|
+
);
|
|
2939
|
+
|
|
2940
|
+
const handleDomTextFieldStyleCommit = useCallback(
|
|
2941
|
+
async (fieldKey: string, property: string, value: string) => {
|
|
2942
|
+
if (!domEditSelection) return;
|
|
2943
|
+
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
2944
|
+
if (!field) return;
|
|
2945
|
+
|
|
2946
|
+
if (field.source === "self") {
|
|
2947
|
+
await handleDomStyleCommit(property, value);
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
const normalizedValue = normalizeDomEditStyleValue(property, value);
|
|
2952
|
+
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
2953
|
+
if (property === "font-family") {
|
|
2954
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
2955
|
+
if (doc) {
|
|
2956
|
+
injectPreviewGoogleFont(doc, normalizedValue);
|
|
2957
|
+
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
const nextTextFields = domEditSelection.textFields.map((entry) =>
|
|
2961
|
+
entry.key === fieldKey
|
|
2962
|
+
? {
|
|
2963
|
+
...entry,
|
|
2964
|
+
inlineStyles: {
|
|
2965
|
+
...entry.inlineStyles,
|
|
2966
|
+
[property]: normalizedValue,
|
|
2967
|
+
},
|
|
2968
|
+
computedStyles: {
|
|
2969
|
+
...entry.computedStyles,
|
|
2970
|
+
[property]: normalizedValue,
|
|
2971
|
+
},
|
|
2972
|
+
}
|
|
2973
|
+
: entry,
|
|
2974
|
+
);
|
|
2975
|
+
|
|
2976
|
+
await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
|
|
2977
|
+
},
|
|
2978
|
+
[commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
|
|
2979
|
+
);
|
|
2980
|
+
|
|
2981
|
+
const handleDomAddTextField = useCallback(
|
|
2982
|
+
async (afterFieldKey?: string) => {
|
|
2983
|
+
if (!domEditSelection) return null;
|
|
2984
|
+
if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
|
|
2985
|
+
|
|
2986
|
+
const insertionIndex = domEditSelection.textFields.findIndex(
|
|
2987
|
+
(field) => field.key === afterFieldKey,
|
|
2988
|
+
);
|
|
2989
|
+
const baseField =
|
|
2990
|
+
domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
|
|
2991
|
+
domEditSelection.textFields[0];
|
|
2992
|
+
const nextField = buildDefaultDomEditTextField(baseField);
|
|
2993
|
+
const nextTextFields = [...domEditSelection.textFields];
|
|
2994
|
+
nextTextFields.splice(
|
|
2995
|
+
insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
|
|
2996
|
+
0,
|
|
2997
|
+
nextField,
|
|
2998
|
+
);
|
|
2999
|
+
|
|
3000
|
+
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
3001
|
+
return nextField.key;
|
|
3002
|
+
},
|
|
3003
|
+
[commitDomTextFields, domEditSelection],
|
|
3004
|
+
);
|
|
3005
|
+
|
|
3006
|
+
const handleDomRemoveTextField = useCallback(
|
|
3007
|
+
async (fieldKey: string) => {
|
|
3008
|
+
if (!domEditSelection) return;
|
|
3009
|
+
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
3010
|
+
if (!field) return;
|
|
3011
|
+
|
|
3012
|
+
if (field.source === "self") {
|
|
3013
|
+
await handleDomTextCommit("", fieldKey);
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
|
|
3018
|
+
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
3019
|
+
},
|
|
3020
|
+
[commitDomTextFields, domEditSelection, handleDomTextCommit],
|
|
3021
|
+
);
|
|
3022
|
+
|
|
3023
|
+
const handleAskAgent = useCallback(() => {
|
|
3024
|
+
if (!domEditSelection) return;
|
|
3025
|
+
setAgentPromptTagSnippet(undefined);
|
|
3026
|
+
void preloadAgentPromptSnippet(domEditSelection);
|
|
3027
|
+
setAgentModalOpen(true);
|
|
3028
|
+
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
3029
|
+
|
|
3030
|
+
const handleAgentModalSubmit = useCallback(
|
|
3031
|
+
async (userInstruction: string) => {
|
|
3032
|
+
if (!domEditSelection) return;
|
|
3033
|
+
|
|
3034
|
+
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
3035
|
+
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
|
3036
|
+
const prompt = buildElementAgentPrompt({
|
|
3037
|
+
selection: domEditSelection,
|
|
3038
|
+
currentTime,
|
|
3039
|
+
tagSnippet,
|
|
3040
|
+
userInstruction,
|
|
3041
|
+
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
const copied = await copyTextToClipboard(prompt);
|
|
3045
|
+
if (!copied) {
|
|
3046
|
+
showToast("Could not copy prompt to clipboard.", "error");
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
setAgentModalOpen(false);
|
|
3051
|
+
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
3052
|
+
setCopiedAgentPrompt(true);
|
|
3053
|
+
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
3054
|
+
},
|
|
3055
|
+
[activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
|
|
3056
|
+
);
|
|
3057
|
+
|
|
3058
|
+
const handlePreviewIframeRef = useCallback(
|
|
3059
|
+
(iframe: HTMLIFrameElement | null) => {
|
|
3060
|
+
previewIframeRef.current = iframe;
|
|
3061
|
+
setPreviewIframe(iframe);
|
|
3062
|
+
syncPreviewTimelineHotkey(iframe);
|
|
3063
|
+
syncPreviewHistoryHotkey(iframe);
|
|
3064
|
+
consoleErrorsRef.current = [];
|
|
3065
|
+
setConsoleErrors(null);
|
|
3066
|
+
refreshPreviewDocumentVersion();
|
|
3067
|
+
},
|
|
3068
|
+
[refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
|
|
3069
|
+
);
|
|
3070
|
+
|
|
3071
|
+
const handlePreviewCanvasMouseDown = useCallback(
|
|
3072
|
+
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
3073
|
+
if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) return;
|
|
3074
|
+
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
3075
|
+
preferClipAncestor: options?.preferClipAncestor ?? true,
|
|
3076
|
+
});
|
|
3077
|
+
if (!nextSelection) {
|
|
3078
|
+
if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
e.preventDefault();
|
|
3082
|
+
e.stopPropagation();
|
|
3083
|
+
applyDomSelection(nextSelection, { additive: e.shiftKey });
|
|
3084
|
+
},
|
|
3085
|
+
[applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
|
|
3086
|
+
);
|
|
3087
|
+
|
|
3088
|
+
const handlePreviewCanvasPointerMove = useCallback(
|
|
3089
|
+
(e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
3090
|
+
if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) {
|
|
3091
|
+
updateDomEditHoverSelection(null);
|
|
3092
|
+
return null;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
3096
|
+
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
3097
|
+
});
|
|
3098
|
+
updateDomEditHoverSelection(nextSelection);
|
|
3099
|
+
return nextSelection;
|
|
3100
|
+
},
|
|
3101
|
+
[captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection],
|
|
3102
|
+
);
|
|
3103
|
+
|
|
3104
|
+
const handlePreviewCanvasPointerLeave = useCallback(() => {
|
|
3105
|
+
updateDomEditHoverSelection(null);
|
|
3106
|
+
}, [updateDomEditHoverSelection]);
|
|
3107
|
+
|
|
3108
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3109
|
+
useEffect(() => {
|
|
3110
|
+
if (captionEditMode) updateDomEditHoverSelection(null);
|
|
3111
|
+
}, [captionEditMode, updateDomEditHoverSelection]);
|
|
3112
|
+
|
|
3113
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3114
|
+
useEffect(() => {
|
|
3115
|
+
updateDomEditHoverSelection(null);
|
|
3116
|
+
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
|
|
3117
|
+
|
|
3118
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3119
|
+
useEffect(() => {
|
|
3120
|
+
if (!domEditHoverSelection) return;
|
|
3121
|
+
const hoverMatchesSelection = domEditSelectionsTargetSame(
|
|
3122
|
+
domEditHoverSelection,
|
|
3123
|
+
domEditSelection,
|
|
3124
|
+
);
|
|
3125
|
+
const hoverMatchesGroup = domEditSelectionInGroup(
|
|
3126
|
+
domEditGroupSelections,
|
|
3127
|
+
domEditHoverSelection,
|
|
3128
|
+
);
|
|
3129
|
+
if (!hoverMatchesSelection && !hoverMatchesGroup) return;
|
|
3130
|
+
updateDomEditHoverSelection(null);
|
|
3131
|
+
}, [
|
|
3132
|
+
domEditGroupSelections,
|
|
3133
|
+
domEditHoverSelection,
|
|
3134
|
+
domEditSelection,
|
|
3135
|
+
updateDomEditHoverSelection,
|
|
3136
|
+
]);
|
|
3137
|
+
|
|
3138
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3139
|
+
useEffect(() => {
|
|
3140
|
+
if (!domEditHoverSelection) return;
|
|
3141
|
+
if (domEditHoverSelection.element.isConnected) return;
|
|
3142
|
+
updateDomEditHoverSelection(null);
|
|
3143
|
+
}, [domEditHoverSelection, updateDomEditHoverSelection]);
|
|
3144
|
+
|
|
3145
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3146
|
+
useEffect(() => {
|
|
3147
|
+
if (!previewIframe) return;
|
|
3148
|
+
|
|
3149
|
+
const syncSelectionFromDocument = () => {
|
|
3150
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
|
|
3151
|
+
const currentSelection = domEditSelectionRef.current;
|
|
3152
|
+
if (!currentSelection) return;
|
|
3153
|
+
let doc: Document | null = null;
|
|
3154
|
+
try {
|
|
3155
|
+
doc = previewIframe.contentDocument;
|
|
3156
|
+
} catch {
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
if (!doc) return;
|
|
3160
|
+
|
|
3161
|
+
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
|
3162
|
+
if (!nextElement) {
|
|
3163
|
+
applyDomSelection(null, { revealPanel: false });
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
const nextSelection = buildDomSelectionFromTarget(nextElement);
|
|
3168
|
+
if (nextSelection) {
|
|
3169
|
+
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
3170
|
+
}
|
|
3171
|
+
};
|
|
3172
|
+
|
|
3173
|
+
const attachErrorCapture = () => {
|
|
3174
|
+
try {
|
|
3175
|
+
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
|
|
3176
|
+
if (!win) return;
|
|
3177
|
+
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
3178
|
+
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
3179
|
+
const origError = win.console.error.bind(win.console);
|
|
3180
|
+
win.console.error = function (...args: unknown[]) {
|
|
3181
|
+
origError(...args);
|
|
3182
|
+
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
|
3183
|
+
if (text.includes("favicon")) return;
|
|
3184
|
+
consoleErrorsRef.current = [
|
|
3185
|
+
...consoleErrorsRef.current,
|
|
3186
|
+
{ severity: "error", message: text },
|
|
3187
|
+
];
|
|
3188
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
3189
|
+
};
|
|
3190
|
+
win.addEventListener("error", (e: ErrorEvent) => {
|
|
3191
|
+
const text = e.message || String(e);
|
|
3192
|
+
consoleErrorsRef.current = [
|
|
3193
|
+
...consoleErrorsRef.current,
|
|
3194
|
+
{ severity: "error", message: text },
|
|
3195
|
+
];
|
|
3196
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
3197
|
+
});
|
|
3198
|
+
} catch {
|
|
3199
|
+
// same-origin only
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
|
|
3203
|
+
attachErrorCapture();
|
|
3204
|
+
syncPreviewHistoryHotkey(previewIframe);
|
|
3205
|
+
void (async () => {
|
|
3206
|
+
await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
3207
|
+
await applyStudioMotionToPreviewAfterRefresh(previewIframe);
|
|
3208
|
+
})();
|
|
3209
|
+
syncSelectionFromDocument();
|
|
3210
|
+
refreshPreviewDocumentVersion();
|
|
3211
|
+
|
|
3212
|
+
const handleLoad = () => {
|
|
3213
|
+
consoleErrorsRef.current = [];
|
|
3214
|
+
setConsoleErrors(null);
|
|
3215
|
+
attachErrorCapture();
|
|
3216
|
+
syncPreviewHistoryHotkey(previewIframe);
|
|
3217
|
+
void (async () => {
|
|
3218
|
+
await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
|
|
3219
|
+
await applyStudioMotionToPreviewAfterRefresh(previewIframe);
|
|
3220
|
+
})();
|
|
3221
|
+
syncSelectionFromDocument();
|
|
3222
|
+
refreshPreviewDocumentVersion();
|
|
3223
|
+
};
|
|
3224
|
+
|
|
3225
|
+
previewIframe.addEventListener("load", handleLoad);
|
|
3226
|
+
return () => {
|
|
3227
|
+
previewIframe.removeEventListener("load", handleLoad);
|
|
3228
|
+
};
|
|
3229
|
+
}, [
|
|
3230
|
+
activeCompPath,
|
|
3231
|
+
applyDomSelection,
|
|
3232
|
+
applyStudioManualEditsToPreviewAfterRefresh,
|
|
3233
|
+
applyStudioMotionToPreviewAfterRefresh,
|
|
3234
|
+
buildDomSelectionFromTarget,
|
|
3235
|
+
captionEditMode,
|
|
3236
|
+
previewIframe,
|
|
3237
|
+
refreshPreviewDocumentVersion,
|
|
3238
|
+
syncPreviewHistoryHotkey,
|
|
3239
|
+
]);
|
|
3240
|
+
|
|
3241
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3242
|
+
useEffect(() => {
|
|
3243
|
+
if (!captionEditMode) return;
|
|
3244
|
+
applyDomSelection(null, { revealPanel: false });
|
|
3245
|
+
}, [applyDomSelection, captionEditMode]);
|
|
3246
|
+
|
|
3247
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3248
|
+
useEffect(() => {
|
|
3249
|
+
if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
3250
|
+
updateDomEditHoverSelection(null);
|
|
3251
|
+
applyDomSelection(null, { revealPanel: false });
|
|
3252
|
+
if (rightPanelTab !== "renders") setRightPanelTab("renders");
|
|
3253
|
+
}, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection]);
|
|
3254
|
+
|
|
3255
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
3256
|
+
useEffect(
|
|
3257
|
+
() => () => {
|
|
3258
|
+
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
3259
|
+
},
|
|
3260
|
+
[],
|
|
3261
|
+
);
|
|
3262
|
+
|
|
3263
|
+
const refreshFileTree = useCallback(async () => {
|
|
3264
|
+
const pid = projectIdRef.current;
|
|
3265
|
+
if (!pid) return;
|
|
3266
|
+
const res = await fetch(`/api/projects/${pid}`);
|
|
3267
|
+
const data = await res.json();
|
|
3268
|
+
if (data.files) setFileTree(data.files);
|
|
3269
|
+
}, []);
|
|
3270
|
+
|
|
3271
|
+
const uploadProjectFiles = useCallback(
|
|
3272
|
+
async (files: Iterable<File>, dir?: string): Promise<string[]> => {
|
|
3273
|
+
const pid = projectIdRef.current;
|
|
3274
|
+
const fileList = Array.from(files);
|
|
3275
|
+
if (!pid || fileList.length === 0) return [];
|
|
3276
|
+
|
|
3277
|
+
const formData = new FormData();
|
|
3278
|
+
for (const file of fileList) {
|
|
3279
|
+
formData.append("file", file);
|
|
1025
3280
|
}
|
|
1026
3281
|
|
|
1027
3282
|
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
@@ -1138,24 +3393,19 @@ export function StudioApp() {
|
|
|
1138
3393
|
duration: normalizedDuration,
|
|
1139
3394
|
track: placement.track,
|
|
1140
3395
|
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
3396
|
+
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
|
1141
3397
|
}),
|
|
1142
3398
|
);
|
|
1143
3399
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
if (editingPathRef.current === targetPath) {
|
|
1157
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1158
|
-
}
|
|
3400
|
+
await saveProjectFilesWithHistory({
|
|
3401
|
+
projectId: pid,
|
|
3402
|
+
label: "Add timeline asset",
|
|
3403
|
+
kind: "timeline",
|
|
3404
|
+
files: { [targetPath]: patchedContent },
|
|
3405
|
+
readFile: async () => originalContent,
|
|
3406
|
+
writeFile: writeProjectFile,
|
|
3407
|
+
recordEdit: editHistory.recordEdit,
|
|
3408
|
+
});
|
|
1159
3409
|
|
|
1160
3410
|
setRefreshKey((k) => k + 1);
|
|
1161
3411
|
} catch (error) {
|
|
@@ -1164,7 +3414,7 @@ export function StudioApp() {
|
|
|
1164
3414
|
showToast(message);
|
|
1165
3415
|
}
|
|
1166
3416
|
},
|
|
1167
|
-
[activeCompPath, showToast, timelineElements],
|
|
3417
|
+
[activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
|
|
1168
3418
|
);
|
|
1169
3419
|
|
|
1170
3420
|
const handleTimelineFileDrop = useCallback(
|
|
@@ -1322,7 +3572,33 @@ export function StudioApp() {
|
|
|
1322
3572
|
|
|
1323
3573
|
const handleImportFiles = useCallback(
|
|
1324
3574
|
async (files: FileList | File[], dir?: string) => {
|
|
1325
|
-
|
|
3575
|
+
return uploadProjectFiles(Array.from(files), dir);
|
|
3576
|
+
},
|
|
3577
|
+
[uploadProjectFiles],
|
|
3578
|
+
);
|
|
3579
|
+
|
|
3580
|
+
const handleImportFonts = useCallback(
|
|
3581
|
+
async (files: FileList | File[]) => {
|
|
3582
|
+
const uploaded = await uploadProjectFiles(
|
|
3583
|
+
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
|
3584
|
+
"assets/fonts",
|
|
3585
|
+
);
|
|
3586
|
+
const pid = projectIdRef.current;
|
|
3587
|
+
const imported = uploaded
|
|
3588
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
3589
|
+
.map((asset) => ({
|
|
3590
|
+
family: fontFamilyFromAssetPath(asset),
|
|
3591
|
+
path: asset,
|
|
3592
|
+
url: `/api/projects/${pid}/preview/${asset}`,
|
|
3593
|
+
}));
|
|
3594
|
+
importedFontAssetsRef.current = [
|
|
3595
|
+
...imported,
|
|
3596
|
+
...importedFontAssetsRef.current.filter(
|
|
3597
|
+
(existing) =>
|
|
3598
|
+
!imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
|
|
3599
|
+
),
|
|
3600
|
+
];
|
|
3601
|
+
return imported;
|
|
1326
3602
|
},
|
|
1327
3603
|
[uploadProjectFiles],
|
|
1328
3604
|
);
|
|
@@ -1394,6 +3670,53 @@ export function StudioApp() {
|
|
|
1394
3670
|
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
1395
3671
|
[fileTree],
|
|
1396
3672
|
);
|
|
3673
|
+
const fontAssets = useMemo<ImportedFontAsset[]>(
|
|
3674
|
+
() =>
|
|
3675
|
+
assets
|
|
3676
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
3677
|
+
.map((asset) => ({
|
|
3678
|
+
family: fontFamilyFromAssetPath(asset),
|
|
3679
|
+
path: asset,
|
|
3680
|
+
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
3681
|
+
})),
|
|
3682
|
+
[assets, projectId],
|
|
3683
|
+
);
|
|
3684
|
+
const selectedStudioMotion =
|
|
3685
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
|
|
3686
|
+
? getStudioMotionForSelection(studioMotionManifestRef.current, domEditSelection)
|
|
3687
|
+
: null;
|
|
3688
|
+
const selectedTimelineElement = useMemo(
|
|
3689
|
+
() =>
|
|
3690
|
+
selectedTimelineElementId
|
|
3691
|
+
? (timelineElements.find(
|
|
3692
|
+
(element) => getTimelineElementKey(element) === selectedTimelineElementId,
|
|
3693
|
+
) ?? null)
|
|
3694
|
+
: null,
|
|
3695
|
+
[selectedTimelineElementId, timelineElements],
|
|
3696
|
+
);
|
|
3697
|
+
const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
|
|
3698
|
+
const motionPanelActive =
|
|
3699
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion";
|
|
3700
|
+
const inspectorPanelActive = designPanelActive || motionPanelActive;
|
|
3701
|
+
const shouldShowSelectedDomBounds =
|
|
3702
|
+
inspectorPanelActive &&
|
|
3703
|
+
!rightCollapsed &&
|
|
3704
|
+
(!selectedTimelineElement ||
|
|
3705
|
+
isTimelineElementActiveAtTime(currentTime, selectedTimelineElement));
|
|
3706
|
+
const inspectorButtonActive =
|
|
3707
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive;
|
|
3708
|
+
const timelineLayerPanel =
|
|
3709
|
+
STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED &&
|
|
3710
|
+
inspectedTimelineElement &&
|
|
3711
|
+
inspectedTimelineLayers.length > 0 ? (
|
|
3712
|
+
<TimelineLayerPanel
|
|
3713
|
+
clipLabel={getTimelineElementLabel(inspectedTimelineElement)}
|
|
3714
|
+
layers={inspectedTimelineLayers}
|
|
3715
|
+
selectedLayerKey={selectedTimelineLayerKey}
|
|
3716
|
+
onSelectLayer={handleTimelineLayerSelect}
|
|
3717
|
+
onClose={handleTimelineLayerPanelClose}
|
|
3718
|
+
/>
|
|
3719
|
+
) : null;
|
|
1397
3720
|
|
|
1398
3721
|
if (resolving || !projectId) {
|
|
1399
3722
|
return (
|
|
@@ -1439,6 +3762,42 @@ export function StudioApp() {
|
|
|
1439
3762
|
</div>
|
|
1440
3763
|
{/* Right: toolbar buttons */}
|
|
1441
3764
|
<div className="flex items-center gap-1.5">
|
|
3765
|
+
<button
|
|
3766
|
+
type="button"
|
|
3767
|
+
onClick={() => void handleUndo()}
|
|
3768
|
+
disabled={!editHistory.canUndo}
|
|
3769
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
3770
|
+
editHistory.canUndo
|
|
3771
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
3772
|
+
: "border-neutral-900 text-neutral-700"
|
|
3773
|
+
}`}
|
|
3774
|
+
title={
|
|
3775
|
+
editHistory.undoLabel
|
|
3776
|
+
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
|
|
3777
|
+
: `Undo (${getHistoryShortcutLabel("undo")})`
|
|
3778
|
+
}
|
|
3779
|
+
aria-label="Undo"
|
|
3780
|
+
>
|
|
3781
|
+
<RotateCcw size={14} />
|
|
3782
|
+
</button>
|
|
3783
|
+
<button
|
|
3784
|
+
type="button"
|
|
3785
|
+
onClick={() => void handleRedo()}
|
|
3786
|
+
disabled={!editHistory.canRedo}
|
|
3787
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
3788
|
+
editHistory.canRedo
|
|
3789
|
+
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
3790
|
+
: "border-neutral-900 text-neutral-700"
|
|
3791
|
+
}`}
|
|
3792
|
+
title={
|
|
3793
|
+
editHistory.redoLabel
|
|
3794
|
+
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
|
|
3795
|
+
: `Redo (${getHistoryShortcutLabel("redo")})`
|
|
3796
|
+
}
|
|
3797
|
+
aria-label="Redo"
|
|
3798
|
+
>
|
|
3799
|
+
<RotateCw size={14} />
|
|
3800
|
+
</button>
|
|
1442
3801
|
<a
|
|
1443
3802
|
href={captureFrameHref}
|
|
1444
3803
|
download={captureFrameFilename}
|
|
@@ -1453,12 +3812,31 @@ export function StudioApp() {
|
|
|
1453
3812
|
<span>Capture</span>
|
|
1454
3813
|
</a>
|
|
1455
3814
|
<button
|
|
1456
|
-
|
|
3815
|
+
type="button"
|
|
3816
|
+
onClick={() => {
|
|
3817
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
3818
|
+
if (rightCollapsed || !inspectorPanelActive) {
|
|
3819
|
+
setRightPanelTab("design");
|
|
3820
|
+
setRightCollapsed(false);
|
|
3821
|
+
return;
|
|
3822
|
+
}
|
|
3823
|
+
clearDomSelection();
|
|
3824
|
+
setRightCollapsed(true);
|
|
3825
|
+
}}
|
|
3826
|
+
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
|
|
1457
3827
|
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
1458
|
-
|
|
3828
|
+
inspectorButtonActive
|
|
1459
3829
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
1460
|
-
:
|
|
3830
|
+
: STUDIO_INSPECTOR_PANELS_ENABLED
|
|
3831
|
+
? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
|
|
3832
|
+
: "cursor-not-allowed border-transparent text-neutral-700"
|
|
1461
3833
|
}`}
|
|
3834
|
+
title={
|
|
3835
|
+
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
|
3836
|
+
}
|
|
3837
|
+
aria-label={
|
|
3838
|
+
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
|
3839
|
+
}
|
|
1462
3840
|
>
|
|
1463
3841
|
<svg
|
|
1464
3842
|
width="12"
|
|
@@ -1471,8 +3849,7 @@ export function StudioApp() {
|
|
|
1471
3849
|
<circle cx="12" cy="12" r="10" />
|
|
1472
3850
|
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
1473
3851
|
</svg>
|
|
1474
|
-
|
|
1475
|
-
{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
3852
|
+
Inspector
|
|
1476
3853
|
</button>
|
|
1477
3854
|
</div>
|
|
1478
3855
|
</div>
|
|
@@ -1553,6 +3930,7 @@ export function StudioApp() {
|
|
|
1553
3930
|
onLint={handleLint}
|
|
1554
3931
|
linting={linting}
|
|
1555
3932
|
onToggleCollapse={toggleLeftSidebar}
|
|
3933
|
+
takeoverContent={timelineLayerPanel}
|
|
1556
3934
|
/>
|
|
1557
3935
|
)}
|
|
1558
3936
|
|
|
@@ -1583,62 +3961,48 @@ export function StudioApp() {
|
|
|
1583
3961
|
onMoveElement={handleTimelineElementMove}
|
|
1584
3962
|
onResizeElement={handleTimelineElementResize}
|
|
1585
3963
|
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
|
3964
|
+
onSelectTimelineElement={handleTimelineElementSelect}
|
|
3965
|
+
onInspectTimelineElement={handleTimelineElementInspect}
|
|
3966
|
+
inspectedTimelineElementId={inspectedTimelineElementId}
|
|
3967
|
+
timelineLayerChildCounts={timelineLayerChildCounts}
|
|
3968
|
+
thumbnailedTimelineElementIds={thumbnailedTimelineElementIds}
|
|
3969
|
+
onToggleTimelineElementThumbnail={handleToggleTimelineElementThumbnail}
|
|
1586
3970
|
onCompIdToSrcChange={setCompIdToSrc}
|
|
1587
3971
|
onCompositionChange={(compPath) => {
|
|
1588
3972
|
// Sync activeCompPath when user drills down via timeline double-click
|
|
1589
3973
|
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
|
1590
3974
|
setActiveCompPath(compPath);
|
|
3975
|
+
setInspectedTimelineElementId(null);
|
|
3976
|
+
refreshPreviewDocumentVersion();
|
|
1591
3977
|
}}
|
|
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
|
-
}}
|
|
3978
|
+
onIframeRef={handlePreviewIframeRef}
|
|
1640
3979
|
previewOverlay={
|
|
1641
|
-
captionEditMode ?
|
|
3980
|
+
captionEditMode ? (
|
|
3981
|
+
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
3982
|
+
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
|
3983
|
+
<DomEditOverlay
|
|
3984
|
+
iframeRef={previewIframeRef}
|
|
3985
|
+
activeCompositionPath={activeCompPath}
|
|
3986
|
+
hoverSelection={
|
|
3987
|
+
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !captionEditMode
|
|
3988
|
+
? domEditHoverSelection
|
|
3989
|
+
: null
|
|
3990
|
+
}
|
|
3991
|
+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
3992
|
+
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
3993
|
+
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
|
3994
|
+
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
3995
|
+
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
3996
|
+
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
3997
|
+
onSelectionChange={applyDomSelection}
|
|
3998
|
+
onBlockedMove={handleBlockedDomMove}
|
|
3999
|
+
onManualDragStart={handleDomManualDragStart}
|
|
4000
|
+
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
4001
|
+
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
4002
|
+
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
4003
|
+
onRotationCommit={handleDomRotationCommit}
|
|
4004
|
+
/>
|
|
4005
|
+
) : null
|
|
1642
4006
|
}
|
|
1643
4007
|
timelineFooter={
|
|
1644
4008
|
captionEditMode ? (
|
|
@@ -1679,16 +4043,94 @@ export function StudioApp() {
|
|
|
1679
4043
|
{captionEditMode ? (
|
|
1680
4044
|
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
1681
4045
|
) : (
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
4046
|
+
<>
|
|
4047
|
+
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
|
4048
|
+
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
|
4049
|
+
<>
|
|
4050
|
+
<button
|
|
4051
|
+
type="button"
|
|
4052
|
+
onClick={() => setRightPanelTab("design")}
|
|
4053
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4054
|
+
rightPanelTab === "design"
|
|
4055
|
+
? "bg-neutral-800 text-white"
|
|
4056
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4057
|
+
}`}
|
|
4058
|
+
>
|
|
4059
|
+
Design
|
|
4060
|
+
</button>
|
|
4061
|
+
{STUDIO_MOTION_PANEL_ENABLED && (
|
|
4062
|
+
<button
|
|
4063
|
+
type="button"
|
|
4064
|
+
onClick={() => setRightPanelTab("motion")}
|
|
4065
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4066
|
+
rightPanelTab === "motion"
|
|
4067
|
+
? "bg-neutral-800 text-white"
|
|
4068
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4069
|
+
}`}
|
|
4070
|
+
>
|
|
4071
|
+
Motion
|
|
4072
|
+
</button>
|
|
4073
|
+
)}
|
|
4074
|
+
</>
|
|
4075
|
+
)}
|
|
4076
|
+
<button
|
|
4077
|
+
type="button"
|
|
4078
|
+
onClick={() => setRightPanelTab("renders")}
|
|
4079
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
4080
|
+
rightPanelTab === "renders"
|
|
4081
|
+
? "bg-neutral-800 text-white"
|
|
4082
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
4083
|
+
}`}
|
|
4084
|
+
>
|
|
4085
|
+
{renderQueue.jobs.length > 0
|
|
4086
|
+
? `Renders (${renderQueue.jobs.length})`
|
|
4087
|
+
: "Renders"}
|
|
4088
|
+
</button>
|
|
4089
|
+
</div>
|
|
4090
|
+
<div className="min-h-0 flex-1">
|
|
4091
|
+
{designPanelActive ? (
|
|
4092
|
+
<PropertyPanel
|
|
4093
|
+
projectId={projectId}
|
|
4094
|
+
assets={assets}
|
|
4095
|
+
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
4096
|
+
copiedAgentPrompt={copiedAgentPrompt}
|
|
4097
|
+
onClearSelection={clearDomSelection}
|
|
4098
|
+
onSetStyle={handleDomStyleCommit}
|
|
4099
|
+
onSetManualOffset={handleDomPathOffsetCommit}
|
|
4100
|
+
onSetManualSize={handleDomBoxSizeCommit}
|
|
4101
|
+
onSetText={handleDomTextCommit}
|
|
4102
|
+
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
|
4103
|
+
onAddTextField={handleDomAddTextField}
|
|
4104
|
+
onRemoveTextField={handleDomRemoveTextField}
|
|
4105
|
+
onResetManualEdits={handleDomManualEditsReset}
|
|
4106
|
+
onAskAgent={handleAskAgent}
|
|
4107
|
+
onImportAssets={handleImportFiles}
|
|
4108
|
+
fontAssets={fontAssets}
|
|
4109
|
+
onImportFonts={handleImportFonts}
|
|
4110
|
+
/>
|
|
4111
|
+
) : motionPanelActive ? (
|
|
4112
|
+
<MotionPanel
|
|
4113
|
+
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
4114
|
+
motion={selectedStudioMotion}
|
|
4115
|
+
onClearSelection={clearDomSelection}
|
|
4116
|
+
onSetMotion={handleDomMotionCommit}
|
|
4117
|
+
onClearMotion={handleDomMotionClear}
|
|
4118
|
+
/>
|
|
4119
|
+
) : (
|
|
4120
|
+
<RenderQueue
|
|
4121
|
+
jobs={renderQueue.jobs}
|
|
4122
|
+
projectId={projectId}
|
|
4123
|
+
onDelete={renderQueue.deleteRender}
|
|
4124
|
+
onClearCompleted={renderQueue.clearCompleted}
|
|
4125
|
+
onStartRender={async (format, quality) => {
|
|
4126
|
+
await waitForPendingDomEditSaves();
|
|
4127
|
+
await renderQueue.startRender(30, quality, format);
|
|
4128
|
+
}}
|
|
4129
|
+
isRendering={renderQueue.isRendering}
|
|
4130
|
+
/>
|
|
4131
|
+
)}
|
|
4132
|
+
</div>
|
|
4133
|
+
</>
|
|
1692
4134
|
)}
|
|
1693
4135
|
</div>
|
|
1694
4136
|
</>
|
|
@@ -1709,6 +4151,15 @@ export function StudioApp() {
|
|
|
1709
4151
|
/>
|
|
1710
4152
|
)}
|
|
1711
4153
|
|
|
4154
|
+
{/* Ask agent modal */}
|
|
4155
|
+
{agentModalOpen && domEditSelection && (
|
|
4156
|
+
<AskAgentModal
|
|
4157
|
+
selectionLabel={domEditSelection.label}
|
|
4158
|
+
onSubmit={handleAgentModalSubmit}
|
|
4159
|
+
onClose={() => setAgentModalOpen(false)}
|
|
4160
|
+
/>
|
|
4161
|
+
)}
|
|
4162
|
+
|
|
1712
4163
|
{/* Global drag-drop overlay */}
|
|
1713
4164
|
{globalDragOver && (
|
|
1714
4165
|
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|