@hyperframes/studio 0.4.38 → 0.5.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +1431 -196
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +445 -0
- package/src/components/editor/PropertyPanel.tsx +2466 -206
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +537 -0
- package/src/components/editor/domEditing.ts +762 -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/nle/NLELayout.tsx +17 -47
- package/src/components/nle/NLEPreview.tsx +50 -5
- 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 +34 -55
- package/src/icons/SystemIcons.tsx +0 -2
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -70
- package/src/player/components/PlayerControls.tsx +44 -3
- package/src/player/components/Timeline.test.ts +12 -0
- package/src/player/components/Timeline.tsx +51 -20
- package/src/player/components/TimelineClip.tsx +20 -7
- package/src/player/components/timelineEditing.test.ts +2 -4
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
- package/src/player/hooks/useTimelinePlayer.ts +74 -32
- package/src/player/lib/time.test.ts +1 -11
- package/src/player/lib/time.ts +0 -6
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -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/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/dist/assets/index-18P_dZeo.js +0 -93
- package/dist/assets/index-BLrgRQSu.css +0 -1
- package/src/utils/frameCapture.test.ts +0 -26
- package/src/utils/frameCapture.ts +0 -38
package/src/App.tsx
CHANGED
|
@@ -1,31 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useState,
|
|
3
|
-
useCallback,
|
|
4
|
-
useRef,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
type MouseEvent,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from "react";
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
|
10
2
|
import { useMountEffect } from "./hooks/useMountEffect";
|
|
11
3
|
import { NLELayout } from "./components/nle/NLELayout";
|
|
12
4
|
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
13
5
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
14
6
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
15
7
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
16
|
-
import { CompositionThumbnail, VideoThumbnail,
|
|
8
|
+
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
|
|
17
9
|
import { AudioWaveform } from "./player/components/AudioWaveform";
|
|
18
10
|
import type { TimelineElement } from "./player";
|
|
19
11
|
import { LintModal } from "./components/LintModal";
|
|
20
12
|
import type { LintFinding } from "./components/LintModal";
|
|
21
13
|
import { MediaPreview } from "./components/MediaPreview";
|
|
22
|
-
import { isMediaFile } from "./utils/mediaTypes";
|
|
14
|
+
import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
|
|
23
15
|
import {
|
|
24
16
|
buildTimelineAssetId,
|
|
25
17
|
buildTimelineAssetInsertHtml,
|
|
26
18
|
buildTimelineFileDropPlacements,
|
|
27
19
|
getTimelineAssetKind,
|
|
28
20
|
insertTimelineAssetIntoSource,
|
|
21
|
+
resolveTimelineAssetInitialGeometry,
|
|
29
22
|
resolveTimelineAssetSrc,
|
|
30
23
|
type TimelineAssetKind,
|
|
31
24
|
} from "./utils/timelineAssetDrop";
|
|
@@ -35,7 +28,13 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
35
28
|
import { useCaptionStore } from "./captions/store";
|
|
36
29
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
37
30
|
import { parseCaptionComposition } from "./captions/parser";
|
|
38
|
-
import {
|
|
31
|
+
import { copyTextToClipboard } from "./utils/clipboard";
|
|
32
|
+
import {
|
|
33
|
+
applyPatchByTarget,
|
|
34
|
+
readAttributeByTarget,
|
|
35
|
+
readTagSnippetByTarget,
|
|
36
|
+
type PatchOperation,
|
|
37
|
+
} from "./utils/sourcePatcher";
|
|
39
38
|
import {
|
|
40
39
|
buildTrackZIndexMap,
|
|
41
40
|
formatTimelineAttributeNumber,
|
|
@@ -45,11 +44,36 @@ import {
|
|
|
45
44
|
getTimelineZoomPercent,
|
|
46
45
|
} from "./player/components/timelineZoom";
|
|
47
46
|
import {
|
|
47
|
+
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
48
|
+
getTimelineEditorHintDismissed,
|
|
48
49
|
getTimelineToggleTitle,
|
|
50
|
+
setTimelineEditorHintDismissed,
|
|
49
51
|
shouldHandleTimelineToggleHotkey,
|
|
50
52
|
} from "./utils/timelineDiscovery";
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
+
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
54
|
+
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
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";
|
|
53
77
|
|
|
54
78
|
interface EditingFile {
|
|
55
79
|
path: string;
|
|
@@ -61,6 +85,435 @@ interface AppToast {
|
|
|
61
85
|
tone: "error" | "info";
|
|
62
86
|
}
|
|
63
87
|
|
|
88
|
+
type RightPanelTab = "design" | "renders";
|
|
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
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
64
517
|
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
65
518
|
image: 3,
|
|
66
519
|
video: 5,
|
|
@@ -139,6 +592,7 @@ export function StudioApp() {
|
|
|
139
592
|
});
|
|
140
593
|
|
|
141
594
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
595
|
+
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
142
596
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
143
597
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
144
598
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -152,6 +606,12 @@ export function StudioApp() {
|
|
|
152
606
|
const [rightWidth, setRightWidth] = useState(400);
|
|
153
607
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
154
608
|
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);
|
|
155
615
|
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
|
|
156
616
|
// This is a subscription to external events (postMessage from runtime) — useEffect
|
|
157
617
|
// is appropriate here. The runtime fires "state"/"timeline" messages after all
|
|
@@ -274,10 +734,14 @@ export function StudioApp() {
|
|
|
274
734
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
275
735
|
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
276
736
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
277
|
-
const [
|
|
737
|
+
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
|
|
738
|
+
getTimelineEditorHintDismissed,
|
|
739
|
+
);
|
|
278
740
|
const dragCounterRef = useRef(0);
|
|
279
741
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
280
742
|
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
743
|
+
const lastBlockedDomMoveToastAtRef = useRef(0);
|
|
744
|
+
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
281
745
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
282
746
|
const panelDragRef = useRef<{
|
|
283
747
|
side: "left" | "right";
|
|
@@ -289,11 +753,14 @@ export function StudioApp() {
|
|
|
289
753
|
const activePreviewUrl = activeCompPath
|
|
290
754
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
291
755
|
: null;
|
|
756
|
+
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
292
757
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
293
758
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
294
759
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
295
760
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
761
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
296
762
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
763
|
+
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
297
764
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
298
765
|
const effectiveTimelineDuration = useMemo(() => {
|
|
299
766
|
const maxEnd =
|
|
@@ -309,29 +776,13 @@ export function StudioApp() {
|
|
|
309
776
|
const toggleTimelineVisibility = useCallback(() => {
|
|
310
777
|
setTimelineVisible((visible) => !visible);
|
|
311
778
|
}, []);
|
|
312
|
-
const toggleLeftSidebar = useCallback(() => {
|
|
313
|
-
setLeftCollapsed((collapsed) => !collapsed);
|
|
314
|
-
}, []);
|
|
315
|
-
const refreshCaptureFrameTime = useCallback(() => {
|
|
316
|
-
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
317
|
-
}, []);
|
|
318
|
-
|
|
319
|
-
useMountEffect(() => {
|
|
320
|
-
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
321
|
-
return liveTime.subscribe(setCaptureFrameTime);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
const captureFrameHref = projectId
|
|
325
|
-
? buildFrameCaptureUrl({
|
|
326
|
-
projectId,
|
|
327
|
-
compositionPath: activeCompPath,
|
|
328
|
-
currentTime: captureFrameTime,
|
|
329
|
-
})
|
|
330
|
-
: "#";
|
|
331
|
-
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
332
779
|
useMountEffect(() => () => {
|
|
333
780
|
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
334
781
|
});
|
|
782
|
+
const dismissTimelineEditorHint = useCallback(() => {
|
|
783
|
+
setTimelineEditorHintState(true);
|
|
784
|
+
setTimelineEditorHintDismissed(true);
|
|
785
|
+
}, []);
|
|
335
786
|
const handleTimelineToggleHotkey = useCallback(
|
|
336
787
|
(event: KeyboardEvent) => {
|
|
337
788
|
if (!shouldHandleTimelineToggleHotkey(event)) return;
|
|
@@ -395,7 +846,6 @@ export function StudioApp() {
|
|
|
395
846
|
label={el.id || el.tag}
|
|
396
847
|
labelColor={style.label}
|
|
397
848
|
accentColor={style.clip}
|
|
398
|
-
selector={el.selector}
|
|
399
849
|
seekTime={0}
|
|
400
850
|
duration={el.duration}
|
|
401
851
|
/>
|
|
@@ -412,6 +862,7 @@ export function StudioApp() {
|
|
|
412
862
|
labelColor={style.label}
|
|
413
863
|
accentColor={style.clip}
|
|
414
864
|
selector={el.selector}
|
|
865
|
+
selectorIndex={el.selectorIndex}
|
|
415
866
|
seekTime={el.start}
|
|
416
867
|
duration={el.duration}
|
|
417
868
|
/>
|
|
@@ -473,6 +924,7 @@ export function StudioApp() {
|
|
|
473
924
|
labelColor={style.label}
|
|
474
925
|
accentColor={style.clip}
|
|
475
926
|
selector={el.selector}
|
|
927
|
+
selectorIndex={el.selectorIndex}
|
|
476
928
|
seekTime={el.start}
|
|
477
929
|
duration={el.duration}
|
|
478
930
|
/>
|
|
@@ -485,6 +937,31 @@ export function StudioApp() {
|
|
|
485
937
|
);
|
|
486
938
|
const timelineToolbar = (
|
|
487
939
|
<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
|
+
|
|
488
965
|
<div className="flex items-center justify-between px-3 py-2">
|
|
489
966
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
490
967
|
Timeline
|
|
@@ -527,28 +1004,6 @@ export function StudioApp() {
|
|
|
527
1004
|
>
|
|
528
1005
|
+
|
|
529
1006
|
</button>
|
|
530
|
-
<button
|
|
531
|
-
type="button"
|
|
532
|
-
onClick={toggleTimelineVisibility}
|
|
533
|
-
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"
|
|
534
|
-
title={getTimelineToggleTitle(true)}
|
|
535
|
-
aria-label="Hide timeline editor"
|
|
536
|
-
>
|
|
537
|
-
<svg
|
|
538
|
-
width="14"
|
|
539
|
-
height="14"
|
|
540
|
-
viewBox="0 0 24 24"
|
|
541
|
-
fill="none"
|
|
542
|
-
stroke="currentColor"
|
|
543
|
-
strokeWidth="1.8"
|
|
544
|
-
strokeLinecap="round"
|
|
545
|
-
strokeLinejoin="round"
|
|
546
|
-
aria-hidden="true"
|
|
547
|
-
>
|
|
548
|
-
<path d="M5 7h14" />
|
|
549
|
-
<path d="m8 11 4 4 4-4" />
|
|
550
|
-
</svg>
|
|
551
|
-
</button>
|
|
552
1007
|
</div>
|
|
553
1008
|
</div>
|
|
554
1009
|
</div>
|
|
@@ -562,11 +1017,20 @@ export function StudioApp() {
|
|
|
562
1017
|
const projectIdRef = useRef(projectId);
|
|
563
1018
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
564
1019
|
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);
|
|
565
1025
|
|
|
566
1026
|
// Listen for external file changes (user editing HTML outside the editor).
|
|
567
1027
|
// 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.
|
|
568
1031
|
useMountEffect(() => {
|
|
569
1032
|
const handler = () => {
|
|
1033
|
+
if (Date.now() - domEditSaveTimestampRef.current < 1200) return;
|
|
570
1034
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
571
1035
|
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
572
1036
|
};
|
|
@@ -580,6 +1044,7 @@ export function StudioApp() {
|
|
|
580
1044
|
return () => es.close();
|
|
581
1045
|
});
|
|
582
1046
|
projectIdRef.current = projectId;
|
|
1047
|
+
domEditSelectionRef.current = domEditSelection;
|
|
583
1048
|
|
|
584
1049
|
// Load file tree when projectId changes.
|
|
585
1050
|
// Note: This is one of the few places where useEffect with deps is acceptable —
|
|
@@ -591,10 +1056,13 @@ export function StudioApp() {
|
|
|
591
1056
|
let cancelled = false;
|
|
592
1057
|
fetch(`/api/projects/${projectId}`)
|
|
593
1058
|
.then((r) => r.json())
|
|
594
|
-
.then((data: { files?: string[] }) => {
|
|
1059
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
595
1060
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1061
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
596
1062
|
})
|
|
597
|
-
.catch(() => {
|
|
1063
|
+
.catch(() => {
|
|
1064
|
+
if (!cancelled) setProjectDir(null);
|
|
1065
|
+
});
|
|
598
1066
|
return () => {
|
|
599
1067
|
cancelled = true;
|
|
600
1068
|
};
|
|
@@ -840,42 +1308,6 @@ export function StudioApp() {
|
|
|
840
1308
|
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
841
1309
|
}, []);
|
|
842
1310
|
|
|
843
|
-
const handleCaptureFrameClick = useCallback(
|
|
844
|
-
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
845
|
-
if (!projectId) return;
|
|
846
|
-
event.preventDefault();
|
|
847
|
-
|
|
848
|
-
const currentTime = usePlayerStore.getState().currentTime;
|
|
849
|
-
setCaptureFrameTime(currentTime);
|
|
850
|
-
const href = buildFrameCaptureUrl({
|
|
851
|
-
projectId,
|
|
852
|
-
compositionPath: activeCompPath,
|
|
853
|
-
currentTime,
|
|
854
|
-
});
|
|
855
|
-
const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
|
|
856
|
-
|
|
857
|
-
try {
|
|
858
|
-
const response = await fetch(href, { cache: "no-store" });
|
|
859
|
-
if (!response.ok) {
|
|
860
|
-
throw new Error(`Capture failed (${response.status})`);
|
|
861
|
-
}
|
|
862
|
-
const blob = await response.blob();
|
|
863
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
864
|
-
const link = document.createElement("a");
|
|
865
|
-
link.href = blobUrl;
|
|
866
|
-
link.download = filename;
|
|
867
|
-
document.body.appendChild(link);
|
|
868
|
-
link.click();
|
|
869
|
-
link.remove();
|
|
870
|
-
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
|
871
|
-
} catch (err) {
|
|
872
|
-
const message = err instanceof Error ? err.message : "Capture failed";
|
|
873
|
-
showToast(message);
|
|
874
|
-
}
|
|
875
|
-
},
|
|
876
|
-
[activeCompPath, projectId, showToast],
|
|
877
|
-
);
|
|
878
|
-
|
|
879
1311
|
const handleTimelineElementDelete = useCallback(
|
|
880
1312
|
async (element: TimelineElement) => {
|
|
881
1313
|
const pid = projectIdRef.current;
|
|
@@ -1000,6 +1432,722 @@ export function StudioApp() {
|
|
|
1000
1432
|
[showToast],
|
|
1001
1433
|
);
|
|
1002
1434
|
|
|
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
|
+
|
|
1003
2151
|
const refreshFileTree = useCallback(async () => {
|
|
1004
2152
|
const pid = projectIdRef.current;
|
|
1005
2153
|
if (!pid) return;
|
|
@@ -1133,6 +2281,7 @@ export function StudioApp() {
|
|
|
1133
2281
|
duration: normalizedDuration,
|
|
1134
2282
|
track: placement.track,
|
|
1135
2283
|
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
2284
|
+
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
|
1136
2285
|
}),
|
|
1137
2286
|
);
|
|
1138
2287
|
|
|
@@ -1317,7 +2466,33 @@ export function StudioApp() {
|
|
|
1317
2466
|
|
|
1318
2467
|
const handleImportFiles = useCallback(
|
|
1319
2468
|
async (files: FileList | File[], dir?: string) => {
|
|
1320
|
-
|
|
2469
|
+
return uploadProjectFiles(Array.from(files), dir);
|
|
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;
|
|
1321
2496
|
},
|
|
1322
2497
|
[uploadProjectFiles],
|
|
1323
2498
|
);
|
|
@@ -1389,6 +2564,17 @@ export function StudioApp() {
|
|
|
1389
2564
|
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
1390
2565
|
[fileTree],
|
|
1391
2566
|
);
|
|
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
|
+
);
|
|
1392
2578
|
|
|
1393
2579
|
if (resolving || !projectId) {
|
|
1394
2580
|
return (
|
|
@@ -1434,21 +2620,65 @@ export function StudioApp() {
|
|
|
1434
2620
|
</div>
|
|
1435
2621
|
{/* Right: toolbar buttons */}
|
|
1436
2622
|
<div className="flex items-center gap-1.5">
|
|
1437
|
-
<
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
title="
|
|
1445
|
-
|
|
2623
|
+
<button
|
|
2624
|
+
onClick={() => setLeftCollapsed((v) => !v)}
|
|
2625
|
+
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
|
2626
|
+
!leftCollapsed
|
|
2627
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
2628
|
+
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
|
2629
|
+
}`}
|
|
2630
|
+
title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
|
|
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"}
|
|
1446
2656
|
>
|
|
1447
|
-
<
|
|
1448
|
-
|
|
1449
|
-
|
|
2657
|
+
<svg
|
|
2658
|
+
width="14"
|
|
2659
|
+
height="14"
|
|
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>
|
|
1450
2672
|
<button
|
|
1451
|
-
onClick={() =>
|
|
2673
|
+
onClick={() => {
|
|
2674
|
+
if (rightCollapsed || rightPanelTab !== "design") {
|
|
2675
|
+
setRightPanelTab("design");
|
|
2676
|
+
setRightCollapsed(false);
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
clearDomSelection();
|
|
2680
|
+
setRightCollapsed(true);
|
|
2681
|
+
}}
|
|
1452
2682
|
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
1453
2683
|
!rightCollapsed
|
|
1454
2684
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
@@ -1466,8 +2696,7 @@ export function StudioApp() {
|
|
|
1466
2696
|
<circle cx="12" cy="12" r="10" />
|
|
1467
2697
|
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
1468
2698
|
</svg>
|
|
1469
|
-
|
|
1470
|
-
{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
2699
|
+
Inspector
|
|
1471
2700
|
</button>
|
|
1472
2701
|
</div>
|
|
1473
2702
|
</div>
|
|
@@ -1475,32 +2704,7 @@ export function StudioApp() {
|
|
|
1475
2704
|
{/* Main content: sidebar + preview + right panel */}
|
|
1476
2705
|
<div className="flex flex-1 min-h-0">
|
|
1477
2706
|
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
|
|
1478
|
-
{leftCollapsed
|
|
1479
|
-
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
|
|
1480
|
-
<button
|
|
1481
|
-
type="button"
|
|
1482
|
-
onClick={toggleLeftSidebar}
|
|
1483
|
-
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"
|
|
1484
|
-
title="Show sidebar"
|
|
1485
|
-
aria-label="Show sidebar"
|
|
1486
|
-
>
|
|
1487
|
-
<svg
|
|
1488
|
-
width="14"
|
|
1489
|
-
height="14"
|
|
1490
|
-
viewBox="0 0 24 24"
|
|
1491
|
-
fill="none"
|
|
1492
|
-
stroke="currentColor"
|
|
1493
|
-
strokeWidth="1.5"
|
|
1494
|
-
strokeLinecap="round"
|
|
1495
|
-
strokeLinejoin="round"
|
|
1496
|
-
aria-hidden="true"
|
|
1497
|
-
>
|
|
1498
|
-
<path d="M5 4v16" />
|
|
1499
|
-
<path d="m10 7 5 5-5 5" />
|
|
1500
|
-
</svg>
|
|
1501
|
-
</button>
|
|
1502
|
-
</div>
|
|
1503
|
-
) : (
|
|
2707
|
+
{!leftCollapsed && (
|
|
1504
2708
|
<LeftSidebar
|
|
1505
2709
|
width={leftWidth}
|
|
1506
2710
|
projectId={projectId}
|
|
@@ -1547,7 +2751,6 @@ export function StudioApp() {
|
|
|
1547
2751
|
}
|
|
1548
2752
|
onLint={handleLint}
|
|
1549
2753
|
linting={linting}
|
|
1550
|
-
onToggleCollapse={toggleLeftSidebar}
|
|
1551
2754
|
/>
|
|
1552
2755
|
)}
|
|
1553
2756
|
|
|
@@ -1584,56 +2787,25 @@ export function StudioApp() {
|
|
|
1584
2787
|
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
|
1585
2788
|
setActiveCompPath(compPath);
|
|
1586
2789
|
}}
|
|
1587
|
-
onIframeRef={
|
|
1588
|
-
previewIframeRef.current = iframe;
|
|
1589
|
-
syncPreviewTimelineHotkey(iframe);
|
|
1590
|
-
consoleErrorsRef.current = [];
|
|
1591
|
-
setConsoleErrors(null);
|
|
1592
|
-
if (!iframe) return;
|
|
1593
|
-
|
|
1594
|
-
// Attach error capture after each iframe load (content resets on navigation)
|
|
1595
|
-
const attachErrorCapture = () => {
|
|
1596
|
-
try {
|
|
1597
|
-
const win = iframe.contentWindow as (Window & typeof globalThis) | null;
|
|
1598
|
-
if (!win) return;
|
|
1599
|
-
// Guard against double-patching
|
|
1600
|
-
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
1601
|
-
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
1602
|
-
const origError = win.console.error.bind(win.console);
|
|
1603
|
-
win.console.error = function (...args: unknown[]) {
|
|
1604
|
-
origError(...args);
|
|
1605
|
-
const text = args
|
|
1606
|
-
.map((a) => (a instanceof Error ? a.message : String(a)))
|
|
1607
|
-
.join(" ");
|
|
1608
|
-
if (text.includes("favicon")) return;
|
|
1609
|
-
consoleErrorsRef.current = [
|
|
1610
|
-
...consoleErrorsRef.current,
|
|
1611
|
-
{ severity: "error", message: text },
|
|
1612
|
-
];
|
|
1613
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
1614
|
-
};
|
|
1615
|
-
win.addEventListener("error", (e: ErrorEvent) => {
|
|
1616
|
-
const text = e.message || String(e);
|
|
1617
|
-
consoleErrorsRef.current = [
|
|
1618
|
-
...consoleErrorsRef.current,
|
|
1619
|
-
{ severity: "error", message: text },
|
|
1620
|
-
];
|
|
1621
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
1622
|
-
});
|
|
1623
|
-
} catch {
|
|
1624
|
-
// cross-origin — can't attach
|
|
1625
|
-
}
|
|
1626
|
-
};
|
|
1627
|
-
// Attach now (iframe may already be loaded) and on future loads
|
|
1628
|
-
attachErrorCapture();
|
|
1629
|
-
iframe.addEventListener("load", () => {
|
|
1630
|
-
consoleErrorsRef.current = [];
|
|
1631
|
-
setConsoleErrors(null);
|
|
1632
|
-
attachErrorCapture();
|
|
1633
|
-
});
|
|
1634
|
-
}}
|
|
2790
|
+
onIframeRef={handlePreviewIframeRef}
|
|
1635
2791
|
previewOverlay={
|
|
1636
|
-
captionEditMode ?
|
|
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
|
+
)
|
|
1637
2809
|
}
|
|
1638
2810
|
timelineFooter={
|
|
1639
2811
|
captionEditMode ? (
|
|
@@ -1674,14 +2846,68 @@ export function StudioApp() {
|
|
|
1674
2846
|
{captionEditMode ? (
|
|
1675
2847
|
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
1676
2848
|
) : (
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
2849
|
+
<>
|
|
2850
|
+
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
|
2851
|
+
<button
|
|
2852
|
+
type="button"
|
|
2853
|
+
onClick={() => setRightPanelTab("design")}
|
|
2854
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
2855
|
+
rightPanelTab === "design"
|
|
2856
|
+
? "bg-neutral-800 text-white"
|
|
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
|
+
</>
|
|
1685
2911
|
)}
|
|
1686
2912
|
</div>
|
|
1687
2913
|
</>
|
|
@@ -1702,6 +2928,15 @@ export function StudioApp() {
|
|
|
1702
2928
|
/>
|
|
1703
2929
|
)}
|
|
1704
2930
|
|
|
2931
|
+
{/* Ask agent modal */}
|
|
2932
|
+
{agentModalOpen && domEditSelection && (
|
|
2933
|
+
<AskAgentModal
|
|
2934
|
+
selectionLabel={domEditSelection.label}
|
|
2935
|
+
onSubmit={handleAgentModalSubmit}
|
|
2936
|
+
onClose={() => setAgentModalOpen(false)}
|
|
2937
|
+
/>
|
|
2938
|
+
)}
|
|
2939
|
+
|
|
1705
2940
|
{/* Global drag-drop overlay */}
|
|
1706
2941
|
{globalDragOver && (
|
|
1707
2942
|
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|