@hyperframes/studio 0.5.0-alpha.9 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1438
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2466
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +5 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
- package/src/player/hooks/useTimelinePlayer.ts +206 -93
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -445
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
package/src/App.tsx
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useRef,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
type MouseEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
2
10
|
import { useMountEffect } from "./hooks/useMountEffect";
|
|
3
11
|
import { NLELayout } from "./components/nle/NLELayout";
|
|
4
12
|
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
5
13
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
6
14
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
7
15
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
8
|
-
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
|
|
16
|
+
import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
|
|
9
17
|
import { AudioWaveform } from "./player/components/AudioWaveform";
|
|
10
18
|
import type { TimelineElement } from "./player";
|
|
11
19
|
import { LintModal } from "./components/LintModal";
|
|
12
20
|
import type { LintFinding } from "./components/LintModal";
|
|
13
21
|
import { MediaPreview } from "./components/MediaPreview";
|
|
14
|
-
import {
|
|
22
|
+
import { isMediaFile } from "./utils/mediaTypes";
|
|
15
23
|
import {
|
|
16
24
|
buildTimelineAssetId,
|
|
17
25
|
buildTimelineAssetInsertHtml,
|
|
18
26
|
buildTimelineFileDropPlacements,
|
|
19
27
|
getTimelineAssetKind,
|
|
20
28
|
insertTimelineAssetIntoSource,
|
|
21
|
-
resolveTimelineAssetInitialGeometry,
|
|
22
29
|
resolveTimelineAssetSrc,
|
|
23
30
|
type TimelineAssetKind,
|
|
24
31
|
} from "./utils/timelineAssetDrop";
|
|
@@ -28,13 +35,7 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
28
35
|
import { useCaptionStore } from "./captions/store";
|
|
29
36
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
30
37
|
import { parseCaptionComposition } from "./captions/parser";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
applyPatchByTarget,
|
|
34
|
-
readAttributeByTarget,
|
|
35
|
-
readTagSnippetByTarget,
|
|
36
|
-
type PatchOperation,
|
|
37
|
-
} from "./utils/sourcePatcher";
|
|
38
|
+
import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
|
|
38
39
|
import {
|
|
39
40
|
buildTrackZIndexMap,
|
|
40
41
|
formatTimelineAttributeNumber,
|
|
@@ -44,36 +45,12 @@ import {
|
|
|
44
45
|
getTimelineZoomPercent,
|
|
45
46
|
} from "./player/components/timelineZoom";
|
|
46
47
|
import {
|
|
47
|
-
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
48
|
-
getTimelineEditorHintDismissed,
|
|
49
48
|
getTimelineToggleTitle,
|
|
50
|
-
setTimelineEditorHintDismissed,
|
|
51
49
|
shouldHandleTimelineToggleHotkey,
|
|
52
50
|
} from "./utils/timelineDiscovery";
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
fontFamilyFromAssetPath,
|
|
57
|
-
importedFontFaceCss,
|
|
58
|
-
type ImportedFontAsset,
|
|
59
|
-
} from "./components/editor/fontAssets";
|
|
60
|
-
import { DomEditOverlay } from "./components/editor/DomEditOverlay";
|
|
61
|
-
import {
|
|
62
|
-
buildDefaultDomEditTextField,
|
|
63
|
-
buildDomEditDetachPatchOperations,
|
|
64
|
-
buildDomEditMovePatchOperations,
|
|
65
|
-
buildDomEditResizePatchOperations,
|
|
66
|
-
buildDomEditStylePatchOperation,
|
|
67
|
-
buildDomEditTextPatchOperation,
|
|
68
|
-
buildElementAgentPrompt,
|
|
69
|
-
findElementForSelection,
|
|
70
|
-
isTextEditableSelection,
|
|
71
|
-
serializeDomEditTextFields,
|
|
72
|
-
resolveDomEditCapabilities,
|
|
73
|
-
resolveDomEditSelection,
|
|
74
|
-
type DomEditTextField,
|
|
75
|
-
type DomEditSelection,
|
|
76
|
-
} from "./components/editor/domEditing";
|
|
51
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
|
52
|
+
import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
|
|
53
|
+
import { Camera } from "./icons/SystemIcons";
|
|
77
54
|
|
|
78
55
|
interface EditingFile {
|
|
79
56
|
path: string;
|
|
@@ -85,433 +62,8 @@ interface AppToast {
|
|
|
85
62
|
tone: "error" | "info";
|
|
86
63
|
}
|
|
87
64
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const GENERIC_FONT_FAMILIES = new Set([
|
|
91
|
-
"inherit",
|
|
92
|
-
"initial",
|
|
93
|
-
"revert",
|
|
94
|
-
"revert-layer",
|
|
95
|
-
"serif",
|
|
96
|
-
"sans-serif",
|
|
97
|
-
"monospace",
|
|
98
|
-
"cursive",
|
|
99
|
-
"fantasy",
|
|
100
|
-
"system-ui",
|
|
101
|
-
"ui-sans-serif",
|
|
102
|
-
"ui-serif",
|
|
103
|
-
"ui-monospace",
|
|
104
|
-
"ui-rounded",
|
|
105
|
-
"emoji",
|
|
106
|
-
"math",
|
|
107
|
-
"fangsong",
|
|
108
|
-
]);
|
|
109
|
-
|
|
110
|
-
function primaryFontFamilyFromCss(value: string): string {
|
|
111
|
-
const first = value.split(",")[0] ?? "";
|
|
112
|
-
return first.trim().replace(/^["']|["']$/g, "");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
|
116
|
-
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
|
117
|
-
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
|
118
|
-
|
|
119
|
-
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
120
|
-
if (doc.getElementById(id)) return;
|
|
121
|
-
|
|
122
|
-
const link = doc.createElement("link");
|
|
123
|
-
link.id = id;
|
|
124
|
-
link.rel = "stylesheet";
|
|
125
|
-
link.href = googleFontStylesheetUrl(family);
|
|
126
|
-
doc.head.appendChild(link);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function primaryFontFamilyValue(value: string): string {
|
|
130
|
-
return (
|
|
131
|
-
value
|
|
132
|
-
.split(",")[0]
|
|
133
|
-
?.trim()
|
|
134
|
-
.replace(/^["']|["']$/g, "")
|
|
135
|
-
.trim() ?? ""
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
|
140
|
-
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
141
|
-
if (doc.getElementById(id)) return;
|
|
142
|
-
const style = doc.createElement("style");
|
|
143
|
-
style.id = id;
|
|
144
|
-
style.textContent = importedFontFaceCss(asset);
|
|
145
|
-
doc.head.appendChild(style);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function normalizeProjectAssetPath(value: string): string {
|
|
149
|
-
const trimmed = value.trim();
|
|
150
|
-
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
151
|
-
return decodeURIComponent(maybeUrl)
|
|
152
|
-
.replace(/\\/g, "/")
|
|
153
|
-
.replace(/^\.?\//, "");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
157
|
-
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
|
158
|
-
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
|
159
|
-
|
|
160
|
-
fromParts.pop();
|
|
161
|
-
|
|
162
|
-
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
163
|
-
fromParts.shift();
|
|
164
|
-
targetParts.shift();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function isAbsoluteFilePath(value: string): boolean {
|
|
171
|
-
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
|
|
175
|
-
const trimmedSource = sourceFile.trim();
|
|
176
|
-
if (!trimmedSource) return undefined;
|
|
177
|
-
|
|
178
|
-
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
179
|
-
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
180
|
-
|
|
181
|
-
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
182
|
-
if (!normalizedRoot) return undefined;
|
|
183
|
-
|
|
184
|
-
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function ensureImportedFontFace(
|
|
188
|
-
html: string,
|
|
189
|
-
asset: ImportedFontAsset,
|
|
190
|
-
sourceFile: string,
|
|
191
|
-
): string {
|
|
192
|
-
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
193
|
-
if (html.includes(css)) return html;
|
|
194
|
-
|
|
195
|
-
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
196
|
-
const styleMatch = styleRe.exec(html);
|
|
197
|
-
if (styleMatch) {
|
|
198
|
-
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
199
|
-
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
203
|
-
if (/<\/head>/i.test(html)) {
|
|
204
|
-
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
|
205
|
-
}
|
|
206
|
-
return `${styleTag}\n${html}`;
|
|
207
|
-
}
|
|
208
|
-
function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
209
|
-
const trimmed = value.trim();
|
|
210
|
-
if (!trimmed) return trimmed;
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
|
|
214
|
-
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
215
|
-
) {
|
|
216
|
-
return `${trimmed}px`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return trimmed;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function isImageBackgroundValue(value: string): boolean {
|
|
223
|
-
return /^url\(/i.test(value.trim());
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
|
|
227
|
-
return Boolean(
|
|
228
|
-
selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function buildOppositeEdgePatchOperations(
|
|
233
|
-
selection: DomEditSelection,
|
|
234
|
-
dimension: "width" | "height" | "both",
|
|
235
|
-
): PatchOperation[] {
|
|
236
|
-
if (!shouldDetachOppositeEdges(selection)) return [];
|
|
237
|
-
const operations: PatchOperation[] = [];
|
|
238
|
-
if (dimension === "width" || dimension === "both") {
|
|
239
|
-
operations.push({ type: "inline-style", property: "right", value: "auto" });
|
|
240
|
-
}
|
|
241
|
-
if (dimension === "height" || dimension === "both") {
|
|
242
|
-
operations.push({ type: "inline-style", property: "bottom", value: "auto" });
|
|
243
|
-
}
|
|
244
|
-
return operations;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
248
|
-
if (!target || typeof target !== "object") return null;
|
|
249
|
-
const maybeNode = target as {
|
|
250
|
-
nodeType?: number;
|
|
251
|
-
parentElement?: Element | null;
|
|
252
|
-
};
|
|
253
|
-
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
|
254
|
-
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
|
255
|
-
return maybeNode.parentElement as HTMLElement;
|
|
256
|
-
}
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function findMatchingTimelineElementId(
|
|
261
|
-
selection: Pick<
|
|
262
|
-
DomEditSelection,
|
|
263
|
-
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
264
|
-
>,
|
|
265
|
-
elements: TimelineElement[],
|
|
266
|
-
): string | null {
|
|
267
|
-
for (const element of elements) {
|
|
268
|
-
if (selection.id && element.domId === selection.id) {
|
|
269
|
-
return element.key ?? element.id;
|
|
270
|
-
}
|
|
271
|
-
if (
|
|
272
|
-
selection.isCompositionHost &&
|
|
273
|
-
selection.compositionSrc &&
|
|
274
|
-
element.compositionSrc === selection.compositionSrc
|
|
275
|
-
) {
|
|
276
|
-
return element.key ?? element.id;
|
|
277
|
-
}
|
|
278
|
-
if (
|
|
279
|
-
selection.selector &&
|
|
280
|
-
element.selector === selection.selector &&
|
|
281
|
-
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
282
|
-
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
283
|
-
) {
|
|
284
|
-
return element.key ?? element.id;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function findMappedCompositionHost(
|
|
292
|
-
target: HTMLElement,
|
|
293
|
-
timelineElements: TimelineElement[],
|
|
294
|
-
compIdToSrc: Map<string, string>,
|
|
295
|
-
fileTree: string[],
|
|
296
|
-
): { host: HTMLElement; compositionSrc: string } | null {
|
|
297
|
-
const rootCompositionId =
|
|
298
|
-
target.ownerDocument
|
|
299
|
-
.querySelector("[data-composition-id]")
|
|
300
|
-
?.getAttribute("data-composition-id") ?? null;
|
|
301
|
-
|
|
302
|
-
let nestedCurrent: HTMLElement | null = target;
|
|
303
|
-
while (nestedCurrent) {
|
|
304
|
-
const nestedCompId = nestedCurrent.getAttribute("data-composition-id");
|
|
305
|
-
if (nestedCompId && nestedCompId !== rootCompositionId) {
|
|
306
|
-
const hostCandidate = nestedCurrent.parentElement?.closest(".clip");
|
|
307
|
-
if (hostCandidate instanceof HTMLElement) {
|
|
308
|
-
const hostCompId = hostCandidate.getAttribute("data-composition-id");
|
|
309
|
-
const compositionSrc =
|
|
310
|
-
hostCandidate.getAttribute("data-composition-src") ??
|
|
311
|
-
hostCandidate.getAttribute("data-composition-file") ??
|
|
312
|
-
(hostCompId ? compIdToSrc.get(hostCompId) : undefined) ??
|
|
313
|
-
compIdToSrc.get(nestedCompId) ??
|
|
314
|
-
fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ??
|
|
315
|
-
undefined;
|
|
316
|
-
if (compositionSrc) {
|
|
317
|
-
return { host: hostCandidate, compositionSrc };
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
nestedCurrent = nestedCurrent.parentElement;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
let current: HTMLElement | null = target;
|
|
325
|
-
while (current) {
|
|
326
|
-
const compId = current.getAttribute("data-composition-id");
|
|
327
|
-
const directSrc =
|
|
328
|
-
current.getAttribute("data-composition-src") ??
|
|
329
|
-
current.getAttribute("data-composition-file") ??
|
|
330
|
-
undefined;
|
|
331
|
-
const timelineMatch =
|
|
332
|
-
timelineElements.find(
|
|
333
|
-
(element) =>
|
|
334
|
-
Boolean(element.compositionSrc) &&
|
|
335
|
-
(element.domId === current?.id ||
|
|
336
|
-
(current?.id && element.id === current.id) ||
|
|
337
|
-
(compId && element.id === compId)),
|
|
338
|
-
) ?? null;
|
|
339
|
-
const compositionSrc =
|
|
340
|
-
directSrc ??
|
|
341
|
-
timelineMatch?.compositionSrc ??
|
|
342
|
-
(compId ? compIdToSrc.get(compId) : undefined) ??
|
|
343
|
-
(compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined);
|
|
344
|
-
if (compositionSrc) {
|
|
345
|
-
return { host: current, compositionSrc };
|
|
346
|
-
}
|
|
347
|
-
current = current.parentElement;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function isMoveStyleProperty(property: string): boolean {
|
|
354
|
-
return property === "left" || property === "top";
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function isResizeStyleProperty(property: string): boolean {
|
|
358
|
-
return property === "width" || property === "height";
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function getDomDetachCoordinateRoot(element: HTMLElement): HTMLElement {
|
|
362
|
-
const offsetParent = element.offsetParent;
|
|
363
|
-
if (offsetParent instanceof HTMLElement) return offsetParent;
|
|
364
|
-
|
|
365
|
-
let current = element.parentElement;
|
|
366
|
-
while (current) {
|
|
367
|
-
if (current.hasAttribute("data-composition-id")) return current;
|
|
368
|
-
current = current.parentElement;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return element.ownerDocument.body;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function measureDomDetachRect(element: HTMLElement): {
|
|
375
|
-
left: number;
|
|
376
|
-
top: number;
|
|
377
|
-
width: number;
|
|
378
|
-
height: number;
|
|
379
|
-
} {
|
|
380
|
-
const root = getDomDetachCoordinateRoot(element);
|
|
381
|
-
const rect = element.getBoundingClientRect();
|
|
382
|
-
const rootRect = root.getBoundingClientRect();
|
|
383
|
-
|
|
384
|
-
return {
|
|
385
|
-
left: rect.left - rootRect.left + root.scrollLeft,
|
|
386
|
-
top: rect.top - rootRect.top + root.scrollTop,
|
|
387
|
-
width: rect.width,
|
|
388
|
-
height: rect.height,
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function getDomSelectionClickKey(
|
|
393
|
-
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex">,
|
|
394
|
-
): string {
|
|
395
|
-
if (selection.id) return `id:${selection.id}`;
|
|
396
|
-
return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function getPreviewTargetFromPointer(
|
|
400
|
-
iframe: HTMLIFrameElement,
|
|
401
|
-
clientX: number,
|
|
402
|
-
clientY: number,
|
|
403
|
-
): HTMLElement | null {
|
|
404
|
-
let doc: Document | null = null;
|
|
405
|
-
let win: Window | null = null;
|
|
406
|
-
try {
|
|
407
|
-
doc = iframe.contentDocument;
|
|
408
|
-
win = iframe.contentWindow;
|
|
409
|
-
} catch {
|
|
410
|
-
return null;
|
|
411
|
-
}
|
|
412
|
-
if (!doc || !win) return null;
|
|
413
|
-
|
|
414
|
-
const iframeRect = iframe.getBoundingClientRect();
|
|
415
|
-
const root =
|
|
416
|
-
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
|
417
|
-
const rootRect = root?.getBoundingClientRect();
|
|
418
|
-
const rootWidth = rootRect?.width || win.innerWidth;
|
|
419
|
-
const rootHeight = rootRect?.height || win.innerHeight;
|
|
420
|
-
if (!rootWidth || !rootHeight) return null;
|
|
421
|
-
|
|
422
|
-
const scaleX = iframeRect.width / rootWidth;
|
|
423
|
-
const scaleY = iframeRect.height / rootHeight;
|
|
424
|
-
const localX = (clientX - iframeRect.left) / scaleX;
|
|
425
|
-
const localY = (clientY - iframeRect.top) / scaleY;
|
|
426
|
-
|
|
427
|
-
return getEventTargetElement(doc.elementFromPoint(localX, localY));
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// ── Ask Agent Modal ──
|
|
431
|
-
|
|
432
|
-
function AskAgentModal({
|
|
433
|
-
selectionLabel,
|
|
434
|
-
onSubmit,
|
|
435
|
-
onClose,
|
|
436
|
-
}: {
|
|
437
|
-
selectionLabel: string;
|
|
438
|
-
onSubmit: (instruction: string) => void;
|
|
439
|
-
onClose: () => void;
|
|
440
|
-
}) {
|
|
441
|
-
const [value, setValue] = useState("");
|
|
442
|
-
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
443
|
-
|
|
444
|
-
useMountEffect(() => {
|
|
445
|
-
requestAnimationFrame(() => inputRef.current?.focus());
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
const handleSubmit = () => {
|
|
449
|
-
if (!value.trim()) return;
|
|
450
|
-
onSubmit(value.trim());
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
return (
|
|
454
|
-
<div
|
|
455
|
-
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
456
|
-
onClick={onClose}
|
|
457
|
-
>
|
|
458
|
-
<div
|
|
459
|
-
className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
|
|
460
|
-
onClick={(e) => e.stopPropagation()}
|
|
461
|
-
>
|
|
462
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
|
463
|
-
<div>
|
|
464
|
-
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
|
465
|
-
<p className="text-xs text-neutral-500 mt-0.5">
|
|
466
|
-
{selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
|
|
467
|
-
</p>
|
|
468
|
-
</div>
|
|
469
|
-
<button
|
|
470
|
-
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
|
471
|
-
onClick={onClose}
|
|
472
|
-
>
|
|
473
|
-
<svg
|
|
474
|
-
width="14"
|
|
475
|
-
height="14"
|
|
476
|
-
viewBox="0 0 24 24"
|
|
477
|
-
fill="none"
|
|
478
|
-
stroke="currentColor"
|
|
479
|
-
strokeWidth="2"
|
|
480
|
-
strokeLinecap="round"
|
|
481
|
-
>
|
|
482
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
483
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
484
|
-
</svg>
|
|
485
|
-
</button>
|
|
486
|
-
</div>
|
|
487
|
-
<div className="px-5 py-4">
|
|
488
|
-
<textarea
|
|
489
|
-
ref={inputRef}
|
|
490
|
-
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"
|
|
491
|
-
placeholder="Describe what you want to change…"
|
|
492
|
-
value={value}
|
|
493
|
-
onChange={(e) => setValue(e.target.value)}
|
|
494
|
-
onKeyDown={(e) => {
|
|
495
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
|
|
496
|
-
if (e.key === "Escape") onClose();
|
|
497
|
-
}}
|
|
498
|
-
/>
|
|
499
|
-
</div>
|
|
500
|
-
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
|
501
|
-
<span className="text-[11px] text-neutral-600">
|
|
502
|
-
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
|
503
|
-
</span>
|
|
504
|
-
<button
|
|
505
|
-
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"
|
|
506
|
-
disabled={!value.trim()}
|
|
507
|
-
onClick={handleSubmit}
|
|
508
|
-
>
|
|
509
|
-
Copy prompt
|
|
510
|
-
</button>
|
|
511
|
-
</div>
|
|
512
|
-
</div>
|
|
513
|
-
</div>
|
|
514
|
-
);
|
|
65
|
+
function getTimelineElementLabel(element: TimelineElement): string {
|
|
66
|
+
return element.label || element.id || element.tag;
|
|
515
67
|
}
|
|
516
68
|
|
|
517
69
|
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
@@ -571,9 +123,9 @@ export function StudioApp() {
|
|
|
571
123
|
const [resolving, setResolving] = useState(true);
|
|
572
124
|
|
|
573
125
|
useMountEffect(() => {
|
|
574
|
-
const
|
|
575
|
-
if (
|
|
576
|
-
setProjectId(
|
|
126
|
+
const hashProjectId = parseProjectIdFromHash(window.location.hash);
|
|
127
|
+
if (hashProjectId) {
|
|
128
|
+
setProjectId(hashProjectId);
|
|
577
129
|
setResolving(false);
|
|
578
130
|
return;
|
|
579
131
|
}
|
|
@@ -584,7 +136,7 @@ export function StudioApp() {
|
|
|
584
136
|
const first = (data.projects ?? [])[0];
|
|
585
137
|
if (first) {
|
|
586
138
|
setProjectId(first.id);
|
|
587
|
-
window.location.hash =
|
|
139
|
+
window.location.hash = buildProjectHash(first.id);
|
|
588
140
|
}
|
|
589
141
|
})
|
|
590
142
|
.catch(() => {})
|
|
@@ -592,7 +144,6 @@ export function StudioApp() {
|
|
|
592
144
|
});
|
|
593
145
|
|
|
594
146
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
595
|
-
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
596
147
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
597
148
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
598
149
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -606,12 +157,6 @@ export function StudioApp() {
|
|
|
606
157
|
const [rightWidth, setRightWidth] = useState(400);
|
|
607
158
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
608
159
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
609
|
-
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
610
|
-
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
611
|
-
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
612
|
-
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
613
|
-
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
614
|
-
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
615
160
|
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
|
|
616
161
|
// This is a subscription to external events (postMessage from runtime) — useEffect
|
|
617
162
|
// is appropriate here. The runtime fires "state"/"timeline" messages after all
|
|
@@ -734,14 +279,10 @@ export function StudioApp() {
|
|
|
734
279
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
735
280
|
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
736
281
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
737
|
-
const [
|
|
738
|
-
getTimelineEditorHintDismissed,
|
|
739
|
-
);
|
|
282
|
+
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
|
740
283
|
const dragCounterRef = useRef(0);
|
|
741
284
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
742
285
|
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
743
|
-
const lastBlockedDomMoveToastAtRef = useRef(0);
|
|
744
|
-
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
745
286
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
746
287
|
const panelDragRef = useRef<{
|
|
747
288
|
side: "left" | "right";
|
|
@@ -753,14 +294,11 @@ export function StudioApp() {
|
|
|
753
294
|
const activePreviewUrl = activeCompPath
|
|
754
295
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
755
296
|
: null;
|
|
756
|
-
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
757
297
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
758
298
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
759
299
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
760
300
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
761
|
-
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
762
301
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
763
|
-
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
764
302
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
765
303
|
const effectiveTimelineDuration = useMemo(() => {
|
|
766
304
|
const maxEnd =
|
|
@@ -776,13 +314,29 @@ export function StudioApp() {
|
|
|
776
314
|
const toggleTimelineVisibility = useCallback(() => {
|
|
777
315
|
setTimelineVisible((visible) => !visible);
|
|
778
316
|
}, []);
|
|
317
|
+
const toggleLeftSidebar = useCallback(() => {
|
|
318
|
+
setLeftCollapsed((collapsed) => !collapsed);
|
|
319
|
+
}, []);
|
|
320
|
+
const refreshCaptureFrameTime = useCallback(() => {
|
|
321
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
useMountEffect(() => {
|
|
325
|
+
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
326
|
+
return liveTime.subscribe(setCaptureFrameTime);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const captureFrameHref = projectId
|
|
330
|
+
? buildFrameCaptureUrl({
|
|
331
|
+
projectId,
|
|
332
|
+
compositionPath: activeCompPath,
|
|
333
|
+
currentTime: captureFrameTime,
|
|
334
|
+
})
|
|
335
|
+
: "#";
|
|
336
|
+
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
779
337
|
useMountEffect(() => () => {
|
|
780
338
|
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
781
339
|
});
|
|
782
|
-
const dismissTimelineEditorHint = useCallback(() => {
|
|
783
|
-
setTimelineEditorHintState(true);
|
|
784
|
-
setTimelineEditorHintDismissed(true);
|
|
785
|
-
}, []);
|
|
786
340
|
const handleTimelineToggleHotkey = useCallback(
|
|
787
341
|
(event: KeyboardEvent) => {
|
|
788
342
|
if (!shouldHandleTimelineToggleHotkey(event)) return;
|
|
@@ -843,9 +397,10 @@ export function StudioApp() {
|
|
|
843
397
|
return (
|
|
844
398
|
<CompositionThumbnail
|
|
845
399
|
previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
|
|
846
|
-
label={el
|
|
400
|
+
label={getTimelineElementLabel(el)}
|
|
847
401
|
labelColor={style.label}
|
|
848
402
|
accentColor={style.clip}
|
|
403
|
+
selector={el.selector}
|
|
849
404
|
seekTime={0}
|
|
850
405
|
duration={el.duration}
|
|
851
406
|
/>
|
|
@@ -858,11 +413,10 @@ export function StudioApp() {
|
|
|
858
413
|
return (
|
|
859
414
|
<CompositionThumbnail
|
|
860
415
|
previewUrl={activePreviewUrl}
|
|
861
|
-
label={el
|
|
416
|
+
label={getTimelineElementLabel(el)}
|
|
862
417
|
labelColor={style.label}
|
|
863
418
|
accentColor={style.clip}
|
|
864
419
|
selector={el.selector}
|
|
865
|
-
selectorIndex={el.selectorIndex}
|
|
866
420
|
seekTime={el.start}
|
|
867
421
|
duration={el.duration}
|
|
868
422
|
/>
|
|
@@ -896,7 +450,7 @@ export function StudioApp() {
|
|
|
896
450
|
<AudioWaveform
|
|
897
451
|
audioUrl={audioUrl}
|
|
898
452
|
waveformUrl={waveformUrl}
|
|
899
|
-
label={el
|
|
453
|
+
label={getTimelineElementLabel(el)}
|
|
900
454
|
labelColor={style.label}
|
|
901
455
|
/>
|
|
902
456
|
);
|
|
@@ -909,7 +463,7 @@ export function StudioApp() {
|
|
|
909
463
|
return (
|
|
910
464
|
<VideoThumbnail
|
|
911
465
|
videoSrc={mediaSrc}
|
|
912
|
-
label={el
|
|
466
|
+
label={getTimelineElementLabel(el)}
|
|
913
467
|
labelColor={style.label}
|
|
914
468
|
duration={el.duration}
|
|
915
469
|
/>
|
|
@@ -920,11 +474,10 @@ export function StudioApp() {
|
|
|
920
474
|
return (
|
|
921
475
|
<CompositionThumbnail
|
|
922
476
|
previewUrl={`/api/projects/${pid}/preview`}
|
|
923
|
-
label={el
|
|
477
|
+
label={getTimelineElementLabel(el)}
|
|
924
478
|
labelColor={style.label}
|
|
925
479
|
accentColor={style.clip}
|
|
926
480
|
selector={el.selector}
|
|
927
|
-
selectorIndex={el.selectorIndex}
|
|
928
481
|
seekTime={el.start}
|
|
929
482
|
duration={el.duration}
|
|
930
483
|
/>
|
|
@@ -937,31 +490,6 @@ export function StudioApp() {
|
|
|
937
490
|
);
|
|
938
491
|
const timelineToolbar = (
|
|
939
492
|
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
|
940
|
-
{timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
|
|
941
|
-
<div className="px-3 pt-3">
|
|
942
|
-
<div className="flex items-start justify-between gap-3 rounded-xl border border-studio-accent/20 bg-studio-accent/[0.07] px-3 py-3">
|
|
943
|
-
<div className="min-w-0">
|
|
944
|
-
<div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
|
|
945
|
-
<p className="mt-1 text-[11px] leading-5 text-neutral-300">
|
|
946
|
-
Drag clips to move timing, and drag clip edges to resize them when handles are
|
|
947
|
-
available. Hide the panel anytime and bring it back with{" "}
|
|
948
|
-
<span className="font-mono text-[10px] text-studio-accent">
|
|
949
|
-
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
950
|
-
</span>
|
|
951
|
-
.
|
|
952
|
-
</p>
|
|
953
|
-
</div>
|
|
954
|
-
<button
|
|
955
|
-
type="button"
|
|
956
|
-
onClick={dismissTimelineEditorHint}
|
|
957
|
-
className="flex-shrink-0 rounded-md border border-neutral-700 px-2 py-1 text-[10px] font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
|
|
958
|
-
>
|
|
959
|
-
Dismiss
|
|
960
|
-
</button>
|
|
961
|
-
</div>
|
|
962
|
-
</div>
|
|
963
|
-
)}
|
|
964
|
-
|
|
965
493
|
<div className="flex items-center justify-between px-3 py-2">
|
|
966
494
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
967
495
|
Timeline
|
|
@@ -1004,6 +532,28 @@ export function StudioApp() {
|
|
|
1004
532
|
>
|
|
1005
533
|
+
|
|
1006
534
|
</button>
|
|
535
|
+
<button
|
|
536
|
+
type="button"
|
|
537
|
+
onClick={toggleTimelineVisibility}
|
|
538
|
+
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
|
539
|
+
title={getTimelineToggleTitle(true)}
|
|
540
|
+
aria-label="Hide timeline editor"
|
|
541
|
+
>
|
|
542
|
+
<svg
|
|
543
|
+
width="14"
|
|
544
|
+
height="14"
|
|
545
|
+
viewBox="0 0 24 24"
|
|
546
|
+
fill="none"
|
|
547
|
+
stroke="currentColor"
|
|
548
|
+
strokeWidth="1.8"
|
|
549
|
+
strokeLinecap="round"
|
|
550
|
+
strokeLinejoin="round"
|
|
551
|
+
aria-hidden="true"
|
|
552
|
+
>
|
|
553
|
+
<path d="M5 7h14" />
|
|
554
|
+
<path d="m8 11 4 4 4-4" />
|
|
555
|
+
</svg>
|
|
556
|
+
</button>
|
|
1007
557
|
</div>
|
|
1008
558
|
</div>
|
|
1009
559
|
</div>
|
|
@@ -1017,20 +567,11 @@ export function StudioApp() {
|
|
|
1017
567
|
const projectIdRef = useRef(projectId);
|
|
1018
568
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
1019
569
|
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
|
1020
|
-
const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1021
|
-
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
1022
|
-
const lastPreviewClickRef = useRef<{ key: string; at: number } | null>(null);
|
|
1023
|
-
const domEditSaveTimestampRef = useRef(0);
|
|
1024
|
-
const domTextCommitVersionRef = useRef(0);
|
|
1025
570
|
|
|
1026
571
|
// Listen for external file changes (user editing HTML outside the editor).
|
|
1027
572
|
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
1028
|
-
// Suppress file-change events that echo back from a recent DOM edit save —
|
|
1029
|
-
// those changes are already applied to the iframe DOM and a full reload
|
|
1030
|
-
// would flash the preview.
|
|
1031
573
|
useMountEffect(() => {
|
|
1032
574
|
const handler = () => {
|
|
1033
|
-
if (Date.now() - domEditSaveTimestampRef.current < 1200) return;
|
|
1034
575
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1035
576
|
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
1036
577
|
};
|
|
@@ -1044,7 +585,6 @@ export function StudioApp() {
|
|
|
1044
585
|
return () => es.close();
|
|
1045
586
|
});
|
|
1046
587
|
projectIdRef.current = projectId;
|
|
1047
|
-
domEditSelectionRef.current = domEditSelection;
|
|
1048
588
|
|
|
1049
589
|
// Load file tree when projectId changes.
|
|
1050
590
|
// Note: This is one of the few places where useEffect with deps is acceptable —
|
|
@@ -1056,13 +596,10 @@ export function StudioApp() {
|
|
|
1056
596
|
let cancelled = false;
|
|
1057
597
|
fetch(`/api/projects/${projectId}`)
|
|
1058
598
|
.then((r) => r.json())
|
|
1059
|
-
.then((data: { files?: string[]
|
|
599
|
+
.then((data: { files?: string[] }) => {
|
|
1060
600
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1061
|
-
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
1062
601
|
})
|
|
1063
|
-
.catch(() => {
|
|
1064
|
-
if (!cancelled) setProjectDir(null);
|
|
1065
|
-
});
|
|
602
|
+
.catch(() => {});
|
|
1066
603
|
return () => {
|
|
1067
604
|
cancelled = true;
|
|
1068
605
|
};
|
|
@@ -1308,6 +845,42 @@ export function StudioApp() {
|
|
|
1308
845
|
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
1309
846
|
}, []);
|
|
1310
847
|
|
|
848
|
+
const handleCaptureFrameClick = useCallback(
|
|
849
|
+
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
850
|
+
if (!projectId) return;
|
|
851
|
+
event.preventDefault();
|
|
852
|
+
|
|
853
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
854
|
+
setCaptureFrameTime(currentTime);
|
|
855
|
+
const href = buildFrameCaptureUrl({
|
|
856
|
+
projectId,
|
|
857
|
+
compositionPath: activeCompPath,
|
|
858
|
+
currentTime,
|
|
859
|
+
});
|
|
860
|
+
const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
const response = await fetch(href, { cache: "no-store" });
|
|
864
|
+
if (!response.ok) {
|
|
865
|
+
throw new Error(`Capture failed (${response.status})`);
|
|
866
|
+
}
|
|
867
|
+
const blob = await response.blob();
|
|
868
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
869
|
+
const link = document.createElement("a");
|
|
870
|
+
link.href = blobUrl;
|
|
871
|
+
link.download = filename;
|
|
872
|
+
document.body.appendChild(link);
|
|
873
|
+
link.click();
|
|
874
|
+
link.remove();
|
|
875
|
+
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
|
876
|
+
} catch (err) {
|
|
877
|
+
const message = err instanceof Error ? err.message : "Capture failed";
|
|
878
|
+
showToast(message);
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
[activeCompPath, projectId, showToast],
|
|
882
|
+
);
|
|
883
|
+
|
|
1311
884
|
const handleTimelineElementDelete = useCallback(
|
|
1312
885
|
async (element: TimelineElement) => {
|
|
1313
886
|
const pid = projectIdRef.current;
|
|
@@ -1432,722 +1005,6 @@ export function StudioApp() {
|
|
|
1432
1005
|
[showToast],
|
|
1433
1006
|
);
|
|
1434
1007
|
|
|
1435
|
-
const handleBlockedDomMove = useCallback(
|
|
1436
|
-
(selection: DomEditSelection) => {
|
|
1437
|
-
const now = Date.now();
|
|
1438
|
-
if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
|
|
1439
|
-
lastBlockedDomMoveToastAtRef.current = now;
|
|
1440
|
-
showToast(
|
|
1441
|
-
selection.capabilities.canDetachFromLayout
|
|
1442
|
-
? "This layer is controlled by layout. Use Make movable in the panel to detach it."
|
|
1443
|
-
: (selection.capabilities.reasonIfDisabled ??
|
|
1444
|
-
"This element can’t be moved directly from the preview."),
|
|
1445
|
-
"info",
|
|
1446
|
-
);
|
|
1447
|
-
},
|
|
1448
|
-
[showToast],
|
|
1449
|
-
);
|
|
1450
|
-
|
|
1451
|
-
const applyDomSelection = useCallback(
|
|
1452
|
-
(selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
|
|
1453
|
-
setDomEditSelection(selection);
|
|
1454
|
-
setAgentPromptTagSnippet(undefined);
|
|
1455
|
-
setCopiedAgentPrompt(false);
|
|
1456
|
-
if (selection) {
|
|
1457
|
-
if (options?.revealPanel !== false) {
|
|
1458
|
-
setRightCollapsed(false);
|
|
1459
|
-
setRightPanelTab("design");
|
|
1460
|
-
}
|
|
1461
|
-
const nextSelectedTimelineId = findMatchingTimelineElementId(selection, timelineElements);
|
|
1462
|
-
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
setSelectedTimelineElementId(null);
|
|
1467
|
-
},
|
|
1468
|
-
[setSelectedTimelineElementId, timelineElements],
|
|
1469
|
-
);
|
|
1470
|
-
|
|
1471
|
-
const clearDomSelection = useCallback(() => {
|
|
1472
|
-
applyDomSelection(null, { revealPanel: false });
|
|
1473
|
-
}, [applyDomSelection]);
|
|
1474
|
-
|
|
1475
|
-
const buildDomSelectionFromTarget = useCallback(
|
|
1476
|
-
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
1477
|
-
if (isMasterView) {
|
|
1478
|
-
const mappedHost = findMappedCompositionHost(
|
|
1479
|
-
target,
|
|
1480
|
-
timelineElements,
|
|
1481
|
-
compIdToSrc,
|
|
1482
|
-
fileTree,
|
|
1483
|
-
);
|
|
1484
|
-
if (mappedHost) {
|
|
1485
|
-
const hostSelection = resolveDomEditSelection(mappedHost.host, {
|
|
1486
|
-
activeCompositionPath: activeCompPath,
|
|
1487
|
-
isMasterView,
|
|
1488
|
-
preferClipAncestor: options?.preferClipAncestor,
|
|
1489
|
-
});
|
|
1490
|
-
if (!hostSelection) return null;
|
|
1491
|
-
return {
|
|
1492
|
-
...hostSelection,
|
|
1493
|
-
compositionSrc: mappedHost.compositionSrc,
|
|
1494
|
-
isCompositionHost: true,
|
|
1495
|
-
capabilities: resolveDomEditCapabilities({
|
|
1496
|
-
selector: hostSelection.selector,
|
|
1497
|
-
tagName: hostSelection.tagName,
|
|
1498
|
-
className: hostSelection.element.className,
|
|
1499
|
-
inlineStyles: hostSelection.inlineStyles,
|
|
1500
|
-
computedStyles: hostSelection.computedStyles,
|
|
1501
|
-
isCompositionHost: true,
|
|
1502
|
-
isMasterView: true,
|
|
1503
|
-
}),
|
|
1504
|
-
} satisfies DomEditSelection;
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
return resolveDomEditSelection(target, {
|
|
1509
|
-
activeCompositionPath: activeCompPath,
|
|
1510
|
-
isMasterView,
|
|
1511
|
-
preferClipAncestor: options?.preferClipAncestor,
|
|
1512
|
-
});
|
|
1513
|
-
},
|
|
1514
|
-
[activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
|
|
1515
|
-
);
|
|
1516
|
-
|
|
1517
|
-
const preloadAgentPromptSnippet = useCallback(
|
|
1518
|
-
async (selection: DomEditSelection) => {
|
|
1519
|
-
const pid = projectIdRef.current;
|
|
1520
|
-
if (!pid) return;
|
|
1521
|
-
|
|
1522
|
-
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
1523
|
-
try {
|
|
1524
|
-
const response = await fetch(
|
|
1525
|
-
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1526
|
-
);
|
|
1527
|
-
if (!response.ok) return;
|
|
1528
|
-
|
|
1529
|
-
const data = (await response.json()) as { content?: string };
|
|
1530
|
-
const html = data.content;
|
|
1531
|
-
const tagSnippet =
|
|
1532
|
-
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
|
1533
|
-
|
|
1534
|
-
setAgentPromptTagSnippet((current) => {
|
|
1535
|
-
if (domEditSelectionRef.current !== selection) return current;
|
|
1536
|
-
return tagSnippet;
|
|
1537
|
-
});
|
|
1538
|
-
} catch {
|
|
1539
|
-
// Runtime outerHTML is still available as a synchronous copy fallback.
|
|
1540
|
-
}
|
|
1541
|
-
},
|
|
1542
|
-
[activeCompPath],
|
|
1543
|
-
);
|
|
1544
|
-
|
|
1545
|
-
const resolveImportedFontAsset = useCallback(
|
|
1546
|
-
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
1547
|
-
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
1548
|
-
if (!family) return null;
|
|
1549
|
-
const imported = importedFontAssetsRef.current.find(
|
|
1550
|
-
(font) => font.family.toLowerCase() === family.toLowerCase(),
|
|
1551
|
-
);
|
|
1552
|
-
if (imported) return imported;
|
|
1553
|
-
const asset = fileTree.find(
|
|
1554
|
-
(path) =>
|
|
1555
|
-
FONT_EXT.test(path) &&
|
|
1556
|
-
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
|
|
1557
|
-
);
|
|
1558
|
-
if (!asset) return null;
|
|
1559
|
-
return {
|
|
1560
|
-
family: fontFamilyFromAssetPath(asset),
|
|
1561
|
-
path: asset,
|
|
1562
|
-
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
1563
|
-
};
|
|
1564
|
-
},
|
|
1565
|
-
[fileTree, projectId],
|
|
1566
|
-
);
|
|
1567
|
-
|
|
1568
|
-
const persistDomEditOperations = useCallback(
|
|
1569
|
-
async (
|
|
1570
|
-
selection: DomEditSelection,
|
|
1571
|
-
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
1572
|
-
options?: {
|
|
1573
|
-
skipRefresh?: boolean;
|
|
1574
|
-
prepareContent?: (html: string, sourceFile: string) => string;
|
|
1575
|
-
shouldSave?: () => boolean;
|
|
1576
|
-
},
|
|
1577
|
-
) => {
|
|
1578
|
-
const pid = projectIdRef.current;
|
|
1579
|
-
if (!pid) throw new Error("No active project");
|
|
1580
|
-
if (options?.shouldSave && !options.shouldSave()) return;
|
|
1581
|
-
|
|
1582
|
-
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
1583
|
-
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
1584
|
-
if (!response.ok) {
|
|
1585
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
const data = (await response.json()) as { content?: string };
|
|
1589
|
-
const originalContent = data.content;
|
|
1590
|
-
if (typeof originalContent !== "string") {
|
|
1591
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
let patchedContent = originalContent;
|
|
1595
|
-
for (const operation of operations) {
|
|
1596
|
-
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
|
1597
|
-
}
|
|
1598
|
-
if (options?.prepareContent) {
|
|
1599
|
-
patchedContent = options.prepareContent(patchedContent, targetPath);
|
|
1600
|
-
}
|
|
1601
|
-
if (options?.shouldSave && !options.shouldSave()) return;
|
|
1602
|
-
|
|
1603
|
-
if (patchedContent === originalContent) {
|
|
1604
|
-
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
const saveResponse = await fetch(
|
|
1608
|
-
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1609
|
-
{
|
|
1610
|
-
method: "PUT",
|
|
1611
|
-
headers: { "Content-Type": "text/plain" },
|
|
1612
|
-
body: patchedContent,
|
|
1613
|
-
},
|
|
1614
|
-
);
|
|
1615
|
-
if (!saveResponse.ok) {
|
|
1616
|
-
throw new Error(`Failed to save ${targetPath}`);
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
if (editingPathRef.current === targetPath) {
|
|
1620
|
-
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
if (options?.skipRefresh) {
|
|
1624
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
1625
|
-
} else {
|
|
1626
|
-
setRefreshKey((k) => k + 1);
|
|
1627
|
-
}
|
|
1628
|
-
},
|
|
1629
|
-
[activeCompPath],
|
|
1630
|
-
);
|
|
1631
|
-
|
|
1632
|
-
const handleDomMoveCommit = useCallback(
|
|
1633
|
-
async (selection: DomEditSelection, next: { left: number; top: number }) => {
|
|
1634
|
-
await persistDomEditOperations(
|
|
1635
|
-
selection,
|
|
1636
|
-
[
|
|
1637
|
-
...buildDomEditMovePatchOperations(next.left, next.top),
|
|
1638
|
-
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1639
|
-
],
|
|
1640
|
-
{ skipRefresh: true },
|
|
1641
|
-
);
|
|
1642
|
-
},
|
|
1643
|
-
[persistDomEditOperations],
|
|
1644
|
-
);
|
|
1645
|
-
|
|
1646
|
-
const handleDomResizeCommit = useCallback(
|
|
1647
|
-
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
1648
|
-
if (shouldDetachOppositeEdges(selection)) {
|
|
1649
|
-
selection.element.style.right = "auto";
|
|
1650
|
-
selection.element.style.bottom = "auto";
|
|
1651
|
-
}
|
|
1652
|
-
await persistDomEditOperations(
|
|
1653
|
-
selection,
|
|
1654
|
-
[
|
|
1655
|
-
...buildDomEditResizePatchOperations(next.width, next.height),
|
|
1656
|
-
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1657
|
-
],
|
|
1658
|
-
{ skipRefresh: true },
|
|
1659
|
-
);
|
|
1660
|
-
},
|
|
1661
|
-
[persistDomEditOperations],
|
|
1662
|
-
);
|
|
1663
|
-
|
|
1664
|
-
const handleDomDetachFromLayout = useCallback(async () => {
|
|
1665
|
-
const selection = domEditSelection;
|
|
1666
|
-
if (!selection?.capabilities.canDetachFromLayout) return;
|
|
1667
|
-
|
|
1668
|
-
const doc = previewIframeRef.current?.contentDocument;
|
|
1669
|
-
const element = doc
|
|
1670
|
-
? findElementForSelection(doc, selection, selection.sourceFile)
|
|
1671
|
-
: selection.element;
|
|
1672
|
-
if (!element) {
|
|
1673
|
-
showToast("Could not find the selected layer in the preview.", "info");
|
|
1674
|
-
return;
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
const rect = measureDomDetachRect(element);
|
|
1678
|
-
const operations = buildDomEditDetachPatchOperations(rect);
|
|
1679
|
-
|
|
1680
|
-
for (const operation of operations) {
|
|
1681
|
-
element.style.setProperty(operation.property, operation.value);
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
await persistDomEditOperations(selection, operations, { skipRefresh: true });
|
|
1685
|
-
|
|
1686
|
-
const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
|
|
1687
|
-
if (refreshed) {
|
|
1688
|
-
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
1689
|
-
if (nextSelection) {
|
|
1690
|
-
applyDomSelection(nextSelection, { revealPanel: false });
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
showToast("Layer detached from layout. You can move it now.", "info");
|
|
1694
|
-
}, [
|
|
1695
|
-
applyDomSelection,
|
|
1696
|
-
buildDomSelectionFromTarget,
|
|
1697
|
-
domEditSelection,
|
|
1698
|
-
persistDomEditOperations,
|
|
1699
|
-
showToast,
|
|
1700
|
-
]);
|
|
1701
|
-
|
|
1702
|
-
const handleDomStyleCommit = useCallback(
|
|
1703
|
-
async (property: string, value: string) => {
|
|
1704
|
-
if (!domEditSelection) return;
|
|
1705
|
-
const isMoveStyle = isMoveStyleProperty(property);
|
|
1706
|
-
const isResizeStyle = isResizeStyleProperty(property);
|
|
1707
|
-
if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
|
|
1708
|
-
if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
|
|
1709
|
-
if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
|
|
1710
|
-
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
1711
|
-
const iframe = previewIframeRef.current;
|
|
1712
|
-
const doc = iframe?.contentDocument;
|
|
1713
|
-
if (doc) {
|
|
1714
|
-
const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
|
|
1715
|
-
if (el) {
|
|
1716
|
-
el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
|
|
1717
|
-
if (property === "font-family") {
|
|
1718
|
-
injectPreviewGoogleFont(doc, value);
|
|
1719
|
-
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
1720
|
-
}
|
|
1721
|
-
if (shouldDetachOppositeEdges(domEditSelection)) {
|
|
1722
|
-
if (property === "width") el.style.right = "auto";
|
|
1723
|
-
if (property === "height") el.style.bottom = "auto";
|
|
1724
|
-
}
|
|
1725
|
-
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
1726
|
-
el.style.setProperty("background-position", "center");
|
|
1727
|
-
el.style.setProperty("background-repeat", "no-repeat");
|
|
1728
|
-
el.style.setProperty("background-size", "contain");
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
const operations: PatchOperation[] = [
|
|
1733
|
-
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
|
1734
|
-
];
|
|
1735
|
-
if (property === "width") {
|
|
1736
|
-
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
|
|
1737
|
-
} else if (property === "height") {
|
|
1738
|
-
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
|
|
1739
|
-
} else if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
1740
|
-
operations.push(
|
|
1741
|
-
buildDomEditStylePatchOperation("background-position", "center"),
|
|
1742
|
-
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
|
1743
|
-
buildDomEditStylePatchOperation("background-size", "contain"),
|
|
1744
|
-
);
|
|
1745
|
-
}
|
|
1746
|
-
await persistDomEditOperations(domEditSelection, operations, {
|
|
1747
|
-
skipRefresh: true,
|
|
1748
|
-
prepareContent: importedFont
|
|
1749
|
-
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
1750
|
-
: undefined,
|
|
1751
|
-
});
|
|
1752
|
-
},
|
|
1753
|
-
[domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
|
|
1754
|
-
);
|
|
1755
|
-
|
|
1756
|
-
const handleDomTextCommit = useCallback(
|
|
1757
|
-
async (value: string, fieldKey?: string) => {
|
|
1758
|
-
if (!domEditSelection) return;
|
|
1759
|
-
if (!isTextEditableSelection(domEditSelection)) return;
|
|
1760
|
-
const commitVersion = domTextCommitVersionRef.current + 1;
|
|
1761
|
-
domTextCommitVersionRef.current = commitVersion;
|
|
1762
|
-
const nextTextFields =
|
|
1763
|
-
domEditSelection.textFields.length > 0
|
|
1764
|
-
? domEditSelection.textFields.map((field) =>
|
|
1765
|
-
field.key === fieldKey ? { ...field, value } : field,
|
|
1766
|
-
)
|
|
1767
|
-
: [];
|
|
1768
|
-
const nextContent =
|
|
1769
|
-
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
1770
|
-
? serializeDomEditTextFields(nextTextFields)
|
|
1771
|
-
: value;
|
|
1772
|
-
const iframe = previewIframeRef.current;
|
|
1773
|
-
const doc = iframe?.contentDocument;
|
|
1774
|
-
if (doc) {
|
|
1775
|
-
const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
|
|
1776
|
-
if (el) {
|
|
1777
|
-
if (
|
|
1778
|
-
nextTextFields.length > 1 ||
|
|
1779
|
-
nextTextFields.some((field) => field.source === "child")
|
|
1780
|
-
) {
|
|
1781
|
-
el.innerHTML = nextContent;
|
|
1782
|
-
} else {
|
|
1783
|
-
el.textContent = value;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
await persistDomEditOperations(
|
|
1788
|
-
domEditSelection,
|
|
1789
|
-
[buildDomEditTextPatchOperation(nextContent)],
|
|
1790
|
-
{
|
|
1791
|
-
skipRefresh: true,
|
|
1792
|
-
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
1793
|
-
},
|
|
1794
|
-
);
|
|
1795
|
-
if (domTextCommitVersionRef.current !== commitVersion) return;
|
|
1796
|
-
|
|
1797
|
-
if (doc) {
|
|
1798
|
-
const refreshed = findElementForSelection(
|
|
1799
|
-
doc,
|
|
1800
|
-
domEditSelection,
|
|
1801
|
-
domEditSelection.sourceFile,
|
|
1802
|
-
);
|
|
1803
|
-
if (refreshed) {
|
|
1804
|
-
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
1805
|
-
if (nextSelection) {
|
|
1806
|
-
applyDomSelection(nextSelection, { revealPanel: false });
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
},
|
|
1811
|
-
[applyDomSelection, buildDomSelectionFromTarget, domEditSelection, persistDomEditOperations],
|
|
1812
|
-
);
|
|
1813
|
-
|
|
1814
|
-
const commitDomTextFields = useCallback(
|
|
1815
|
-
async (
|
|
1816
|
-
selection: DomEditSelection,
|
|
1817
|
-
nextTextFields: DomEditTextField[],
|
|
1818
|
-
options?: { importedFont?: ImportedFontAsset | null },
|
|
1819
|
-
) => {
|
|
1820
|
-
const nextContent =
|
|
1821
|
-
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
1822
|
-
? serializeDomEditTextFields(nextTextFields)
|
|
1823
|
-
: (nextTextFields[0]?.value ?? "");
|
|
1824
|
-
|
|
1825
|
-
const iframe = previewIframeRef.current;
|
|
1826
|
-
const doc = iframe?.contentDocument;
|
|
1827
|
-
if (doc) {
|
|
1828
|
-
const el = findElementForSelection(doc, selection, selection.sourceFile);
|
|
1829
|
-
if (el) {
|
|
1830
|
-
if (
|
|
1831
|
-
nextTextFields.length > 1 ||
|
|
1832
|
-
nextTextFields.some((field) => field.source === "child")
|
|
1833
|
-
) {
|
|
1834
|
-
el.innerHTML = nextContent;
|
|
1835
|
-
} else {
|
|
1836
|
-
el.textContent = nextContent;
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
const importedFont = options?.importedFont ?? null;
|
|
1842
|
-
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
|
1843
|
-
skipRefresh: true,
|
|
1844
|
-
prepareContent: importedFont
|
|
1845
|
-
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
1846
|
-
: undefined,
|
|
1847
|
-
});
|
|
1848
|
-
|
|
1849
|
-
if (doc) {
|
|
1850
|
-
const refreshed = findElementForSelection(doc, selection, selection.sourceFile);
|
|
1851
|
-
if (refreshed) {
|
|
1852
|
-
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
1853
|
-
if (nextSelection) {
|
|
1854
|
-
applyDomSelection(nextSelection, { revealPanel: false });
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
},
|
|
1859
|
-
[applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
|
|
1860
|
-
);
|
|
1861
|
-
|
|
1862
|
-
const handleDomTextFieldStyleCommit = useCallback(
|
|
1863
|
-
async (fieldKey: string, property: string, value: string) => {
|
|
1864
|
-
if (!domEditSelection) return;
|
|
1865
|
-
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
1866
|
-
if (!field) return;
|
|
1867
|
-
|
|
1868
|
-
if (field.source === "self") {
|
|
1869
|
-
await handleDomStyleCommit(property, value);
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
const normalizedValue = normalizeDomEditStyleValue(property, value);
|
|
1874
|
-
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
1875
|
-
if (property === "font-family") {
|
|
1876
|
-
const doc = previewIframeRef.current?.contentDocument;
|
|
1877
|
-
if (doc) {
|
|
1878
|
-
injectPreviewGoogleFont(doc, normalizedValue);
|
|
1879
|
-
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
const nextTextFields = domEditSelection.textFields.map((entry) =>
|
|
1883
|
-
entry.key === fieldKey
|
|
1884
|
-
? {
|
|
1885
|
-
...entry,
|
|
1886
|
-
inlineStyles: {
|
|
1887
|
-
...entry.inlineStyles,
|
|
1888
|
-
[property]: normalizedValue,
|
|
1889
|
-
},
|
|
1890
|
-
computedStyles: {
|
|
1891
|
-
...entry.computedStyles,
|
|
1892
|
-
[property]: normalizedValue,
|
|
1893
|
-
},
|
|
1894
|
-
}
|
|
1895
|
-
: entry,
|
|
1896
|
-
);
|
|
1897
|
-
|
|
1898
|
-
await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
|
|
1899
|
-
},
|
|
1900
|
-
[commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
|
|
1901
|
-
);
|
|
1902
|
-
|
|
1903
|
-
const handleDomAddTextField = useCallback(
|
|
1904
|
-
async (afterFieldKey?: string) => {
|
|
1905
|
-
if (!domEditSelection) return null;
|
|
1906
|
-
if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
|
|
1907
|
-
|
|
1908
|
-
const insertionIndex = domEditSelection.textFields.findIndex(
|
|
1909
|
-
(field) => field.key === afterFieldKey,
|
|
1910
|
-
);
|
|
1911
|
-
const baseField =
|
|
1912
|
-
domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
|
|
1913
|
-
domEditSelection.textFields[0];
|
|
1914
|
-
const nextField = buildDefaultDomEditTextField(baseField);
|
|
1915
|
-
const nextTextFields = [...domEditSelection.textFields];
|
|
1916
|
-
nextTextFields.splice(
|
|
1917
|
-
insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
|
|
1918
|
-
0,
|
|
1919
|
-
nextField,
|
|
1920
|
-
);
|
|
1921
|
-
|
|
1922
|
-
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
1923
|
-
return nextField.key;
|
|
1924
|
-
},
|
|
1925
|
-
[commitDomTextFields, domEditSelection],
|
|
1926
|
-
);
|
|
1927
|
-
|
|
1928
|
-
const handleDomRemoveTextField = useCallback(
|
|
1929
|
-
async (fieldKey: string) => {
|
|
1930
|
-
if (!domEditSelection) return;
|
|
1931
|
-
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
1932
|
-
if (!field) return;
|
|
1933
|
-
|
|
1934
|
-
if (field.source === "self") {
|
|
1935
|
-
await handleDomTextCommit("", fieldKey);
|
|
1936
|
-
return;
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
|
|
1940
|
-
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
1941
|
-
},
|
|
1942
|
-
[commitDomTextFields, domEditSelection, handleDomTextCommit],
|
|
1943
|
-
);
|
|
1944
|
-
|
|
1945
|
-
const handleAskAgent = useCallback(() => {
|
|
1946
|
-
if (!domEditSelection) return;
|
|
1947
|
-
setAgentPromptTagSnippet(undefined);
|
|
1948
|
-
void preloadAgentPromptSnippet(domEditSelection);
|
|
1949
|
-
setAgentModalOpen(true);
|
|
1950
|
-
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
1951
|
-
|
|
1952
|
-
const handleAgentModalSubmit = useCallback(
|
|
1953
|
-
async (userInstruction: string) => {
|
|
1954
|
-
if (!domEditSelection) return;
|
|
1955
|
-
|
|
1956
|
-
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
1957
|
-
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
|
1958
|
-
const prompt = buildElementAgentPrompt({
|
|
1959
|
-
selection: domEditSelection,
|
|
1960
|
-
currentTime,
|
|
1961
|
-
tagSnippet,
|
|
1962
|
-
userInstruction,
|
|
1963
|
-
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
1964
|
-
});
|
|
1965
|
-
|
|
1966
|
-
const copied = await copyTextToClipboard(prompt);
|
|
1967
|
-
if (!copied) {
|
|
1968
|
-
showToast("Could not copy prompt to clipboard.", "error");
|
|
1969
|
-
return;
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
setAgentModalOpen(false);
|
|
1973
|
-
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
1974
|
-
setCopiedAgentPrompt(true);
|
|
1975
|
-
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
1976
|
-
},
|
|
1977
|
-
[activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
|
|
1978
|
-
);
|
|
1979
|
-
|
|
1980
|
-
const handlePreviewIframeRef = useCallback(
|
|
1981
|
-
(iframe: HTMLIFrameElement | null) => {
|
|
1982
|
-
previewIframeRef.current = iframe;
|
|
1983
|
-
setPreviewIframe(iframe);
|
|
1984
|
-
syncPreviewTimelineHotkey(iframe);
|
|
1985
|
-
consoleErrorsRef.current = [];
|
|
1986
|
-
setConsoleErrors(null);
|
|
1987
|
-
},
|
|
1988
|
-
[syncPreviewTimelineHotkey],
|
|
1989
|
-
);
|
|
1990
|
-
|
|
1991
|
-
const handlePreviewCanvasMouseDown = useCallback(
|
|
1992
|
-
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
1993
|
-
const iframe = previewIframeRef.current;
|
|
1994
|
-
if (!iframe || captionEditMode) return;
|
|
1995
|
-
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
1996
|
-
if (!target) {
|
|
1997
|
-
lastPreviewClickRef.current = null;
|
|
1998
|
-
applyDomSelection(null, { revealPanel: false });
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2001
|
-
e.preventDefault();
|
|
2002
|
-
e.stopPropagation();
|
|
2003
|
-
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
2004
|
-
preferClipAncestor: options?.preferClipAncestor ?? true,
|
|
2005
|
-
});
|
|
2006
|
-
if (!nextSelection) {
|
|
2007
|
-
lastPreviewClickRef.current = null;
|
|
2008
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2009
|
-
return;
|
|
2010
|
-
}
|
|
2011
|
-
if (nextSelection.isCompositionHost && isMasterView && nextSelection.compositionSrc) {
|
|
2012
|
-
const key = getDomSelectionClickKey(nextSelection);
|
|
2013
|
-
const last = lastPreviewClickRef.current;
|
|
2014
|
-
const now = Date.now();
|
|
2015
|
-
if (last && last.key === key && now - last.at < 350) {
|
|
2016
|
-
lastPreviewClickRef.current = null;
|
|
2017
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2018
|
-
setActiveCompPath(nextSelection.compositionSrc);
|
|
2019
|
-
return;
|
|
2020
|
-
}
|
|
2021
|
-
lastPreviewClickRef.current = { key, at: now };
|
|
2022
|
-
} else {
|
|
2023
|
-
lastPreviewClickRef.current = null;
|
|
2024
|
-
}
|
|
2025
|
-
applyDomSelection(nextSelection);
|
|
2026
|
-
},
|
|
2027
|
-
[applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
|
|
2028
|
-
);
|
|
2029
|
-
|
|
2030
|
-
const handlePreviewCanvasDoubleClick = useCallback(
|
|
2031
|
-
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
2032
|
-
const iframe = previewIframeRef.current;
|
|
2033
|
-
if (!iframe || captionEditMode) return;
|
|
2034
|
-
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
2035
|
-
if (!target) return;
|
|
2036
|
-
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
2037
|
-
preferClipAncestor: false,
|
|
2038
|
-
});
|
|
2039
|
-
if (!nextSelection?.isCompositionHost || !isMasterView || !nextSelection.compositionSrc) {
|
|
2040
|
-
return;
|
|
2041
|
-
}
|
|
2042
|
-
e.preventDefault();
|
|
2043
|
-
e.stopPropagation();
|
|
2044
|
-
lastPreviewClickRef.current = null;
|
|
2045
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2046
|
-
setActiveCompPath(nextSelection.compositionSrc);
|
|
2047
|
-
},
|
|
2048
|
-
[applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
|
|
2049
|
-
);
|
|
2050
|
-
|
|
2051
|
-
const handleSelectedOverlayDoubleClick = useCallback(() => {
|
|
2052
|
-
const selection = domEditSelectionRef.current;
|
|
2053
|
-
if (!selection?.isCompositionHost || !selection.compositionSrc) return;
|
|
2054
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2055
|
-
setActiveCompPath(selection.compositionSrc);
|
|
2056
|
-
}, [applyDomSelection]);
|
|
2057
|
-
|
|
2058
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
2059
|
-
useEffect(() => {
|
|
2060
|
-
if (!previewIframe || captionEditMode) return;
|
|
2061
|
-
|
|
2062
|
-
const syncSelectionFromDocument = () => {
|
|
2063
|
-
const currentSelection = domEditSelectionRef.current;
|
|
2064
|
-
if (!currentSelection) return;
|
|
2065
|
-
let doc: Document | null = null;
|
|
2066
|
-
try {
|
|
2067
|
-
doc = previewIframe.contentDocument;
|
|
2068
|
-
} catch {
|
|
2069
|
-
return;
|
|
2070
|
-
}
|
|
2071
|
-
if (!doc) return;
|
|
2072
|
-
|
|
2073
|
-
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
|
2074
|
-
if (!nextElement) {
|
|
2075
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2076
|
-
return;
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2079
|
-
const nextSelection = buildDomSelectionFromTarget(nextElement);
|
|
2080
|
-
if (nextSelection) {
|
|
2081
|
-
applyDomSelection(nextSelection, { revealPanel: false });
|
|
2082
|
-
}
|
|
2083
|
-
};
|
|
2084
|
-
|
|
2085
|
-
const attachErrorCapture = () => {
|
|
2086
|
-
try {
|
|
2087
|
-
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
|
|
2088
|
-
if (!win) return;
|
|
2089
|
-
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
2090
|
-
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
2091
|
-
const origError = win.console.error.bind(win.console);
|
|
2092
|
-
win.console.error = function (...args: unknown[]) {
|
|
2093
|
-
origError(...args);
|
|
2094
|
-
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
|
2095
|
-
if (text.includes("favicon")) return;
|
|
2096
|
-
consoleErrorsRef.current = [
|
|
2097
|
-
...consoleErrorsRef.current,
|
|
2098
|
-
{ severity: "error", message: text },
|
|
2099
|
-
];
|
|
2100
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
2101
|
-
};
|
|
2102
|
-
win.addEventListener("error", (e: ErrorEvent) => {
|
|
2103
|
-
const text = e.message || String(e);
|
|
2104
|
-
consoleErrorsRef.current = [
|
|
2105
|
-
...consoleErrorsRef.current,
|
|
2106
|
-
{ severity: "error", message: text },
|
|
2107
|
-
];
|
|
2108
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
2109
|
-
});
|
|
2110
|
-
} catch {
|
|
2111
|
-
// same-origin only
|
|
2112
|
-
}
|
|
2113
|
-
};
|
|
2114
|
-
|
|
2115
|
-
attachErrorCapture();
|
|
2116
|
-
syncSelectionFromDocument();
|
|
2117
|
-
|
|
2118
|
-
const handleLoad = () => {
|
|
2119
|
-
consoleErrorsRef.current = [];
|
|
2120
|
-
setConsoleErrors(null);
|
|
2121
|
-
attachErrorCapture();
|
|
2122
|
-
syncSelectionFromDocument();
|
|
2123
|
-
};
|
|
2124
|
-
|
|
2125
|
-
previewIframe.addEventListener("load", handleLoad);
|
|
2126
|
-
return () => {
|
|
2127
|
-
previewIframe.removeEventListener("load", handleLoad);
|
|
2128
|
-
};
|
|
2129
|
-
}, [
|
|
2130
|
-
activeCompPath,
|
|
2131
|
-
applyDomSelection,
|
|
2132
|
-
buildDomSelectionFromTarget,
|
|
2133
|
-
captionEditMode,
|
|
2134
|
-
previewIframe,
|
|
2135
|
-
]);
|
|
2136
|
-
|
|
2137
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
2138
|
-
useEffect(() => {
|
|
2139
|
-
if (!captionEditMode) return;
|
|
2140
|
-
applyDomSelection(null, { revealPanel: false });
|
|
2141
|
-
}, [applyDomSelection, captionEditMode]);
|
|
2142
|
-
|
|
2143
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
2144
|
-
useEffect(
|
|
2145
|
-
() => () => {
|
|
2146
|
-
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
2147
|
-
},
|
|
2148
|
-
[],
|
|
2149
|
-
);
|
|
2150
|
-
|
|
2151
1008
|
const refreshFileTree = useCallback(async () => {
|
|
2152
1009
|
const pid = projectIdRef.current;
|
|
2153
1010
|
if (!pid) return;
|
|
@@ -2281,7 +1138,6 @@ export function StudioApp() {
|
|
|
2281
1138
|
duration: normalizedDuration,
|
|
2282
1139
|
track: placement.track,
|
|
2283
1140
|
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
2284
|
-
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
|
2285
1141
|
}),
|
|
2286
1142
|
);
|
|
2287
1143
|
|
|
@@ -2466,33 +1322,7 @@ export function StudioApp() {
|
|
|
2466
1322
|
|
|
2467
1323
|
const handleImportFiles = useCallback(
|
|
2468
1324
|
async (files: FileList | File[], dir?: string) => {
|
|
2469
|
-
|
|
2470
|
-
},
|
|
2471
|
-
[uploadProjectFiles],
|
|
2472
|
-
);
|
|
2473
|
-
|
|
2474
|
-
const handleImportFonts = useCallback(
|
|
2475
|
-
async (files: FileList | File[]) => {
|
|
2476
|
-
const uploaded = await uploadProjectFiles(
|
|
2477
|
-
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
|
2478
|
-
"assets/fonts",
|
|
2479
|
-
);
|
|
2480
|
-
const pid = projectIdRef.current;
|
|
2481
|
-
const imported = uploaded
|
|
2482
|
-
.filter((asset) => FONT_EXT.test(asset))
|
|
2483
|
-
.map((asset) => ({
|
|
2484
|
-
family: fontFamilyFromAssetPath(asset),
|
|
2485
|
-
path: asset,
|
|
2486
|
-
url: `/api/projects/${pid}/preview/${asset}`,
|
|
2487
|
-
}));
|
|
2488
|
-
importedFontAssetsRef.current = [
|
|
2489
|
-
...imported,
|
|
2490
|
-
...importedFontAssetsRef.current.filter(
|
|
2491
|
-
(existing) =>
|
|
2492
|
-
!imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
|
|
2493
|
-
),
|
|
2494
|
-
];
|
|
2495
|
-
return imported;
|
|
1325
|
+
void uploadProjectFiles(Array.from(files), dir);
|
|
2496
1326
|
},
|
|
2497
1327
|
[uploadProjectFiles],
|
|
2498
1328
|
);
|
|
@@ -2564,17 +1394,6 @@ export function StudioApp() {
|
|
|
2564
1394
|
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
2565
1395
|
[fileTree],
|
|
2566
1396
|
);
|
|
2567
|
-
const fontAssets = useMemo<ImportedFontAsset[]>(
|
|
2568
|
-
() =>
|
|
2569
|
-
assets
|
|
2570
|
-
.filter((asset) => FONT_EXT.test(asset))
|
|
2571
|
-
.map((asset) => ({
|
|
2572
|
-
family: fontFamilyFromAssetPath(asset),
|
|
2573
|
-
path: asset,
|
|
2574
|
-
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
2575
|
-
})),
|
|
2576
|
-
[assets, projectId],
|
|
2577
|
-
);
|
|
2578
1397
|
|
|
2579
1398
|
if (resolving || !projectId) {
|
|
2580
1399
|
return (
|
|
@@ -2620,65 +1439,21 @@ export function StudioApp() {
|
|
|
2620
1439
|
</div>
|
|
2621
1440
|
{/* Right: toolbar buttons */}
|
|
2622
1441
|
<div className="flex items-center gap-1.5">
|
|
2623
|
-
<
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
title=
|
|
2631
|
-
|
|
2632
|
-
<svg
|
|
2633
|
-
width="14"
|
|
2634
|
-
height="14"
|
|
2635
|
-
viewBox="0 0 24 24"
|
|
2636
|
-
fill="none"
|
|
2637
|
-
stroke="currentColor"
|
|
2638
|
-
strokeWidth="1.5"
|
|
2639
|
-
strokeLinecap="round"
|
|
2640
|
-
strokeLinejoin="round"
|
|
2641
|
-
>
|
|
2642
|
-
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
2643
|
-
<path d="M9 3v18" />
|
|
2644
|
-
</svg>
|
|
2645
|
-
</button>
|
|
2646
|
-
<button
|
|
2647
|
-
type="button"
|
|
2648
|
-
onClick={toggleTimelineVisibility}
|
|
2649
|
-
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
2650
|
-
timelineVisible
|
|
2651
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
2652
|
-
: "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
|
|
2653
|
-
}`}
|
|
2654
|
-
title={getTimelineToggleTitle(timelineVisible)}
|
|
2655
|
-
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
|
1442
|
+
<a
|
|
1443
|
+
href={captureFrameHref}
|
|
1444
|
+
download={captureFrameFilename}
|
|
1445
|
+
onClick={handleCaptureFrameClick}
|
|
1446
|
+
onFocus={refreshCaptureFrameTime}
|
|
1447
|
+
onPointerDown={refreshCaptureFrameTime}
|
|
1448
|
+
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
|
1449
|
+
title="Capture current frame"
|
|
1450
|
+
aria-label="Capture current frame"
|
|
2656
1451
|
>
|
|
2657
|
-
<
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
viewBox="0 0 24 24"
|
|
2661
|
-
fill="none"
|
|
2662
|
-
stroke="currentColor"
|
|
2663
|
-
strokeWidth="1.5"
|
|
2664
|
-
strokeLinecap="round"
|
|
2665
|
-
>
|
|
2666
|
-
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
2667
|
-
<line x1="3" y1="9" x2="21" y2="9" />
|
|
2668
|
-
<line x1="3" y1="5" x2="21" y2="5" />
|
|
2669
|
-
</svg>
|
|
2670
|
-
<span>Timeline</span>
|
|
2671
|
-
</button>
|
|
1452
|
+
<Camera size={14} />
|
|
1453
|
+
<span>Capture</span>
|
|
1454
|
+
</a>
|
|
2672
1455
|
<button
|
|
2673
|
-
onClick={() =>
|
|
2674
|
-
if (rightCollapsed || rightPanelTab !== "design") {
|
|
2675
|
-
setRightPanelTab("design");
|
|
2676
|
-
setRightCollapsed(false);
|
|
2677
|
-
return;
|
|
2678
|
-
}
|
|
2679
|
-
clearDomSelection();
|
|
2680
|
-
setRightCollapsed(true);
|
|
2681
|
-
}}
|
|
1456
|
+
onClick={() => setRightCollapsed((v) => !v)}
|
|
2682
1457
|
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
2683
1458
|
!rightCollapsed
|
|
2684
1459
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
@@ -2696,7 +1471,8 @@ export function StudioApp() {
|
|
|
2696
1471
|
<circle cx="12" cy="12" r="10" />
|
|
2697
1472
|
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
2698
1473
|
</svg>
|
|
2699
|
-
|
|
1474
|
+
Renders
|
|
1475
|
+
{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
2700
1476
|
</button>
|
|
2701
1477
|
</div>
|
|
2702
1478
|
</div>
|
|
@@ -2704,7 +1480,32 @@ export function StudioApp() {
|
|
|
2704
1480
|
{/* Main content: sidebar + preview + right panel */}
|
|
2705
1481
|
<div className="flex flex-1 min-h-0">
|
|
2706
1482
|
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
|
|
2707
|
-
{
|
|
1483
|
+
{leftCollapsed ? (
|
|
1484
|
+
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
|
|
1485
|
+
<button
|
|
1486
|
+
type="button"
|
|
1487
|
+
onClick={toggleLeftSidebar}
|
|
1488
|
+
className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
1489
|
+
title="Show sidebar"
|
|
1490
|
+
aria-label="Show sidebar"
|
|
1491
|
+
>
|
|
1492
|
+
<svg
|
|
1493
|
+
width="14"
|
|
1494
|
+
height="14"
|
|
1495
|
+
viewBox="0 0 24 24"
|
|
1496
|
+
fill="none"
|
|
1497
|
+
stroke="currentColor"
|
|
1498
|
+
strokeWidth="1.5"
|
|
1499
|
+
strokeLinecap="round"
|
|
1500
|
+
strokeLinejoin="round"
|
|
1501
|
+
aria-hidden="true"
|
|
1502
|
+
>
|
|
1503
|
+
<path d="M5 4v16" />
|
|
1504
|
+
<path d="m10 7 5 5-5 5" />
|
|
1505
|
+
</svg>
|
|
1506
|
+
</button>
|
|
1507
|
+
</div>
|
|
1508
|
+
) : (
|
|
2708
1509
|
<LeftSidebar
|
|
2709
1510
|
width={leftWidth}
|
|
2710
1511
|
projectId={projectId}
|
|
@@ -2751,6 +1552,7 @@ export function StudioApp() {
|
|
|
2751
1552
|
}
|
|
2752
1553
|
onLint={handleLint}
|
|
2753
1554
|
linting={linting}
|
|
1555
|
+
onToggleCollapse={toggleLeftSidebar}
|
|
2754
1556
|
/>
|
|
2755
1557
|
)}
|
|
2756
1558
|
|
|
@@ -2787,25 +1589,56 @@ export function StudioApp() {
|
|
|
2787
1589
|
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
|
2788
1590
|
setActiveCompPath(compPath);
|
|
2789
1591
|
}}
|
|
2790
|
-
onIframeRef={
|
|
1592
|
+
onIframeRef={(iframe) => {
|
|
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
|
+
}}
|
|
2791
1640
|
previewOverlay={
|
|
2792
|
-
captionEditMode ?
|
|
2793
|
-
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
2794
|
-
) : (
|
|
2795
|
-
<DomEditOverlay
|
|
2796
|
-
iframeRef={previewIframeRef}
|
|
2797
|
-
selection={
|
|
2798
|
-
!rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
|
|
2799
|
-
}
|
|
2800
|
-
allowCanvasMovement={false}
|
|
2801
|
-
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
2802
|
-
onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
|
|
2803
|
-
onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
|
|
2804
|
-
onBlockedMove={handleBlockedDomMove}
|
|
2805
|
-
onMoveCommit={handleDomMoveCommit}
|
|
2806
|
-
onResizeCommit={handleDomResizeCommit}
|
|
2807
|
-
/>
|
|
2808
|
-
)
|
|
1641
|
+
captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
|
|
2809
1642
|
}
|
|
2810
1643
|
timelineFooter={
|
|
2811
1644
|
captionEditMode ? (
|
|
@@ -2846,68 +1679,14 @@ export function StudioApp() {
|
|
|
2846
1679
|
{captionEditMode ? (
|
|
2847
1680
|
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
2848
1681
|
) : (
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
2858
|
-
}`}
|
|
2859
|
-
>
|
|
2860
|
-
Design
|
|
2861
|
-
</button>
|
|
2862
|
-
<button
|
|
2863
|
-
type="button"
|
|
2864
|
-
onClick={() => setRightPanelTab("renders")}
|
|
2865
|
-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
2866
|
-
rightPanelTab === "renders"
|
|
2867
|
-
? "bg-neutral-800 text-white"
|
|
2868
|
-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
2869
|
-
}`}
|
|
2870
|
-
>
|
|
2871
|
-
{renderQueue.jobs.length > 0
|
|
2872
|
-
? `Renders (${renderQueue.jobs.length})`
|
|
2873
|
-
: "Renders"}
|
|
2874
|
-
</button>
|
|
2875
|
-
</div>
|
|
2876
|
-
<div className="min-h-0 flex-1">
|
|
2877
|
-
{rightPanelTab === "design" ? (
|
|
2878
|
-
<PropertyPanel
|
|
2879
|
-
projectId={projectId}
|
|
2880
|
-
assets={assets}
|
|
2881
|
-
element={domEditSelection}
|
|
2882
|
-
copiedAgentPrompt={copiedAgentPrompt}
|
|
2883
|
-
onClearSelection={clearDomSelection}
|
|
2884
|
-
onSetStyle={handleDomStyleCommit}
|
|
2885
|
-
onSetText={handleDomTextCommit}
|
|
2886
|
-
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
|
2887
|
-
onAddTextField={handleDomAddTextField}
|
|
2888
|
-
onRemoveTextField={handleDomRemoveTextField}
|
|
2889
|
-
onDetachFromLayout={handleDomDetachFromLayout}
|
|
2890
|
-
onAskAgent={handleAskAgent}
|
|
2891
|
-
onCopyAgentInstruction={handleAgentModalSubmit}
|
|
2892
|
-
onImportAssets={handleImportFiles}
|
|
2893
|
-
fontAssets={fontAssets}
|
|
2894
|
-
onImportFonts={handleImportFonts}
|
|
2895
|
-
allowLayoutDetach={false}
|
|
2896
|
-
/>
|
|
2897
|
-
) : (
|
|
2898
|
-
<RenderQueue
|
|
2899
|
-
jobs={renderQueue.jobs}
|
|
2900
|
-
projectId={projectId}
|
|
2901
|
-
onDelete={renderQueue.deleteRender}
|
|
2902
|
-
onClearCompleted={renderQueue.clearCompleted}
|
|
2903
|
-
onStartRender={(format, quality) =>
|
|
2904
|
-
renderQueue.startRender(30, quality, format)
|
|
2905
|
-
}
|
|
2906
|
-
isRendering={renderQueue.isRendering}
|
|
2907
|
-
/>
|
|
2908
|
-
)}
|
|
2909
|
-
</div>
|
|
2910
|
-
</>
|
|
1682
|
+
<RenderQueue
|
|
1683
|
+
jobs={renderQueue.jobs}
|
|
1684
|
+
projectId={projectId}
|
|
1685
|
+
onDelete={renderQueue.deleteRender}
|
|
1686
|
+
onClearCompleted={renderQueue.clearCompleted}
|
|
1687
|
+
onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
|
|
1688
|
+
isRendering={renderQueue.isRendering}
|
|
1689
|
+
/>
|
|
2911
1690
|
)}
|
|
2912
1691
|
</div>
|
|
2913
1692
|
</>
|
|
@@ -2928,15 +1707,6 @@ export function StudioApp() {
|
|
|
2928
1707
|
/>
|
|
2929
1708
|
)}
|
|
2930
1709
|
|
|
2931
|
-
{/* Ask agent modal */}
|
|
2932
|
-
{agentModalOpen && domEditSelection && (
|
|
2933
|
-
<AskAgentModal
|
|
2934
|
-
selectionLabel={domEditSelection.label}
|
|
2935
|
-
onSubmit={handleAgentModalSubmit}
|
|
2936
|
-
onClose={() => setAgentModalOpen(false)}
|
|
2937
|
-
/>
|
|
2938
|
-
)}
|
|
2939
|
-
|
|
2940
1710
|
{/* Global drag-drop overlay */}
|
|
2941
1711
|
{globalDragOver && (
|
|
2942
1712
|
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|