@hyperframes/studio 0.4.24 → 0.5.0-alpha.2
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-BExHzIDS.js +105 -0
- package/dist/assets/index-BpcIkyVP.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +1327 -76
- package/src/components/editor/DomEditOverlay.tsx +410 -0
- package/src/components/editor/PropertyPanel.tsx +2462 -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 +427 -0
- package/src/components/editor/domEditing.ts +733 -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 +9 -4
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +38 -33
- package/src/player/components/Player.tsx +18 -70
- package/src/player/components/Timeline.test.ts +0 -1
- package/src/player/components/Timeline.tsx +0 -3
- package/src/player/components/TimelineClip.tsx +20 -7
- package/src/player/components/timelineEditing.test.ts +0 -2
- package/src/player/components/timelineEditing.ts +0 -2
- package/src/player/hooks/useTimelinePlayer.ts +0 -17
- 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-CAscydDF.js +0 -115
- package/dist/assets/index-dpgHnQGg.css +0 -1
package/src/App.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
|
2
2
|
import { useMountEffect } from "./hooks/useMountEffect";
|
|
3
3
|
import { NLELayout } from "./components/nle/NLELayout";
|
|
4
|
-
import { TimelineEditorNotice } from "./components/nle/TimelineEditorNotice";
|
|
5
4
|
import { SourceEditor } from "./components/editor/SourceEditor";
|
|
6
5
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
7
6
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
@@ -12,13 +11,14 @@ import type { TimelineElement } from "./player";
|
|
|
12
11
|
import { LintModal } from "./components/LintModal";
|
|
13
12
|
import type { LintFinding } from "./components/LintModal";
|
|
14
13
|
import { MediaPreview } from "./components/MediaPreview";
|
|
15
|
-
import { isMediaFile } from "./utils/mediaTypes";
|
|
14
|
+
import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
|
|
16
15
|
import {
|
|
17
16
|
buildTimelineAssetId,
|
|
18
17
|
buildTimelineAssetInsertHtml,
|
|
19
18
|
buildTimelineFileDropPlacements,
|
|
20
19
|
getTimelineAssetKind,
|
|
21
20
|
insertTimelineAssetIntoSource,
|
|
21
|
+
resolveTimelineAssetInitialGeometry,
|
|
22
22
|
resolveTimelineAssetSrc,
|
|
23
23
|
type TimelineAssetKind,
|
|
24
24
|
} from "./utils/timelineAssetDrop";
|
|
@@ -28,7 +28,12 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
28
28
|
import { useCaptionStore } from "./captions/store";
|
|
29
29
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
30
30
|
import { parseCaptionComposition } from "./captions/parser";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
applyPatchByTarget,
|
|
33
|
+
readAttributeByTarget,
|
|
34
|
+
readTagSnippetByTarget,
|
|
35
|
+
type PatchOperation,
|
|
36
|
+
} from "./utils/sourcePatcher";
|
|
32
37
|
import {
|
|
33
38
|
buildTrackZIndexMap,
|
|
34
39
|
formatTimelineAttributeNumber,
|
|
@@ -38,11 +43,36 @@ import {
|
|
|
38
43
|
getTimelineZoomPercent,
|
|
39
44
|
} from "./player/components/timelineZoom";
|
|
40
45
|
import {
|
|
46
|
+
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
41
47
|
getTimelineEditorHintDismissed,
|
|
42
48
|
getTimelineToggleTitle,
|
|
43
49
|
setTimelineEditorHintDismissed,
|
|
44
50
|
shouldHandleTimelineToggleHotkey,
|
|
45
51
|
} from "./utils/timelineDiscovery";
|
|
52
|
+
import { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
53
|
+
import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
|
|
54
|
+
import {
|
|
55
|
+
fontFamilyFromAssetPath,
|
|
56
|
+
importedFontFaceCss,
|
|
57
|
+
type ImportedFontAsset,
|
|
58
|
+
} from "./components/editor/fontAssets";
|
|
59
|
+
import { DomEditOverlay } from "./components/editor/DomEditOverlay";
|
|
60
|
+
import {
|
|
61
|
+
buildDefaultDomEditTextField,
|
|
62
|
+
buildDomEditDetachPatchOperations,
|
|
63
|
+
buildDomEditMovePatchOperations,
|
|
64
|
+
buildDomEditResizePatchOperations,
|
|
65
|
+
buildDomEditStylePatchOperation,
|
|
66
|
+
buildDomEditTextPatchOperation,
|
|
67
|
+
buildElementAgentPrompt,
|
|
68
|
+
findElementForSelection,
|
|
69
|
+
isTextEditableSelection,
|
|
70
|
+
serializeDomEditTextFields,
|
|
71
|
+
resolveDomEditCapabilities,
|
|
72
|
+
resolveDomEditSelection,
|
|
73
|
+
type DomEditTextField,
|
|
74
|
+
type DomEditSelection,
|
|
75
|
+
} from "./components/editor/domEditing";
|
|
46
76
|
|
|
47
77
|
interface EditingFile {
|
|
48
78
|
path: string;
|
|
@@ -54,6 +84,418 @@ interface AppToast {
|
|
|
54
84
|
tone: "error" | "info";
|
|
55
85
|
}
|
|
56
86
|
|
|
87
|
+
type RightPanelTab = "design" | "renders";
|
|
88
|
+
|
|
89
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
90
|
+
"inherit",
|
|
91
|
+
"initial",
|
|
92
|
+
"revert",
|
|
93
|
+
"revert-layer",
|
|
94
|
+
"serif",
|
|
95
|
+
"sans-serif",
|
|
96
|
+
"monospace",
|
|
97
|
+
"cursive",
|
|
98
|
+
"fantasy",
|
|
99
|
+
"system-ui",
|
|
100
|
+
"ui-sans-serif",
|
|
101
|
+
"ui-serif",
|
|
102
|
+
"ui-monospace",
|
|
103
|
+
"ui-rounded",
|
|
104
|
+
"emoji",
|
|
105
|
+
"math",
|
|
106
|
+
"fangsong",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
function primaryFontFamilyFromCss(value: string): string {
|
|
110
|
+
const first = value.split(",")[0] ?? "";
|
|
111
|
+
return first.trim().replace(/^["']|["']$/g, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
|
115
|
+
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
|
116
|
+
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
|
117
|
+
|
|
118
|
+
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
119
|
+
if (doc.getElementById(id)) return;
|
|
120
|
+
|
|
121
|
+
const link = doc.createElement("link");
|
|
122
|
+
link.id = id;
|
|
123
|
+
link.rel = "stylesheet";
|
|
124
|
+
link.href = googleFontStylesheetUrl(family);
|
|
125
|
+
doc.head.appendChild(link);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function primaryFontFamilyValue(value: string): string {
|
|
129
|
+
return (
|
|
130
|
+
value
|
|
131
|
+
.split(",")[0]
|
|
132
|
+
?.trim()
|
|
133
|
+
.replace(/^["']|["']$/g, "")
|
|
134
|
+
.trim() ?? ""
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
|
139
|
+
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
140
|
+
if (doc.getElementById(id)) return;
|
|
141
|
+
const style = doc.createElement("style");
|
|
142
|
+
style.id = id;
|
|
143
|
+
style.textContent = importedFontFaceCss(asset);
|
|
144
|
+
doc.head.appendChild(style);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeProjectAssetPath(value: string): string {
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
150
|
+
return decodeURIComponent(maybeUrl)
|
|
151
|
+
.replace(/\\/g, "/")
|
|
152
|
+
.replace(/^\.?\//, "");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
156
|
+
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
|
157
|
+
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
|
158
|
+
|
|
159
|
+
fromParts.pop();
|
|
160
|
+
|
|
161
|
+
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
162
|
+
fromParts.shift();
|
|
163
|
+
targetParts.shift();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ensureImportedFontFace(
|
|
170
|
+
html: string,
|
|
171
|
+
asset: ImportedFontAsset,
|
|
172
|
+
sourceFile: string,
|
|
173
|
+
): string {
|
|
174
|
+
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
175
|
+
if (html.includes(css)) return html;
|
|
176
|
+
|
|
177
|
+
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
178
|
+
const styleMatch = styleRe.exec(html);
|
|
179
|
+
if (styleMatch) {
|
|
180
|
+
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
181
|
+
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
185
|
+
if (/<\/head>/i.test(html)) {
|
|
186
|
+
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
|
187
|
+
}
|
|
188
|
+
return `${styleTag}\n${html}`;
|
|
189
|
+
}
|
|
190
|
+
function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
191
|
+
const trimmed = value.trim();
|
|
192
|
+
if (!trimmed) return trimmed;
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
|
|
196
|
+
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
197
|
+
) {
|
|
198
|
+
return `${trimmed}px`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return trimmed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isImageBackgroundValue(value: string): boolean {
|
|
205
|
+
return /^url\(/i.test(value.trim());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
|
|
209
|
+
return Boolean(
|
|
210
|
+
selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildOppositeEdgePatchOperations(
|
|
215
|
+
selection: DomEditSelection,
|
|
216
|
+
dimension: "width" | "height" | "both",
|
|
217
|
+
): PatchOperation[] {
|
|
218
|
+
if (!shouldDetachOppositeEdges(selection)) return [];
|
|
219
|
+
const operations: PatchOperation[] = [];
|
|
220
|
+
if (dimension === "width" || dimension === "both") {
|
|
221
|
+
operations.push({ type: "inline-style", property: "right", value: "auto" });
|
|
222
|
+
}
|
|
223
|
+
if (dimension === "height" || dimension === "both") {
|
|
224
|
+
operations.push({ type: "inline-style", property: "bottom", value: "auto" });
|
|
225
|
+
}
|
|
226
|
+
return operations;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
230
|
+
if (!target || typeof target !== "object") return null;
|
|
231
|
+
const maybeNode = target as {
|
|
232
|
+
nodeType?: number;
|
|
233
|
+
parentElement?: Element | null;
|
|
234
|
+
};
|
|
235
|
+
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
|
236
|
+
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
|
237
|
+
return maybeNode.parentElement as HTMLElement;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function findMatchingTimelineElementId(
|
|
243
|
+
selection: Pick<
|
|
244
|
+
DomEditSelection,
|
|
245
|
+
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
246
|
+
>,
|
|
247
|
+
elements: TimelineElement[],
|
|
248
|
+
): string | null {
|
|
249
|
+
for (const element of elements) {
|
|
250
|
+
if (selection.id && element.domId === selection.id) {
|
|
251
|
+
return element.key ?? element.id;
|
|
252
|
+
}
|
|
253
|
+
if (
|
|
254
|
+
selection.isCompositionHost &&
|
|
255
|
+
selection.compositionSrc &&
|
|
256
|
+
element.compositionSrc === selection.compositionSrc
|
|
257
|
+
) {
|
|
258
|
+
return element.key ?? element.id;
|
|
259
|
+
}
|
|
260
|
+
if (
|
|
261
|
+
selection.selector &&
|
|
262
|
+
element.selector === selection.selector &&
|
|
263
|
+
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
264
|
+
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
265
|
+
) {
|
|
266
|
+
return element.key ?? element.id;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findMappedCompositionHost(
|
|
274
|
+
target: HTMLElement,
|
|
275
|
+
timelineElements: TimelineElement[],
|
|
276
|
+
compIdToSrc: Map<string, string>,
|
|
277
|
+
fileTree: string[],
|
|
278
|
+
): { host: HTMLElement; compositionSrc: string } | null {
|
|
279
|
+
const rootCompositionId =
|
|
280
|
+
target.ownerDocument
|
|
281
|
+
.querySelector("[data-composition-id]")
|
|
282
|
+
?.getAttribute("data-composition-id") ?? null;
|
|
283
|
+
|
|
284
|
+
let nestedCurrent: HTMLElement | null = target;
|
|
285
|
+
while (nestedCurrent) {
|
|
286
|
+
const nestedCompId = nestedCurrent.getAttribute("data-composition-id");
|
|
287
|
+
if (nestedCompId && nestedCompId !== rootCompositionId) {
|
|
288
|
+
const hostCandidate = nestedCurrent.parentElement?.closest(".clip");
|
|
289
|
+
if (hostCandidate instanceof HTMLElement) {
|
|
290
|
+
const hostCompId = hostCandidate.getAttribute("data-composition-id");
|
|
291
|
+
const compositionSrc =
|
|
292
|
+
hostCandidate.getAttribute("data-composition-src") ??
|
|
293
|
+
hostCandidate.getAttribute("data-composition-file") ??
|
|
294
|
+
(hostCompId ? compIdToSrc.get(hostCompId) : undefined) ??
|
|
295
|
+
compIdToSrc.get(nestedCompId) ??
|
|
296
|
+
fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ??
|
|
297
|
+
undefined;
|
|
298
|
+
if (compositionSrc) {
|
|
299
|
+
return { host: hostCandidate, compositionSrc };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
nestedCurrent = nestedCurrent.parentElement;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let current: HTMLElement | null = target;
|
|
307
|
+
while (current) {
|
|
308
|
+
const compId = current.getAttribute("data-composition-id");
|
|
309
|
+
const directSrc =
|
|
310
|
+
current.getAttribute("data-composition-src") ??
|
|
311
|
+
current.getAttribute("data-composition-file") ??
|
|
312
|
+
undefined;
|
|
313
|
+
const timelineMatch =
|
|
314
|
+
timelineElements.find(
|
|
315
|
+
(element) =>
|
|
316
|
+
Boolean(element.compositionSrc) &&
|
|
317
|
+
(element.domId === current?.id ||
|
|
318
|
+
(current?.id && element.id === current.id) ||
|
|
319
|
+
(compId && element.id === compId)),
|
|
320
|
+
) ?? null;
|
|
321
|
+
const compositionSrc =
|
|
322
|
+
directSrc ??
|
|
323
|
+
timelineMatch?.compositionSrc ??
|
|
324
|
+
(compId ? compIdToSrc.get(compId) : undefined) ??
|
|
325
|
+
(compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined);
|
|
326
|
+
if (compositionSrc) {
|
|
327
|
+
return { host: current, compositionSrc };
|
|
328
|
+
}
|
|
329
|
+
current = current.parentElement;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isMoveStyleProperty(property: string): boolean {
|
|
336
|
+
return property === "left" || property === "top";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isResizeStyleProperty(property: string): boolean {
|
|
340
|
+
return property === "width" || property === "height";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function getDomDetachCoordinateRoot(element: HTMLElement): HTMLElement {
|
|
344
|
+
const offsetParent = element.offsetParent;
|
|
345
|
+
if (offsetParent instanceof HTMLElement) return offsetParent;
|
|
346
|
+
|
|
347
|
+
let current = element.parentElement;
|
|
348
|
+
while (current) {
|
|
349
|
+
if (current.hasAttribute("data-composition-id")) return current;
|
|
350
|
+
current = current.parentElement;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return element.ownerDocument.body;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function measureDomDetachRect(element: HTMLElement): {
|
|
357
|
+
left: number;
|
|
358
|
+
top: number;
|
|
359
|
+
width: number;
|
|
360
|
+
height: number;
|
|
361
|
+
} {
|
|
362
|
+
const root = getDomDetachCoordinateRoot(element);
|
|
363
|
+
const rect = element.getBoundingClientRect();
|
|
364
|
+
const rootRect = root.getBoundingClientRect();
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
left: rect.left - rootRect.left + root.scrollLeft,
|
|
368
|
+
top: rect.top - rootRect.top + root.scrollTop,
|
|
369
|
+
width: rect.width,
|
|
370
|
+
height: rect.height,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function getDomSelectionClickKey(
|
|
375
|
+
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex">,
|
|
376
|
+
): string {
|
|
377
|
+
if (selection.id) return `id:${selection.id}`;
|
|
378
|
+
return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function getPreviewTargetFromPointer(
|
|
382
|
+
iframe: HTMLIFrameElement,
|
|
383
|
+
clientX: number,
|
|
384
|
+
clientY: number,
|
|
385
|
+
): HTMLElement | null {
|
|
386
|
+
let doc: Document | null = null;
|
|
387
|
+
let win: Window | null = null;
|
|
388
|
+
try {
|
|
389
|
+
doc = iframe.contentDocument;
|
|
390
|
+
win = iframe.contentWindow;
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
if (!doc || !win) return null;
|
|
395
|
+
|
|
396
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
397
|
+
const root =
|
|
398
|
+
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
|
399
|
+
const rootRect = root?.getBoundingClientRect();
|
|
400
|
+
const rootWidth = rootRect?.width || win.innerWidth;
|
|
401
|
+
const rootHeight = rootRect?.height || win.innerHeight;
|
|
402
|
+
if (!rootWidth || !rootHeight) return null;
|
|
403
|
+
|
|
404
|
+
const scaleX = iframeRect.width / rootWidth;
|
|
405
|
+
const scaleY = iframeRect.height / rootHeight;
|
|
406
|
+
const localX = (clientX - iframeRect.left) / scaleX;
|
|
407
|
+
const localY = (clientY - iframeRect.top) / scaleY;
|
|
408
|
+
|
|
409
|
+
return getEventTargetElement(doc.elementFromPoint(localX, localY));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Ask Agent Modal ──
|
|
413
|
+
|
|
414
|
+
function AskAgentModal({
|
|
415
|
+
selectionLabel,
|
|
416
|
+
onSubmit,
|
|
417
|
+
onClose,
|
|
418
|
+
}: {
|
|
419
|
+
selectionLabel: string;
|
|
420
|
+
onSubmit: (instruction: string) => void;
|
|
421
|
+
onClose: () => void;
|
|
422
|
+
}) {
|
|
423
|
+
const [value, setValue] = useState("");
|
|
424
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
425
|
+
|
|
426
|
+
useMountEffect(() => {
|
|
427
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const handleSubmit = () => {
|
|
431
|
+
if (!value.trim()) return;
|
|
432
|
+
onSubmit(value.trim());
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<div
|
|
437
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
438
|
+
onClick={onClose}
|
|
439
|
+
>
|
|
440
|
+
<div
|
|
441
|
+
className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
|
|
442
|
+
onClick={(e) => e.stopPropagation()}
|
|
443
|
+
>
|
|
444
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
|
445
|
+
<div>
|
|
446
|
+
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
|
447
|
+
<p className="text-xs text-neutral-500 mt-0.5">
|
|
448
|
+
{selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
|
|
449
|
+
</p>
|
|
450
|
+
</div>
|
|
451
|
+
<button
|
|
452
|
+
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
|
453
|
+
onClick={onClose}
|
|
454
|
+
>
|
|
455
|
+
<svg
|
|
456
|
+
width="14"
|
|
457
|
+
height="14"
|
|
458
|
+
viewBox="0 0 24 24"
|
|
459
|
+
fill="none"
|
|
460
|
+
stroke="currentColor"
|
|
461
|
+
strokeWidth="2"
|
|
462
|
+
strokeLinecap="round"
|
|
463
|
+
>
|
|
464
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
465
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
466
|
+
</svg>
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
469
|
+
<div className="px-5 py-4">
|
|
470
|
+
<textarea
|
|
471
|
+
ref={inputRef}
|
|
472
|
+
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"
|
|
473
|
+
placeholder="Describe what you want to change…"
|
|
474
|
+
value={value}
|
|
475
|
+
onChange={(e) => setValue(e.target.value)}
|
|
476
|
+
onKeyDown={(e) => {
|
|
477
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
|
|
478
|
+
if (e.key === "Escape") onClose();
|
|
479
|
+
}}
|
|
480
|
+
/>
|
|
481
|
+
</div>
|
|
482
|
+
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
|
483
|
+
<span className="text-[11px] text-neutral-600">
|
|
484
|
+
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
|
485
|
+
</span>
|
|
486
|
+
<button
|
|
487
|
+
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"
|
|
488
|
+
disabled={!value.trim()}
|
|
489
|
+
onClick={handleSubmit}
|
|
490
|
+
>
|
|
491
|
+
Copy prompt
|
|
492
|
+
</button>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
57
499
|
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
58
500
|
image: 3,
|
|
59
501
|
video: 5,
|
|
@@ -145,6 +587,11 @@ export function StudioApp() {
|
|
|
145
587
|
const [rightWidth, setRightWidth] = useState(400);
|
|
146
588
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
147
589
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
590
|
+
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
591
|
+
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
592
|
+
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
593
|
+
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
594
|
+
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
148
595
|
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
|
|
149
596
|
// This is a subscription to external events (postMessage from runtime) — useEffect
|
|
150
597
|
// is appropriate here. The runtime fires "state"/"timeline" messages after all
|
|
@@ -273,6 +720,8 @@ export function StudioApp() {
|
|
|
273
720
|
const dragCounterRef = useRef(0);
|
|
274
721
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
275
722
|
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
723
|
+
const lastBlockedDomMoveToastAtRef = useRef(0);
|
|
724
|
+
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
|
276
725
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
277
726
|
const panelDragRef = useRef<{
|
|
278
727
|
side: "left" | "right";
|
|
@@ -284,11 +733,14 @@ export function StudioApp() {
|
|
|
284
733
|
const activePreviewUrl = activeCompPath
|
|
285
734
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
286
735
|
: null;
|
|
736
|
+
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
287
737
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
288
738
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
289
739
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
290
740
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
741
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
291
742
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
743
|
+
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
292
744
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
293
745
|
const effectiveTimelineDuration = useMemo(() => {
|
|
294
746
|
const maxEnd =
|
|
@@ -304,9 +756,6 @@ export function StudioApp() {
|
|
|
304
756
|
const toggleTimelineVisibility = useCallback(() => {
|
|
305
757
|
setTimelineVisible((visible) => !visible);
|
|
306
758
|
}, []);
|
|
307
|
-
useMountEffect(() => () => {
|
|
308
|
-
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
309
|
-
});
|
|
310
759
|
const dismissTimelineEditorHint = useCallback(() => {
|
|
311
760
|
setTimelineEditorHintState(true);
|
|
312
761
|
setTimelineEditorHintDismissed(true);
|
|
@@ -374,7 +823,6 @@ export function StudioApp() {
|
|
|
374
823
|
label={el.id || el.tag}
|
|
375
824
|
labelColor={style.label}
|
|
376
825
|
accentColor={style.clip}
|
|
377
|
-
selector={el.selector}
|
|
378
826
|
seekTime={0}
|
|
379
827
|
duration={el.duration}
|
|
380
828
|
/>
|
|
@@ -390,7 +838,6 @@ export function StudioApp() {
|
|
|
390
838
|
label={el.id || el.tag}
|
|
391
839
|
labelColor={style.label}
|
|
392
840
|
accentColor={style.clip}
|
|
393
|
-
selector={el.selector}
|
|
394
841
|
seekTime={el.start}
|
|
395
842
|
duration={el.duration}
|
|
396
843
|
/>
|
|
@@ -436,7 +883,6 @@ export function StudioApp() {
|
|
|
436
883
|
label={el.id || el.tag}
|
|
437
884
|
labelColor={style.label}
|
|
438
885
|
accentColor={style.clip}
|
|
439
|
-
selector={el.selector}
|
|
440
886
|
seekTime={el.start}
|
|
441
887
|
duration={el.duration}
|
|
442
888
|
/>
|
|
@@ -449,6 +895,31 @@ export function StudioApp() {
|
|
|
449
895
|
);
|
|
450
896
|
const timelineToolbar = (
|
|
451
897
|
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
|
898
|
+
{timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
|
|
899
|
+
<div className="px-3 pt-3">
|
|
900
|
+
<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">
|
|
901
|
+
<div className="min-w-0">
|
|
902
|
+
<div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
|
|
903
|
+
<p className="mt-1 text-[11px] leading-5 text-neutral-300">
|
|
904
|
+
Drag clips to move timing, and drag clip edges to resize them when handles are
|
|
905
|
+
available. Hide the panel anytime and bring it back with{" "}
|
|
906
|
+
<span className="font-mono text-[10px] text-studio-accent">
|
|
907
|
+
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
908
|
+
</span>
|
|
909
|
+
.
|
|
910
|
+
</p>
|
|
911
|
+
</div>
|
|
912
|
+
<button
|
|
913
|
+
type="button"
|
|
914
|
+
onClick={dismissTimelineEditorHint}
|
|
915
|
+
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"
|
|
916
|
+
>
|
|
917
|
+
Dismiss
|
|
918
|
+
</button>
|
|
919
|
+
</div>
|
|
920
|
+
</div>
|
|
921
|
+
)}
|
|
922
|
+
|
|
452
923
|
<div className="flex items-center justify-between px-3 py-2">
|
|
453
924
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
454
925
|
Timeline
|
|
@@ -504,11 +975,20 @@ export function StudioApp() {
|
|
|
504
975
|
const projectIdRef = useRef(projectId);
|
|
505
976
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
506
977
|
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
|
978
|
+
const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
979
|
+
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
980
|
+
const lastPreviewClickRef = useRef<{ key: string; at: number } | null>(null);
|
|
981
|
+
const domEditSaveTimestampRef = useRef(0);
|
|
982
|
+
const domTextCommitVersionRef = useRef(0);
|
|
507
983
|
|
|
508
984
|
// Listen for external file changes (user editing HTML outside the editor).
|
|
509
985
|
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
986
|
+
// Suppress file-change events that echo back from a recent DOM edit save —
|
|
987
|
+
// those changes are already applied to the iframe DOM and a full reload
|
|
988
|
+
// would flash the preview.
|
|
510
989
|
useMountEffect(() => {
|
|
511
990
|
const handler = () => {
|
|
991
|
+
if (Date.now() - domEditSaveTimestampRef.current < 1200) return;
|
|
512
992
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
513
993
|
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
|
|
514
994
|
};
|
|
@@ -522,6 +1002,7 @@ export function StudioApp() {
|
|
|
522
1002
|
return () => es.close();
|
|
523
1003
|
});
|
|
524
1004
|
projectIdRef.current = projectId;
|
|
1005
|
+
domEditSelectionRef.current = domEditSelection;
|
|
525
1006
|
|
|
526
1007
|
// Load file tree when projectId changes.
|
|
527
1008
|
// Note: This is one of the few places where useEffect with deps is acceptable —
|
|
@@ -906,6 +1387,707 @@ export function StudioApp() {
|
|
|
906
1387
|
[showToast],
|
|
907
1388
|
);
|
|
908
1389
|
|
|
1390
|
+
const handleBlockedDomMove = useCallback(
|
|
1391
|
+
(selection: DomEditSelection) => {
|
|
1392
|
+
const now = Date.now();
|
|
1393
|
+
if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
|
|
1394
|
+
lastBlockedDomMoveToastAtRef.current = now;
|
|
1395
|
+
showToast(
|
|
1396
|
+
selection.capabilities.canDetachFromLayout
|
|
1397
|
+
? "This layer is controlled by layout. Use Make movable in the panel to detach it."
|
|
1398
|
+
: (selection.capabilities.reasonIfDisabled ??
|
|
1399
|
+
"This element can’t be moved directly from the preview."),
|
|
1400
|
+
"info",
|
|
1401
|
+
);
|
|
1402
|
+
},
|
|
1403
|
+
[showToast],
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
const applyDomSelection = useCallback(
|
|
1407
|
+
(selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
|
|
1408
|
+
setDomEditSelection(selection);
|
|
1409
|
+
setCopiedAgentPrompt(false);
|
|
1410
|
+
if (selection) {
|
|
1411
|
+
if (options?.revealPanel !== false) {
|
|
1412
|
+
setRightCollapsed(false);
|
|
1413
|
+
setRightPanelTab("design");
|
|
1414
|
+
}
|
|
1415
|
+
const nextSelectedTimelineId = findMatchingTimelineElementId(selection, timelineElements);
|
|
1416
|
+
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
setSelectedTimelineElementId(null);
|
|
1421
|
+
},
|
|
1422
|
+
[setSelectedTimelineElementId, timelineElements],
|
|
1423
|
+
);
|
|
1424
|
+
|
|
1425
|
+
const clearDomSelection = useCallback(() => {
|
|
1426
|
+
applyDomSelection(null, { revealPanel: false });
|
|
1427
|
+
}, [applyDomSelection]);
|
|
1428
|
+
|
|
1429
|
+
const buildDomSelectionFromTarget = useCallback(
|
|
1430
|
+
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
|
1431
|
+
if (isMasterView) {
|
|
1432
|
+
const mappedHost = findMappedCompositionHost(
|
|
1433
|
+
target,
|
|
1434
|
+
timelineElements,
|
|
1435
|
+
compIdToSrc,
|
|
1436
|
+
fileTree,
|
|
1437
|
+
);
|
|
1438
|
+
if (mappedHost) {
|
|
1439
|
+
const hostSelection = resolveDomEditSelection(mappedHost.host, {
|
|
1440
|
+
activeCompositionPath: activeCompPath,
|
|
1441
|
+
isMasterView,
|
|
1442
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
1443
|
+
});
|
|
1444
|
+
if (!hostSelection) return null;
|
|
1445
|
+
return {
|
|
1446
|
+
...hostSelection,
|
|
1447
|
+
compositionSrc: mappedHost.compositionSrc,
|
|
1448
|
+
isCompositionHost: true,
|
|
1449
|
+
capabilities: resolveDomEditCapabilities({
|
|
1450
|
+
selector: hostSelection.selector,
|
|
1451
|
+
tagName: hostSelection.tagName,
|
|
1452
|
+
className: hostSelection.element.className,
|
|
1453
|
+
inlineStyles: hostSelection.inlineStyles,
|
|
1454
|
+
computedStyles: hostSelection.computedStyles,
|
|
1455
|
+
isCompositionHost: true,
|
|
1456
|
+
isMasterView: true,
|
|
1457
|
+
}),
|
|
1458
|
+
} satisfies DomEditSelection;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return resolveDomEditSelection(target, {
|
|
1463
|
+
activeCompositionPath: activeCompPath,
|
|
1464
|
+
isMasterView,
|
|
1465
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
1466
|
+
});
|
|
1467
|
+
},
|
|
1468
|
+
[activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
const resolveImportedFontAsset = useCallback(
|
|
1472
|
+
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
1473
|
+
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
1474
|
+
if (!family) return null;
|
|
1475
|
+
const imported = importedFontAssetsRef.current.find(
|
|
1476
|
+
(font) => font.family.toLowerCase() === family.toLowerCase(),
|
|
1477
|
+
);
|
|
1478
|
+
if (imported) return imported;
|
|
1479
|
+
const asset = fileTree.find(
|
|
1480
|
+
(path) =>
|
|
1481
|
+
FONT_EXT.test(path) &&
|
|
1482
|
+
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
|
|
1483
|
+
);
|
|
1484
|
+
if (!asset) return null;
|
|
1485
|
+
return {
|
|
1486
|
+
family: fontFamilyFromAssetPath(asset),
|
|
1487
|
+
path: asset,
|
|
1488
|
+
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
1489
|
+
};
|
|
1490
|
+
},
|
|
1491
|
+
[fileTree, projectId],
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const persistDomEditOperations = useCallback(
|
|
1495
|
+
async (
|
|
1496
|
+
selection: DomEditSelection,
|
|
1497
|
+
operations: Parameters<typeof applyPatchByTarget>[2][],
|
|
1498
|
+
options?: {
|
|
1499
|
+
skipRefresh?: boolean;
|
|
1500
|
+
prepareContent?: (html: string, sourceFile: string) => string;
|
|
1501
|
+
shouldSave?: () => boolean;
|
|
1502
|
+
},
|
|
1503
|
+
) => {
|
|
1504
|
+
const pid = projectIdRef.current;
|
|
1505
|
+
if (!pid) throw new Error("No active project");
|
|
1506
|
+
if (options?.shouldSave && !options.shouldSave()) return;
|
|
1507
|
+
|
|
1508
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
1509
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
1510
|
+
if (!response.ok) {
|
|
1511
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const data = (await response.json()) as { content?: string };
|
|
1515
|
+
const originalContent = data.content;
|
|
1516
|
+
if (typeof originalContent !== "string") {
|
|
1517
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
let patchedContent = originalContent;
|
|
1521
|
+
for (const operation of operations) {
|
|
1522
|
+
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
|
1523
|
+
}
|
|
1524
|
+
if (options?.prepareContent) {
|
|
1525
|
+
patchedContent = options.prepareContent(patchedContent, targetPath);
|
|
1526
|
+
}
|
|
1527
|
+
if (options?.shouldSave && !options.shouldSave()) return;
|
|
1528
|
+
|
|
1529
|
+
if (patchedContent === originalContent) {
|
|
1530
|
+
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const saveResponse = await fetch(
|
|
1534
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1535
|
+
{
|
|
1536
|
+
method: "PUT",
|
|
1537
|
+
headers: { "Content-Type": "text/plain" },
|
|
1538
|
+
body: patchedContent,
|
|
1539
|
+
},
|
|
1540
|
+
);
|
|
1541
|
+
if (!saveResponse.ok) {
|
|
1542
|
+
throw new Error(`Failed to save ${targetPath}`);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (editingPathRef.current === targetPath) {
|
|
1546
|
+
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (options?.skipRefresh) {
|
|
1550
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
1551
|
+
} else {
|
|
1552
|
+
setRefreshKey((k) => k + 1);
|
|
1553
|
+
}
|
|
1554
|
+
},
|
|
1555
|
+
[activeCompPath],
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
const handleDomMoveCommit = useCallback(
|
|
1559
|
+
async (selection: DomEditSelection, next: { left: number; top: number }) => {
|
|
1560
|
+
await persistDomEditOperations(
|
|
1561
|
+
selection,
|
|
1562
|
+
[
|
|
1563
|
+
...buildDomEditMovePatchOperations(next.left, next.top),
|
|
1564
|
+
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1565
|
+
],
|
|
1566
|
+
{ skipRefresh: true },
|
|
1567
|
+
);
|
|
1568
|
+
},
|
|
1569
|
+
[persistDomEditOperations],
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
const handleDomResizeCommit = useCallback(
|
|
1573
|
+
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
1574
|
+
if (shouldDetachOppositeEdges(selection)) {
|
|
1575
|
+
selection.element.style.right = "auto";
|
|
1576
|
+
selection.element.style.bottom = "auto";
|
|
1577
|
+
}
|
|
1578
|
+
await persistDomEditOperations(
|
|
1579
|
+
selection,
|
|
1580
|
+
[
|
|
1581
|
+
...buildDomEditResizePatchOperations(next.width, next.height),
|
|
1582
|
+
...buildOppositeEdgePatchOperations(selection, "both"),
|
|
1583
|
+
],
|
|
1584
|
+
{ skipRefresh: true },
|
|
1585
|
+
);
|
|
1586
|
+
},
|
|
1587
|
+
[persistDomEditOperations],
|
|
1588
|
+
);
|
|
1589
|
+
|
|
1590
|
+
const handleDomDetachFromLayout = useCallback(async () => {
|
|
1591
|
+
const selection = domEditSelection;
|
|
1592
|
+
if (!selection?.capabilities.canDetachFromLayout) return;
|
|
1593
|
+
|
|
1594
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
1595
|
+
const element = doc
|
|
1596
|
+
? findElementForSelection(doc, selection, selection.sourceFile)
|
|
1597
|
+
: selection.element;
|
|
1598
|
+
if (!element) {
|
|
1599
|
+
showToast("Could not find the selected layer in the preview.", "info");
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const rect = measureDomDetachRect(element);
|
|
1604
|
+
const operations = buildDomEditDetachPatchOperations(rect);
|
|
1605
|
+
|
|
1606
|
+
for (const operation of operations) {
|
|
1607
|
+
element.style.setProperty(operation.property, operation.value);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
await persistDomEditOperations(selection, operations, { skipRefresh: true });
|
|
1611
|
+
|
|
1612
|
+
const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
|
|
1613
|
+
if (refreshed) {
|
|
1614
|
+
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
1615
|
+
if (nextSelection) {
|
|
1616
|
+
applyDomSelection(nextSelection, { revealPanel: false });
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
showToast("Layer detached from layout. You can move it now.", "info");
|
|
1620
|
+
}, [
|
|
1621
|
+
applyDomSelection,
|
|
1622
|
+
buildDomSelectionFromTarget,
|
|
1623
|
+
domEditSelection,
|
|
1624
|
+
persistDomEditOperations,
|
|
1625
|
+
showToast,
|
|
1626
|
+
]);
|
|
1627
|
+
|
|
1628
|
+
const handleDomStyleCommit = useCallback(
|
|
1629
|
+
async (property: string, value: string) => {
|
|
1630
|
+
if (!domEditSelection) return;
|
|
1631
|
+
const isMoveStyle = isMoveStyleProperty(property);
|
|
1632
|
+
const isResizeStyle = isResizeStyleProperty(property);
|
|
1633
|
+
if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
|
|
1634
|
+
if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
|
|
1635
|
+
if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
|
|
1636
|
+
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
1637
|
+
const iframe = previewIframeRef.current;
|
|
1638
|
+
const doc = iframe?.contentDocument;
|
|
1639
|
+
if (doc) {
|
|
1640
|
+
const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
|
|
1641
|
+
if (el) {
|
|
1642
|
+
el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
|
|
1643
|
+
if (property === "font-family") {
|
|
1644
|
+
injectPreviewGoogleFont(doc, value);
|
|
1645
|
+
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
1646
|
+
}
|
|
1647
|
+
if (shouldDetachOppositeEdges(domEditSelection)) {
|
|
1648
|
+
if (property === "width") el.style.right = "auto";
|
|
1649
|
+
if (property === "height") el.style.bottom = "auto";
|
|
1650
|
+
}
|
|
1651
|
+
if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
1652
|
+
el.style.setProperty("background-position", "center");
|
|
1653
|
+
el.style.setProperty("background-repeat", "no-repeat");
|
|
1654
|
+
el.style.setProperty("background-size", "contain");
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
const operations: PatchOperation[] = [
|
|
1659
|
+
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
|
1660
|
+
];
|
|
1661
|
+
if (property === "width") {
|
|
1662
|
+
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
|
|
1663
|
+
} else if (property === "height") {
|
|
1664
|
+
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
|
|
1665
|
+
} else if (property === "background-image" && isImageBackgroundValue(value)) {
|
|
1666
|
+
operations.push(
|
|
1667
|
+
buildDomEditStylePatchOperation("background-position", "center"),
|
|
1668
|
+
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
|
1669
|
+
buildDomEditStylePatchOperation("background-size", "contain"),
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
await persistDomEditOperations(domEditSelection, operations, {
|
|
1673
|
+
skipRefresh: true,
|
|
1674
|
+
prepareContent: importedFont
|
|
1675
|
+
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
1676
|
+
: undefined,
|
|
1677
|
+
});
|
|
1678
|
+
},
|
|
1679
|
+
[domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
|
|
1680
|
+
);
|
|
1681
|
+
|
|
1682
|
+
const handleDomTextCommit = useCallback(
|
|
1683
|
+
async (value: string, fieldKey?: string) => {
|
|
1684
|
+
if (!domEditSelection) return;
|
|
1685
|
+
if (!isTextEditableSelection(domEditSelection)) return;
|
|
1686
|
+
const commitVersion = domTextCommitVersionRef.current + 1;
|
|
1687
|
+
domTextCommitVersionRef.current = commitVersion;
|
|
1688
|
+
const nextTextFields =
|
|
1689
|
+
domEditSelection.textFields.length > 0
|
|
1690
|
+
? domEditSelection.textFields.map((field) =>
|
|
1691
|
+
field.key === fieldKey ? { ...field, value } : field,
|
|
1692
|
+
)
|
|
1693
|
+
: [];
|
|
1694
|
+
const nextContent =
|
|
1695
|
+
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
1696
|
+
? serializeDomEditTextFields(nextTextFields)
|
|
1697
|
+
: value;
|
|
1698
|
+
const iframe = previewIframeRef.current;
|
|
1699
|
+
const doc = iframe?.contentDocument;
|
|
1700
|
+
if (doc) {
|
|
1701
|
+
const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
|
|
1702
|
+
if (el) {
|
|
1703
|
+
if (
|
|
1704
|
+
nextTextFields.length > 1 ||
|
|
1705
|
+
nextTextFields.some((field) => field.source === "child")
|
|
1706
|
+
) {
|
|
1707
|
+
el.innerHTML = nextContent;
|
|
1708
|
+
} else {
|
|
1709
|
+
el.textContent = value;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
await persistDomEditOperations(
|
|
1714
|
+
domEditSelection,
|
|
1715
|
+
[buildDomEditTextPatchOperation(nextContent)],
|
|
1716
|
+
{
|
|
1717
|
+
skipRefresh: true,
|
|
1718
|
+
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
|
1719
|
+
},
|
|
1720
|
+
);
|
|
1721
|
+
if (domTextCommitVersionRef.current !== commitVersion) return;
|
|
1722
|
+
|
|
1723
|
+
if (doc) {
|
|
1724
|
+
const refreshed = findElementForSelection(
|
|
1725
|
+
doc,
|
|
1726
|
+
domEditSelection,
|
|
1727
|
+
domEditSelection.sourceFile,
|
|
1728
|
+
);
|
|
1729
|
+
if (refreshed) {
|
|
1730
|
+
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
1731
|
+
if (nextSelection) {
|
|
1732
|
+
applyDomSelection(nextSelection, { revealPanel: false });
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
},
|
|
1737
|
+
[applyDomSelection, buildDomSelectionFromTarget, domEditSelection, persistDomEditOperations],
|
|
1738
|
+
);
|
|
1739
|
+
|
|
1740
|
+
const commitDomTextFields = useCallback(
|
|
1741
|
+
async (
|
|
1742
|
+
selection: DomEditSelection,
|
|
1743
|
+
nextTextFields: DomEditTextField[],
|
|
1744
|
+
options?: { importedFont?: ImportedFontAsset | null },
|
|
1745
|
+
) => {
|
|
1746
|
+
const nextContent =
|
|
1747
|
+
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
|
1748
|
+
? serializeDomEditTextFields(nextTextFields)
|
|
1749
|
+
: (nextTextFields[0]?.value ?? "");
|
|
1750
|
+
|
|
1751
|
+
const iframe = previewIframeRef.current;
|
|
1752
|
+
const doc = iframe?.contentDocument;
|
|
1753
|
+
if (doc) {
|
|
1754
|
+
const el = findElementForSelection(doc, selection, selection.sourceFile);
|
|
1755
|
+
if (el) {
|
|
1756
|
+
if (
|
|
1757
|
+
nextTextFields.length > 1 ||
|
|
1758
|
+
nextTextFields.some((field) => field.source === "child")
|
|
1759
|
+
) {
|
|
1760
|
+
el.innerHTML = nextContent;
|
|
1761
|
+
} else {
|
|
1762
|
+
el.textContent = nextContent;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const importedFont = options?.importedFont ?? null;
|
|
1768
|
+
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
|
1769
|
+
skipRefresh: true,
|
|
1770
|
+
prepareContent: importedFont
|
|
1771
|
+
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
1772
|
+
: undefined,
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
if (doc) {
|
|
1776
|
+
const refreshed = findElementForSelection(doc, selection, selection.sourceFile);
|
|
1777
|
+
if (refreshed) {
|
|
1778
|
+
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
|
1779
|
+
if (nextSelection) {
|
|
1780
|
+
applyDomSelection(nextSelection, { revealPanel: false });
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
[applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
const handleDomTextFieldStyleCommit = useCallback(
|
|
1789
|
+
async (fieldKey: string, property: string, value: string) => {
|
|
1790
|
+
if (!domEditSelection) return;
|
|
1791
|
+
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
1792
|
+
if (!field) return;
|
|
1793
|
+
|
|
1794
|
+
if (field.source === "self") {
|
|
1795
|
+
await handleDomStyleCommit(property, value);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const normalizedValue = normalizeDomEditStyleValue(property, value);
|
|
1800
|
+
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
|
1801
|
+
if (property === "font-family") {
|
|
1802
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
1803
|
+
if (doc) {
|
|
1804
|
+
injectPreviewGoogleFont(doc, normalizedValue);
|
|
1805
|
+
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
const nextTextFields = domEditSelection.textFields.map((entry) =>
|
|
1809
|
+
entry.key === fieldKey
|
|
1810
|
+
? {
|
|
1811
|
+
...entry,
|
|
1812
|
+
inlineStyles: {
|
|
1813
|
+
...entry.inlineStyles,
|
|
1814
|
+
[property]: normalizedValue,
|
|
1815
|
+
},
|
|
1816
|
+
computedStyles: {
|
|
1817
|
+
...entry.computedStyles,
|
|
1818
|
+
[property]: normalizedValue,
|
|
1819
|
+
},
|
|
1820
|
+
}
|
|
1821
|
+
: entry,
|
|
1822
|
+
);
|
|
1823
|
+
|
|
1824
|
+
await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
|
|
1825
|
+
},
|
|
1826
|
+
[commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
const handleDomAddTextField = useCallback(
|
|
1830
|
+
async (afterFieldKey?: string) => {
|
|
1831
|
+
if (!domEditSelection) return null;
|
|
1832
|
+
if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
|
|
1833
|
+
|
|
1834
|
+
const insertionIndex = domEditSelection.textFields.findIndex(
|
|
1835
|
+
(field) => field.key === afterFieldKey,
|
|
1836
|
+
);
|
|
1837
|
+
const baseField =
|
|
1838
|
+
domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
|
|
1839
|
+
domEditSelection.textFields[0];
|
|
1840
|
+
const nextField = buildDefaultDomEditTextField(baseField);
|
|
1841
|
+
const nextTextFields = [...domEditSelection.textFields];
|
|
1842
|
+
nextTextFields.splice(
|
|
1843
|
+
insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
|
|
1844
|
+
0,
|
|
1845
|
+
nextField,
|
|
1846
|
+
);
|
|
1847
|
+
|
|
1848
|
+
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
1849
|
+
return nextField.key;
|
|
1850
|
+
},
|
|
1851
|
+
[commitDomTextFields, domEditSelection],
|
|
1852
|
+
);
|
|
1853
|
+
|
|
1854
|
+
const handleDomRemoveTextField = useCallback(
|
|
1855
|
+
async (fieldKey: string) => {
|
|
1856
|
+
if (!domEditSelection) return;
|
|
1857
|
+
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
|
1858
|
+
if (!field) return;
|
|
1859
|
+
|
|
1860
|
+
if (field.source === "self") {
|
|
1861
|
+
await handleDomTextCommit("", fieldKey);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
|
|
1866
|
+
await commitDomTextFields(domEditSelection, nextTextFields);
|
|
1867
|
+
},
|
|
1868
|
+
[commitDomTextFields, domEditSelection, handleDomTextCommit],
|
|
1869
|
+
);
|
|
1870
|
+
|
|
1871
|
+
const handleAskAgent = useCallback(() => {
|
|
1872
|
+
if (!domEditSelection) return;
|
|
1873
|
+
setAgentModalOpen(true);
|
|
1874
|
+
}, [domEditSelection]);
|
|
1875
|
+
|
|
1876
|
+
const handleAgentModalSubmit = useCallback(
|
|
1877
|
+
async (userInstruction: string) => {
|
|
1878
|
+
if (!domEditSelection) return;
|
|
1879
|
+
|
|
1880
|
+
const pid = projectIdRef.current;
|
|
1881
|
+
if (!pid) return;
|
|
1882
|
+
|
|
1883
|
+
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
1884
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
1885
|
+
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
|
1886
|
+
|
|
1887
|
+
const data = (await response.json()) as { content?: string };
|
|
1888
|
+
const html = data.content;
|
|
1889
|
+
const tagSnippet =
|
|
1890
|
+
typeof html === "string" ? readTagSnippetByTarget(html, domEditSelection) : undefined;
|
|
1891
|
+
const prompt = buildElementAgentPrompt({
|
|
1892
|
+
selection: domEditSelection,
|
|
1893
|
+
currentTime,
|
|
1894
|
+
tagSnippet,
|
|
1895
|
+
userInstruction,
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
try {
|
|
1899
|
+
await navigator.clipboard.writeText(prompt);
|
|
1900
|
+
} catch {
|
|
1901
|
+
const textarea = document.createElement("textarea");
|
|
1902
|
+
textarea.value = prompt;
|
|
1903
|
+
textarea.setAttribute("readonly", "true");
|
|
1904
|
+
textarea.style.position = "fixed";
|
|
1905
|
+
textarea.style.opacity = "0";
|
|
1906
|
+
document.body.appendChild(textarea);
|
|
1907
|
+
textarea.select();
|
|
1908
|
+
document.execCommand("copy");
|
|
1909
|
+
document.body.removeChild(textarea);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
setAgentModalOpen(false);
|
|
1913
|
+
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
1914
|
+
setCopiedAgentPrompt(true);
|
|
1915
|
+
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
1916
|
+
},
|
|
1917
|
+
[activeCompPath, currentTime, domEditSelection],
|
|
1918
|
+
);
|
|
1919
|
+
|
|
1920
|
+
const handlePreviewIframeRef = useCallback(
|
|
1921
|
+
(iframe: HTMLIFrameElement | null) => {
|
|
1922
|
+
previewIframeRef.current = iframe;
|
|
1923
|
+
setPreviewIframe(iframe);
|
|
1924
|
+
syncPreviewTimelineHotkey(iframe);
|
|
1925
|
+
consoleErrorsRef.current = [];
|
|
1926
|
+
setConsoleErrors(null);
|
|
1927
|
+
},
|
|
1928
|
+
[syncPreviewTimelineHotkey],
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
const handlePreviewCanvasMouseDown = useCallback(
|
|
1932
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
1933
|
+
const iframe = previewIframeRef.current;
|
|
1934
|
+
if (!iframe || captionEditMode) return;
|
|
1935
|
+
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
1936
|
+
if (!target) {
|
|
1937
|
+
lastPreviewClickRef.current = null;
|
|
1938
|
+
applyDomSelection(null, { revealPanel: false });
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
e.preventDefault();
|
|
1942
|
+
e.stopPropagation();
|
|
1943
|
+
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
1944
|
+
preferClipAncestor: true,
|
|
1945
|
+
});
|
|
1946
|
+
if (!nextSelection) {
|
|
1947
|
+
lastPreviewClickRef.current = null;
|
|
1948
|
+
applyDomSelection(null, { revealPanel: false });
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (nextSelection.isCompositionHost && isMasterView && nextSelection.compositionSrc) {
|
|
1952
|
+
const key = getDomSelectionClickKey(nextSelection);
|
|
1953
|
+
const last = lastPreviewClickRef.current;
|
|
1954
|
+
const now = Date.now();
|
|
1955
|
+
if (last && last.key === key && now - last.at < 350) {
|
|
1956
|
+
lastPreviewClickRef.current = null;
|
|
1957
|
+
applyDomSelection(null, { revealPanel: false });
|
|
1958
|
+
setActiveCompPath(nextSelection.compositionSrc);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
lastPreviewClickRef.current = { key, at: now };
|
|
1962
|
+
} else {
|
|
1963
|
+
lastPreviewClickRef.current = null;
|
|
1964
|
+
}
|
|
1965
|
+
applyDomSelection(nextSelection);
|
|
1966
|
+
},
|
|
1967
|
+
[applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
|
|
1968
|
+
);
|
|
1969
|
+
|
|
1970
|
+
const handlePreviewCanvasDoubleClick = useCallback(
|
|
1971
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
1972
|
+
const iframe = previewIframeRef.current;
|
|
1973
|
+
if (!iframe || captionEditMode) return;
|
|
1974
|
+
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
1975
|
+
if (!target) return;
|
|
1976
|
+
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
1977
|
+
preferClipAncestor: false,
|
|
1978
|
+
});
|
|
1979
|
+
if (!nextSelection?.isCompositionHost || !isMasterView || !nextSelection.compositionSrc) {
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
e.preventDefault();
|
|
1983
|
+
e.stopPropagation();
|
|
1984
|
+
lastPreviewClickRef.current = null;
|
|
1985
|
+
applyDomSelection(null, { revealPanel: false });
|
|
1986
|
+
setActiveCompPath(nextSelection.compositionSrc);
|
|
1987
|
+
},
|
|
1988
|
+
[applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
|
|
1989
|
+
);
|
|
1990
|
+
|
|
1991
|
+
const handleSelectedOverlayDoubleClick = useCallback(() => {
|
|
1992
|
+
const selection = domEditSelectionRef.current;
|
|
1993
|
+
if (!selection?.isCompositionHost || !selection.compositionSrc) return;
|
|
1994
|
+
applyDomSelection(null, { revealPanel: false });
|
|
1995
|
+
setActiveCompPath(selection.compositionSrc);
|
|
1996
|
+
}, [applyDomSelection]);
|
|
1997
|
+
|
|
1998
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1999
|
+
useEffect(() => {
|
|
2000
|
+
if (!previewIframe || captionEditMode) return;
|
|
2001
|
+
|
|
2002
|
+
const syncSelectionFromDocument = () => {
|
|
2003
|
+
const currentSelection = domEditSelectionRef.current;
|
|
2004
|
+
if (!currentSelection) return;
|
|
2005
|
+
let doc: Document | null = null;
|
|
2006
|
+
try {
|
|
2007
|
+
doc = previewIframe.contentDocument;
|
|
2008
|
+
} catch {
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
if (!doc) return;
|
|
2012
|
+
|
|
2013
|
+
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
|
2014
|
+
if (!nextElement) {
|
|
2015
|
+
applyDomSelection(null, { revealPanel: false });
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
const nextSelection = buildDomSelectionFromTarget(nextElement);
|
|
2020
|
+
if (nextSelection) {
|
|
2021
|
+
applyDomSelection(nextSelection, { revealPanel: false });
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
const attachErrorCapture = () => {
|
|
2026
|
+
try {
|
|
2027
|
+
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
|
|
2028
|
+
if (!win) return;
|
|
2029
|
+
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
2030
|
+
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
2031
|
+
const origError = win.console.error.bind(win.console);
|
|
2032
|
+
win.console.error = function (...args: unknown[]) {
|
|
2033
|
+
origError(...args);
|
|
2034
|
+
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
|
2035
|
+
if (text.includes("favicon")) return;
|
|
2036
|
+
consoleErrorsRef.current = [
|
|
2037
|
+
...consoleErrorsRef.current,
|
|
2038
|
+
{ severity: "error", message: text },
|
|
2039
|
+
];
|
|
2040
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
2041
|
+
};
|
|
2042
|
+
win.addEventListener("error", (e: ErrorEvent) => {
|
|
2043
|
+
const text = e.message || String(e);
|
|
2044
|
+
consoleErrorsRef.current = [
|
|
2045
|
+
...consoleErrorsRef.current,
|
|
2046
|
+
{ severity: "error", message: text },
|
|
2047
|
+
];
|
|
2048
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
2049
|
+
});
|
|
2050
|
+
} catch {
|
|
2051
|
+
// same-origin only
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
|
|
2055
|
+
attachErrorCapture();
|
|
2056
|
+
syncSelectionFromDocument();
|
|
2057
|
+
|
|
2058
|
+
const handleLoad = () => {
|
|
2059
|
+
consoleErrorsRef.current = [];
|
|
2060
|
+
setConsoleErrors(null);
|
|
2061
|
+
attachErrorCapture();
|
|
2062
|
+
syncSelectionFromDocument();
|
|
2063
|
+
};
|
|
2064
|
+
|
|
2065
|
+
previewIframe.addEventListener("load", handleLoad);
|
|
2066
|
+
return () => {
|
|
2067
|
+
previewIframe.removeEventListener("load", handleLoad);
|
|
2068
|
+
};
|
|
2069
|
+
}, [
|
|
2070
|
+
activeCompPath,
|
|
2071
|
+
applyDomSelection,
|
|
2072
|
+
buildDomSelectionFromTarget,
|
|
2073
|
+
captionEditMode,
|
|
2074
|
+
previewIframe,
|
|
2075
|
+
]);
|
|
2076
|
+
|
|
2077
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2078
|
+
useEffect(() => {
|
|
2079
|
+
if (!captionEditMode) return;
|
|
2080
|
+
applyDomSelection(null, { revealPanel: false });
|
|
2081
|
+
}, [applyDomSelection, captionEditMode]);
|
|
2082
|
+
|
|
2083
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
2084
|
+
useEffect(
|
|
2085
|
+
() => () => {
|
|
2086
|
+
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
|
2087
|
+
},
|
|
2088
|
+
[],
|
|
2089
|
+
);
|
|
2090
|
+
|
|
909
2091
|
const refreshFileTree = useCallback(async () => {
|
|
910
2092
|
const pid = projectIdRef.current;
|
|
911
2093
|
if (!pid) return;
|
|
@@ -1039,6 +2221,7 @@ export function StudioApp() {
|
|
|
1039
2221
|
duration: normalizedDuration,
|
|
1040
2222
|
track: placement.track,
|
|
1041
2223
|
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
2224
|
+
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
|
1042
2225
|
}),
|
|
1043
2226
|
);
|
|
1044
2227
|
|
|
@@ -1223,7 +2406,33 @@ export function StudioApp() {
|
|
|
1223
2406
|
|
|
1224
2407
|
const handleImportFiles = useCallback(
|
|
1225
2408
|
async (files: FileList | File[], dir?: string) => {
|
|
1226
|
-
|
|
2409
|
+
return uploadProjectFiles(Array.from(files), dir);
|
|
2410
|
+
},
|
|
2411
|
+
[uploadProjectFiles],
|
|
2412
|
+
);
|
|
2413
|
+
|
|
2414
|
+
const handleImportFonts = useCallback(
|
|
2415
|
+
async (files: FileList | File[]) => {
|
|
2416
|
+
const uploaded = await uploadProjectFiles(
|
|
2417
|
+
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
|
2418
|
+
"assets/fonts",
|
|
2419
|
+
);
|
|
2420
|
+
const pid = projectIdRef.current;
|
|
2421
|
+
const imported = uploaded
|
|
2422
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
2423
|
+
.map((asset) => ({
|
|
2424
|
+
family: fontFamilyFromAssetPath(asset),
|
|
2425
|
+
path: asset,
|
|
2426
|
+
url: `/api/projects/${pid}/preview/${asset}`,
|
|
2427
|
+
}));
|
|
2428
|
+
importedFontAssetsRef.current = [
|
|
2429
|
+
...imported,
|
|
2430
|
+
...importedFontAssetsRef.current.filter(
|
|
2431
|
+
(existing) =>
|
|
2432
|
+
!imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
|
|
2433
|
+
),
|
|
2434
|
+
];
|
|
2435
|
+
return imported;
|
|
1227
2436
|
},
|
|
1228
2437
|
[uploadProjectFiles],
|
|
1229
2438
|
);
|
|
@@ -1295,6 +2504,17 @@ export function StudioApp() {
|
|
|
1295
2504
|
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
|
1296
2505
|
[fileTree],
|
|
1297
2506
|
);
|
|
2507
|
+
const fontAssets = useMemo<ImportedFontAsset[]>(
|
|
2508
|
+
() =>
|
|
2509
|
+
assets
|
|
2510
|
+
.filter((asset) => FONT_EXT.test(asset))
|
|
2511
|
+
.map((asset) => ({
|
|
2512
|
+
family: fontFamilyFromAssetPath(asset),
|
|
2513
|
+
path: asset,
|
|
2514
|
+
url: `/api/projects/${projectId}/preview/${asset}`,
|
|
2515
|
+
})),
|
|
2516
|
+
[assets, projectId],
|
|
2517
|
+
);
|
|
1298
2518
|
|
|
1299
2519
|
if (resolving || !projectId) {
|
|
1300
2520
|
return (
|
|
@@ -1390,7 +2610,15 @@ export function StudioApp() {
|
|
|
1390
2610
|
<span>Timeline</span>
|
|
1391
2611
|
</button>
|
|
1392
2612
|
<button
|
|
1393
|
-
onClick={() =>
|
|
2613
|
+
onClick={() => {
|
|
2614
|
+
if (rightCollapsed || rightPanelTab !== "design") {
|
|
2615
|
+
setRightPanelTab("design");
|
|
2616
|
+
setRightCollapsed(false);
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
clearDomSelection();
|
|
2620
|
+
setRightCollapsed(true);
|
|
2621
|
+
}}
|
|
1394
2622
|
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
1395
2623
|
!rightCollapsed
|
|
1396
2624
|
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
@@ -1408,8 +2636,7 @@ export function StudioApp() {
|
|
|
1408
2636
|
<circle cx="12" cy="12" r="10" />
|
|
1409
2637
|
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
1410
2638
|
</svg>
|
|
1411
|
-
|
|
1412
|
-
{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
|
|
2639
|
+
Inspector
|
|
1413
2640
|
</button>
|
|
1414
2641
|
</div>
|
|
1415
2642
|
</div>
|
|
@@ -1500,56 +2727,24 @@ export function StudioApp() {
|
|
|
1500
2727
|
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
|
1501
2728
|
setActiveCompPath(compPath);
|
|
1502
2729
|
}}
|
|
1503
|
-
onIframeRef={
|
|
1504
|
-
previewIframeRef.current = iframe;
|
|
1505
|
-
syncPreviewTimelineHotkey(iframe);
|
|
1506
|
-
consoleErrorsRef.current = [];
|
|
1507
|
-
setConsoleErrors(null);
|
|
1508
|
-
if (!iframe) return;
|
|
1509
|
-
|
|
1510
|
-
// Attach error capture after each iframe load (content resets on navigation)
|
|
1511
|
-
const attachErrorCapture = () => {
|
|
1512
|
-
try {
|
|
1513
|
-
const win = iframe.contentWindow as (Window & typeof globalThis) | null;
|
|
1514
|
-
if (!win) return;
|
|
1515
|
-
// Guard against double-patching
|
|
1516
|
-
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
1517
|
-
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
1518
|
-
const origError = win.console.error.bind(win.console);
|
|
1519
|
-
win.console.error = function (...args: unknown[]) {
|
|
1520
|
-
origError(...args);
|
|
1521
|
-
const text = args
|
|
1522
|
-
.map((a) => (a instanceof Error ? a.message : String(a)))
|
|
1523
|
-
.join(" ");
|
|
1524
|
-
if (text.includes("favicon")) return;
|
|
1525
|
-
consoleErrorsRef.current = [
|
|
1526
|
-
...consoleErrorsRef.current,
|
|
1527
|
-
{ severity: "error", message: text },
|
|
1528
|
-
];
|
|
1529
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
1530
|
-
};
|
|
1531
|
-
win.addEventListener("error", (e: ErrorEvent) => {
|
|
1532
|
-
const text = e.message || String(e);
|
|
1533
|
-
consoleErrorsRef.current = [
|
|
1534
|
-
...consoleErrorsRef.current,
|
|
1535
|
-
{ severity: "error", message: text },
|
|
1536
|
-
];
|
|
1537
|
-
setConsoleErrors([...consoleErrorsRef.current]);
|
|
1538
|
-
});
|
|
1539
|
-
} catch {
|
|
1540
|
-
// cross-origin — can't attach
|
|
1541
|
-
}
|
|
1542
|
-
};
|
|
1543
|
-
// Attach now (iframe may already be loaded) and on future loads
|
|
1544
|
-
attachErrorCapture();
|
|
1545
|
-
iframe.addEventListener("load", () => {
|
|
1546
|
-
consoleErrorsRef.current = [];
|
|
1547
|
-
setConsoleErrors(null);
|
|
1548
|
-
attachErrorCapture();
|
|
1549
|
-
});
|
|
1550
|
-
}}
|
|
2730
|
+
onIframeRef={handlePreviewIframeRef}
|
|
1551
2731
|
previewOverlay={
|
|
1552
|
-
captionEditMode ?
|
|
2732
|
+
captionEditMode ? (
|
|
2733
|
+
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
2734
|
+
) : (
|
|
2735
|
+
<DomEditOverlay
|
|
2736
|
+
iframeRef={previewIframeRef}
|
|
2737
|
+
selection={
|
|
2738
|
+
!rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
|
|
2739
|
+
}
|
|
2740
|
+
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
2741
|
+
onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
|
|
2742
|
+
onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
|
|
2743
|
+
onBlockedMove={handleBlockedDomMove}
|
|
2744
|
+
onMoveCommit={handleDomMoveCommit}
|
|
2745
|
+
onResizeCommit={handleDomResizeCommit}
|
|
2746
|
+
/>
|
|
2747
|
+
)
|
|
1553
2748
|
}
|
|
1554
2749
|
timelineFooter={
|
|
1555
2750
|
captionEditMode ? (
|
|
@@ -1590,26 +2785,73 @@ export function StudioApp() {
|
|
|
1590
2785
|
{captionEditMode ? (
|
|
1591
2786
|
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
1592
2787
|
) : (
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2788
|
+
<>
|
|
2789
|
+
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
|
2790
|
+
<button
|
|
2791
|
+
type="button"
|
|
2792
|
+
onClick={() => setRightPanelTab("design")}
|
|
2793
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
2794
|
+
rightPanelTab === "design"
|
|
2795
|
+
? "bg-neutral-800 text-white"
|
|
2796
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
2797
|
+
}`}
|
|
2798
|
+
>
|
|
2799
|
+
Design
|
|
2800
|
+
</button>
|
|
2801
|
+
<button
|
|
2802
|
+
type="button"
|
|
2803
|
+
onClick={() => setRightPanelTab("renders")}
|
|
2804
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
2805
|
+
rightPanelTab === "renders"
|
|
2806
|
+
? "bg-neutral-800 text-white"
|
|
2807
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
2808
|
+
}`}
|
|
2809
|
+
>
|
|
2810
|
+
{renderQueue.jobs.length > 0
|
|
2811
|
+
? `Renders (${renderQueue.jobs.length})`
|
|
2812
|
+
: "Renders"}
|
|
2813
|
+
</button>
|
|
2814
|
+
</div>
|
|
2815
|
+
<div className="min-h-0 flex-1">
|
|
2816
|
+
{rightPanelTab === "design" ? (
|
|
2817
|
+
<PropertyPanel
|
|
2818
|
+
projectId={projectId}
|
|
2819
|
+
assets={assets}
|
|
2820
|
+
element={domEditSelection}
|
|
2821
|
+
copiedAgentPrompt={copiedAgentPrompt}
|
|
2822
|
+
onClearSelection={clearDomSelection}
|
|
2823
|
+
onSetStyle={handleDomStyleCommit}
|
|
2824
|
+
onSetText={handleDomTextCommit}
|
|
2825
|
+
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
|
2826
|
+
onAddTextField={handleDomAddTextField}
|
|
2827
|
+
onRemoveTextField={handleDomRemoveTextField}
|
|
2828
|
+
onDetachFromLayout={handleDomDetachFromLayout}
|
|
2829
|
+
onAskAgent={handleAskAgent}
|
|
2830
|
+
onCopyAgentInstruction={handleAgentModalSubmit}
|
|
2831
|
+
onImportAssets={handleImportFiles}
|
|
2832
|
+
fontAssets={fontAssets}
|
|
2833
|
+
onImportFonts={handleImportFonts}
|
|
2834
|
+
/>
|
|
2835
|
+
) : (
|
|
2836
|
+
<RenderQueue
|
|
2837
|
+
jobs={renderQueue.jobs}
|
|
2838
|
+
projectId={projectId}
|
|
2839
|
+
onDelete={renderQueue.deleteRender}
|
|
2840
|
+
onClearCompleted={renderQueue.clearCompleted}
|
|
2841
|
+
onStartRender={(format, quality) =>
|
|
2842
|
+
renderQueue.startRender(30, quality, format)
|
|
2843
|
+
}
|
|
2844
|
+
isRendering={renderQueue.isRendering}
|
|
2845
|
+
/>
|
|
2846
|
+
)}
|
|
2847
|
+
</div>
|
|
2848
|
+
</>
|
|
1601
2849
|
)}
|
|
1602
2850
|
</div>
|
|
1603
2851
|
</>
|
|
1604
2852
|
)}
|
|
1605
2853
|
</div>
|
|
1606
2854
|
|
|
1607
|
-
{timelineElements.length > 0 && !timelineEditorHintDismissed && (
|
|
1608
|
-
<div className="pointer-events-none absolute bottom-5 left-5 z-[140]">
|
|
1609
|
-
<TimelineEditorNotice onDismiss={dismissTimelineEditorHint} />
|
|
1610
|
-
</div>
|
|
1611
|
-
)}
|
|
1612
|
-
|
|
1613
2855
|
{/* Lint modal */}
|
|
1614
2856
|
{lintModal !== null && projectId && (
|
|
1615
2857
|
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
|
@@ -1624,6 +2866,15 @@ export function StudioApp() {
|
|
|
1624
2866
|
/>
|
|
1625
2867
|
)}
|
|
1626
2868
|
|
|
2869
|
+
{/* Ask agent modal */}
|
|
2870
|
+
{agentModalOpen && domEditSelection && (
|
|
2871
|
+
<AskAgentModal
|
|
2872
|
+
selectionLabel={domEditSelection.label}
|
|
2873
|
+
onSubmit={handleAgentModalSubmit}
|
|
2874
|
+
onClose={() => setAgentModalOpen(false)}
|
|
2875
|
+
/>
|
|
2876
|
+
)}
|
|
2877
|
+
|
|
1627
2878
|
{/* Global drag-drop overlay */}
|
|
1628
2879
|
{globalDragOver && (
|
|
1629
2880
|
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|