@hyperframes/studio 0.6.0-alpha.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +33 -2
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-DYCiFGWQ.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,132 @@
1
+ import { useEffect } from "react";
2
+ import { useCaptionStore } from "../captions/store";
3
+ import { useCaptionSync } from "../captions/hooks/useCaptionSync";
4
+ import { parseCaptionComposition } from "../captions/parser";
5
+
6
+ interface UseCaptionDetectionParams {
7
+ projectId: string | null;
8
+ activeCompPath: string | null;
9
+ compIdToSrc: Map<string, string>;
10
+ captionEditMode: boolean;
11
+ captionHasSelection: boolean;
12
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
13
+ captionSync: ReturnType<typeof useCaptionSync>;
14
+ setRightCollapsed: (collapsed: boolean) => void;
15
+ }
16
+
17
+ export function useCaptionDetection({
18
+ projectId,
19
+ activeCompPath,
20
+ compIdToSrc,
21
+ captionEditMode,
22
+ captionHasSelection,
23
+ previewIframeRef,
24
+ captionSync,
25
+ setRightCollapsed,
26
+ }: UseCaptionDetectionParams) {
27
+ // eslint-disable-next-line no-restricted-syntax
28
+ useEffect(() => {
29
+ if (!projectId) return;
30
+
31
+ let activating = false;
32
+
33
+ const tryActivateCaptions = () => {
34
+ if (useCaptionStore.getState().isEditMode || activating) {
35
+ return;
36
+ }
37
+
38
+ const iframe = previewIframeRef.current;
39
+ let doc: Document | null = null;
40
+ let win: Window | null = null;
41
+ try {
42
+ doc = iframe?.contentDocument ?? null;
43
+ win = iframe?.contentWindow ?? null;
44
+ } catch {
45
+ return;
46
+ }
47
+ if (!doc || !win) return;
48
+
49
+ const groups = doc.querySelectorAll(".caption-group");
50
+ if (groups.length === 0) return;
51
+
52
+ let captionSrcPath: string | null = null;
53
+
54
+ const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
55
+ for (const host of compHosts) {
56
+ const src =
57
+ host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
58
+ if (src && src.includes("captions")) {
59
+ captionSrcPath = src;
60
+ break;
61
+ }
62
+ }
63
+
64
+ if (!captionSrcPath) {
65
+ for (const [id, src] of compIdToSrc) {
66
+ if (id.includes("caption") || src.includes("caption")) {
67
+ captionSrcPath = src;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+
73
+ if (!captionSrcPath && activeCompPath?.includes("captions")) {
74
+ captionSrcPath = activeCompPath;
75
+ }
76
+
77
+ if (!captionSrcPath) {
78
+ const captionComp = doc.querySelector('[data-composition-id*="caption"]');
79
+ if (captionComp) {
80
+ const compId = captionComp.getAttribute("data-composition-id") || "";
81
+ captionSrcPath = compIdToSrc.get(compId) || null;
82
+ }
83
+ }
84
+
85
+ if (!captionSrcPath) return;
86
+
87
+ activating = true;
88
+ const srcPath = captionSrcPath;
89
+ fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
90
+ .then((r) => r.json())
91
+ .then((data: { content?: string }) => {
92
+ if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
93
+ const root = doc.querySelector("[data-composition-id]");
94
+ const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
95
+ const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
96
+ const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
97
+ const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
98
+ if (!model) return;
99
+ const store = useCaptionStore.getState();
100
+ store.setModel(model);
101
+ store.setSourceFilePath(srcPath);
102
+ store.setEditMode(true);
103
+ captionSync.loadOverrides();
104
+ })
105
+ .catch(() => {})
106
+ .finally(() => {
107
+ activating = false;
108
+ });
109
+ };
110
+
111
+ const handleMessage = (e: MessageEvent) => {
112
+ const data = e.data;
113
+ if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
114
+ tryActivateCaptions();
115
+ }
116
+ };
117
+
118
+ window.addEventListener("message", handleMessage);
119
+ tryActivateCaptions();
120
+
121
+ return () => {
122
+ window.removeEventListener("message", handleMessage);
123
+ };
124
+ }, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef]);
125
+
126
+ // eslint-disable-next-line no-restricted-syntax
127
+ useEffect(() => {
128
+ if (captionEditMode) {
129
+ setRightCollapsed(!captionHasSelection);
130
+ }
131
+ }, [captionHasSelection, captionEditMode, setRightCollapsed]);
132
+ }
@@ -0,0 +1,25 @@
1
+ import { useState } from "react";
2
+ import { useMountEffect } from "./useMountEffect";
3
+ import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
+
5
+ export function useCompositionDimensions() {
6
+ const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
7
+ null,
8
+ );
9
+
10
+ useMountEffect(() => {
11
+ const handleMessage = (e: MessageEvent) => {
12
+ const data = e.data;
13
+ if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
14
+ const { width, height } = data as { width: number; height: number };
15
+ if (!(width > 0) || !(height > 0)) return;
16
+ setCompositionDimensions((prev) =>
17
+ prev && prev.width === width && prev.height === height ? prev : { width, height },
18
+ );
19
+ };
20
+ window.addEventListener("message", handleMessage);
21
+ return () => window.removeEventListener("message", handleMessage);
22
+ });
23
+
24
+ return compositionDimensions;
25
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { LintFinding } from "../components/LintModal";
3
+
4
+ /**
5
+ * Captures `console.error` and `window.onerror` events from a preview iframe
6
+ * and exposes them as LintFinding[] for the console errors modal.
7
+ */
8
+ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null) {
9
+ const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
10
+ const consoleErrorsRef = useRef<LintFinding[]>([]);
11
+
12
+ const resetErrors = () => {
13
+ consoleErrorsRef.current = [];
14
+ setConsoleErrors(null);
15
+ };
16
+
17
+ // eslint-disable-next-line no-restricted-syntax
18
+ useEffect(() => {
19
+ if (!previewIframe) return;
20
+ const attachErrorCapture = () => {
21
+ try {
22
+ const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
23
+ if (!win) return;
24
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
25
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
26
+ const origError = win.console.error.bind(win.console);
27
+ win.console.error = function (...args: unknown[]) {
28
+ origError(...args);
29
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
30
+ if (text.includes("favicon")) return;
31
+ consoleErrorsRef.current = [
32
+ ...consoleErrorsRef.current,
33
+ { severity: "error", message: text },
34
+ ];
35
+ setConsoleErrors([...consoleErrorsRef.current]);
36
+ };
37
+ win.addEventListener("error", (e: ErrorEvent) => {
38
+ const text = e.message || String(e);
39
+ consoleErrorsRef.current = [
40
+ ...consoleErrorsRef.current,
41
+ { severity: "error", message: text },
42
+ ];
43
+ setConsoleErrors([...consoleErrorsRef.current]);
44
+ });
45
+ } catch {
46
+ /* same-origin only */
47
+ }
48
+ };
49
+ attachErrorCapture();
50
+ const handleLoad = () => {
51
+ consoleErrorsRef.current = [];
52
+ setConsoleErrors(null);
53
+ attachErrorCapture();
54
+ };
55
+ previewIframe.addEventListener("load", handleLoad);
56
+ return () => previewIframe.removeEventListener("load", handleLoad);
57
+ }, [previewIframe]);
58
+
59
+ return { consoleErrors, setConsoleErrors, resetErrors };
60
+ }
@@ -0,0 +1,437 @@
1
+ import { useCallback } from "react";
2
+ import { usePlayerStore } from "../player";
3
+ import { FONT_EXT } from "../utils/mediaTypes";
4
+ import { applyPatchByTarget } from "../utils/sourcePatcher";
5
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
6
+ import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
7
+ import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
8
+ import {
9
+ removeStudioManualEditsForSelection,
10
+ type StudioManualEditManifest,
11
+ upsertStudioBoxSizeEdit,
12
+ upsertStudioPathOffsetEdit,
13
+ upsertStudioRotationEdit,
14
+ } from "../components/editor/manualEdits";
15
+ import {
16
+ removeStudioMotionForSelection,
17
+ type StudioGsapMotion,
18
+ type StudioMotionManifest,
19
+ upsertStudioGsapMotion,
20
+ } from "../components/editor/studioMotion";
21
+ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
22
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
23
+ import type { EditHistoryKind } from "../utils/editHistory";
24
+ import { useDomEditTextCommits } from "./useDomEditTextCommits";
25
+
26
+ // ── Types ──
27
+
28
+ interface RecordEditInput {
29
+ label: string;
30
+ kind: EditHistoryKind;
31
+ coalesceKey?: string;
32
+ files: Record<string, { before: string; after: string }>;
33
+ }
34
+
35
+ export type PersistDomEditOperations = (
36
+ selection: DomEditSelection,
37
+ operations: Parameters<typeof applyPatchByTarget>[2][],
38
+ options?: {
39
+ label?: string;
40
+ coalesceKey?: string;
41
+ skipRefresh?: boolean;
42
+ prepareContent?: (html: string, sourceFile: string) => string;
43
+ shouldSave?: () => boolean;
44
+ },
45
+ ) => Promise<void>;
46
+
47
+ export interface UseDomEditCommitsParams {
48
+ activeCompPath: string | null;
49
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
50
+ showToast: (message: string, tone?: "error" | "info") => void;
51
+ commitStudioManualEditManifestOptimistically: (
52
+ updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
53
+ options: { label: string; coalesceKey: string },
54
+ ) => void;
55
+ commitStudioMotionManifestOptimistically: (
56
+ updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
57
+ options: { label: string; coalesceKey: string },
58
+ ) => void;
59
+ applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void;
60
+ applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
61
+ writeProjectFile: (path: string, content: string) => Promise<void>;
62
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
63
+ editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
64
+ fileTree: string[];
65
+ importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
66
+ projectId: string | null;
67
+ projectIdRef: React.MutableRefObject<string | null>;
68
+ reloadPreview: () => void;
69
+
70
+ // From useDomSelection
71
+ domEditSelection: DomEditSelection | null;
72
+ domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
73
+ domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
74
+ applyDomSelection: (
75
+ selection: DomEditSelection | null,
76
+ options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
77
+ ) => void;
78
+ clearDomSelection: () => void;
79
+ refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
80
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
81
+ buildDomSelectionFromTarget: (
82
+ target: HTMLElement,
83
+ options?: { preferClipAncestor?: boolean },
84
+ ) => DomEditSelection | null;
85
+ }
86
+
87
+ // ── Hook ──
88
+
89
+ export function useDomEditCommits({
90
+ activeCompPath,
91
+ previewIframeRef,
92
+ showToast,
93
+ commitStudioManualEditManifestOptimistically,
94
+ commitStudioMotionManifestOptimistically,
95
+ applyCurrentStudioManualEditsToPreview,
96
+ applyCurrentStudioMotionToPreview,
97
+ writeProjectFile,
98
+ domEditSaveTimestampRef,
99
+ editHistory,
100
+ fileTree,
101
+ importedFontAssetsRef,
102
+ projectId,
103
+ projectIdRef,
104
+ reloadPreview,
105
+ domEditSelection,
106
+ domEditGroupSelectionsRef,
107
+ applyDomSelection,
108
+ clearDomSelection,
109
+ refreshDomEditSelectionFromPreview,
110
+ refreshDomEditGroupSelectionsFromPreview,
111
+ buildDomSelectionFromTarget,
112
+ }: UseDomEditCommitsParams) {
113
+ const resolveImportedFontAsset = useCallback(
114
+ (fontFamilyValue: string): ImportedFontAsset | null => {
115
+ const family = primaryFontFamilyValue(fontFamilyValue);
116
+ if (!family) return null;
117
+ const imported = importedFontAssetsRef.current.find(
118
+ (font) => font.family.toLowerCase() === family.toLowerCase(),
119
+ );
120
+ if (imported) return imported;
121
+ const asset = fileTree.find(
122
+ (path) =>
123
+ FONT_EXT.test(path) &&
124
+ fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
125
+ );
126
+ if (!asset) return null;
127
+ return {
128
+ family: fontFamilyFromAssetPath(asset),
129
+ path: asset,
130
+ url: `/api/projects/${projectId}/preview/${asset}`,
131
+ };
132
+ },
133
+ [fileTree, projectId, importedFontAssetsRef],
134
+ );
135
+
136
+ const persistDomEditOperations: PersistDomEditOperations = useCallback(
137
+ async (selection, operations, options) => {
138
+ const pid = projectIdRef.current;
139
+ if (!pid) throw new Error("No active project");
140
+ if (options?.shouldSave && !options.shouldSave()) return;
141
+
142
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
143
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
144
+ if (!response.ok) {
145
+ throw new Error(`Failed to read ${targetPath}`);
146
+ }
147
+
148
+ const data = (await response.json()) as { content?: string };
149
+ const originalContent = data.content;
150
+ if (typeof originalContent !== "string") {
151
+ throw new Error(`Missing file contents for ${targetPath}`);
152
+ }
153
+
154
+ let patchedContent = originalContent;
155
+ for (const operation of operations) {
156
+ patchedContent = applyPatchByTarget(patchedContent, selection, operation);
157
+ }
158
+ if (options?.prepareContent) {
159
+ patchedContent = options.prepareContent(patchedContent, targetPath);
160
+ }
161
+ if (options?.shouldSave && !options.shouldSave()) return;
162
+
163
+ if (patchedContent === originalContent) {
164
+ throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
165
+ }
166
+
167
+ await saveProjectFilesWithHistory({
168
+ projectId: pid,
169
+ label: options?.label ?? "Edit layer",
170
+ kind: "manual",
171
+ coalesceKey: options?.coalesceKey,
172
+ files: { [targetPath]: patchedContent },
173
+ readFile: async () => originalContent,
174
+ writeFile: writeProjectFile,
175
+ recordEdit: editHistory.recordEdit,
176
+ });
177
+
178
+ if (options?.skipRefresh) {
179
+ domEditSaveTimestampRef.current = Date.now();
180
+ } else {
181
+ reloadPreview();
182
+ }
183
+ },
184
+ [
185
+ activeCompPath,
186
+ editHistory.recordEdit,
187
+ writeProjectFile,
188
+ projectIdRef,
189
+ domEditSaveTimestampRef,
190
+ reloadPreview,
191
+ ],
192
+ );
193
+
194
+ // ── Text & style commits (delegated to useDomEditTextCommits) ──
195
+
196
+ const {
197
+ handleDomStyleCommit,
198
+ handleDomTextCommit,
199
+ commitDomTextFields,
200
+ handleDomTextFieldStyleCommit,
201
+ handleDomAddTextField,
202
+ handleDomRemoveTextField,
203
+ } = useDomEditTextCommits({
204
+ activeCompPath,
205
+ previewIframeRef,
206
+ domEditSelection,
207
+ applyDomSelection,
208
+ refreshDomEditSelectionFromPreview,
209
+ buildDomSelectionFromTarget,
210
+ persistDomEditOperations,
211
+ resolveImportedFontAsset,
212
+ });
213
+
214
+ // ── Manifest commits ──
215
+
216
+ const handleDomPathOffsetCommit = useCallback(
217
+ (selection: DomEditSelection, next: { x: number; y: number }) => {
218
+ commitStudioManualEditManifestOptimistically(
219
+ (manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
220
+ {
221
+ label: "Move layer",
222
+ coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
223
+ },
224
+ );
225
+ refreshDomEditSelectionFromPreview(selection);
226
+ },
227
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
228
+ );
229
+
230
+ const handleDomGroupPathOffsetCommit = useCallback(
231
+ (updates: DomEditGroupPathOffsetCommit[]) => {
232
+ if (updates.length === 0) return;
233
+ const coalesceKey = updates
234
+ .map((update) => getDomEditTargetKey(update.selection))
235
+ .sort()
236
+ .join(":");
237
+ commitStudioManualEditManifestOptimistically(
238
+ (manifest) =>
239
+ updates.reduce(
240
+ (nextManifest, update) =>
241
+ upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
242
+ manifest,
243
+ ),
244
+ {
245
+ label: `Move ${updates.length} layers`,
246
+ coalesceKey: `group-path-offset:${coalesceKey}`,
247
+ },
248
+ );
249
+ refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
250
+ },
251
+ [
252
+ commitStudioManualEditManifestOptimistically,
253
+ domEditGroupSelectionsRef,
254
+ refreshDomEditGroupSelectionsFromPreview,
255
+ ],
256
+ );
257
+
258
+ const handleDomBoxSizeCommit = useCallback(
259
+ (selection: DomEditSelection, next: { width: number; height: number }) => {
260
+ commitStudioManualEditManifestOptimistically(
261
+ (manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
262
+ {
263
+ label: "Resize layer box",
264
+ coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
265
+ },
266
+ );
267
+ refreshDomEditSelectionFromPreview(selection);
268
+ },
269
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
270
+ );
271
+
272
+ const handleDomRotationCommit = useCallback(
273
+ (selection: DomEditSelection, next: { angle: number }) => {
274
+ commitStudioManualEditManifestOptimistically(
275
+ (manifest) => upsertStudioRotationEdit(manifest, selection, next),
276
+ {
277
+ label: "Rotate layer",
278
+ coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
279
+ },
280
+ );
281
+ refreshDomEditSelectionFromPreview(selection);
282
+ },
283
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
284
+ );
285
+
286
+ const handleDomManualEditsReset = useCallback(
287
+ (selection: DomEditSelection) => {
288
+ commitStudioManualEditManifestOptimistically(
289
+ (manifest) => removeStudioManualEditsForSelection(manifest, selection),
290
+ {
291
+ label: "Reset layer edits",
292
+ coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
293
+ },
294
+ );
295
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
296
+ refreshDomEditSelectionFromPreview(selection);
297
+ },
298
+ [
299
+ applyCurrentStudioManualEditsToPreview,
300
+ commitStudioManualEditManifestOptimistically,
301
+ refreshDomEditSelectionFromPreview,
302
+ previewIframeRef,
303
+ ],
304
+ );
305
+
306
+ const handleDomMotionCommit = useCallback(
307
+ (
308
+ selection: DomEditSelection,
309
+ motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
310
+ ) => {
311
+ commitStudioMotionManifestOptimistically(
312
+ (manifest) => upsertStudioGsapMotion(manifest, selection, motion),
313
+ {
314
+ label: "Set GSAP motion",
315
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
316
+ },
317
+ );
318
+ refreshDomEditSelectionFromPreview(selection);
319
+ },
320
+ [commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
321
+ );
322
+
323
+ const handleDomMotionClear = useCallback(
324
+ (selection: DomEditSelection) => {
325
+ commitStudioMotionManifestOptimistically(
326
+ (manifest) => removeStudioMotionForSelection(manifest, selection),
327
+ {
328
+ label: "Clear GSAP motion",
329
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
330
+ },
331
+ );
332
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
333
+ refreshDomEditSelectionFromPreview(selection);
334
+ },
335
+ [
336
+ applyCurrentStudioMotionToPreview,
337
+ commitStudioMotionManifestOptimistically,
338
+ refreshDomEditSelectionFromPreview,
339
+ previewIframeRef,
340
+ ],
341
+ );
342
+
343
+ const handleDomEditElementDelete = useCallback(
344
+ async (selection: DomEditSelection) => {
345
+ const pid = projectIdRef.current;
346
+ if (!pid) return;
347
+ const label = selection.label || selection.id || selection.selector || selection.tagName;
348
+
349
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
350
+ try {
351
+ const response = await fetch(
352
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
353
+ );
354
+ if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
355
+
356
+ const data = (await response.json()) as { content?: string };
357
+ const originalContent = data.content;
358
+ if (typeof originalContent !== "string")
359
+ throw new Error(`Missing file contents for ${targetPath}`);
360
+
361
+ const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
362
+ ? {
363
+ id: selection.id,
364
+ selector: selection.selector,
365
+ selectorIndex: selection.selectorIndex,
366
+ }
367
+ : selection.selector
368
+ ? { selector: selection.selector, selectorIndex: selection.selectorIndex }
369
+ : ({} as never);
370
+ if (!patchTarget.id && !patchTarget.selector) {
371
+ throw new Error("Selected element has no patchable target");
372
+ }
373
+
374
+ const removeResponse = await fetch(
375
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
376
+ {
377
+ method: "POST",
378
+ headers: { "Content-Type": "application/json" },
379
+ body: JSON.stringify({ target: patchTarget }),
380
+ },
381
+ );
382
+ if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
383
+
384
+ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
385
+ const patchedContent =
386
+ typeof removeData.content === "string" ? removeData.content : originalContent;
387
+
388
+ domEditSaveTimestampRef.current = Date.now();
389
+ await saveProjectFilesWithHistory({
390
+ projectId: pid,
391
+ label: "Delete element",
392
+ kind: "timeline",
393
+ files: { [targetPath]: patchedContent },
394
+ readFile: async () => originalContent,
395
+ writeFile: writeProjectFile,
396
+ recordEdit: editHistory.recordEdit,
397
+ });
398
+
399
+ clearDomSelection();
400
+ usePlayerStore.getState().setSelectedElementId(null);
401
+ reloadPreview();
402
+ showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
403
+ } catch (error) {
404
+ const message = error instanceof Error ? error.message : "Failed to delete element";
405
+ showToast(message);
406
+ }
407
+ },
408
+ [
409
+ activeCompPath,
410
+ clearDomSelection,
411
+ domEditSaveTimestampRef,
412
+ editHistory.recordEdit,
413
+ projectIdRef,
414
+ reloadPreview,
415
+ showToast,
416
+ writeProjectFile,
417
+ ],
418
+ );
419
+
420
+ return {
421
+ resolveImportedFontAsset,
422
+ handleDomStyleCommit,
423
+ handleDomTextCommit,
424
+ commitDomTextFields,
425
+ handleDomTextFieldStyleCommit,
426
+ handleDomAddTextField,
427
+ handleDomRemoveTextField,
428
+ handleDomPathOffsetCommit,
429
+ handleDomGroupPathOffsetCommit,
430
+ handleDomBoxSizeCommit,
431
+ handleDomRotationCommit,
432
+ handleDomManualEditsReset,
433
+ handleDomMotionCommit,
434
+ handleDomMotionClear,
435
+ handleDomEditElementDelete,
436
+ };
437
+ }