@hyperframes/studio 0.6.0-alpha.13 → 0.6.0-alpha.14

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 (51) hide show
  1. package/dist/assets/hyperframes-player-BI1oj9hu.js +418 -0
  2. package/dist/assets/index-CBj2NLRG.js +117 -0
  3. package/dist/assets/index-D1JDq7Gg.css +1 -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 +424 -4217
  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/PropertyPanel.tsx +125 -2808
  15. package/src/components/editor/manualEdits.ts +32 -0
  16. package/src/components/editor/propertyPanelColor.tsx +371 -0
  17. package/src/components/editor/propertyPanelFill.tsx +421 -0
  18. package/src/components/editor/propertyPanelFont.tsx +455 -0
  19. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  20. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  21. package/src/components/editor/propertyPanelSections.tsx +453 -0
  22. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  23. package/src/contexts/DomEditContext.tsx +137 -0
  24. package/src/contexts/FileManagerContext.tsx +110 -0
  25. package/src/contexts/PanelLayoutContext.tsx +68 -0
  26. package/src/contexts/StudioContext.tsx +135 -0
  27. package/src/hooks/useAppHotkeys.ts +326 -0
  28. package/src/hooks/useAskAgentModal.ts +162 -0
  29. package/src/hooks/useCaptionDetection.ts +132 -0
  30. package/src/hooks/useCompositionDimensions.ts +25 -0
  31. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  32. package/src/hooks/useDomEditCommits.ts +437 -0
  33. package/src/hooks/useDomEditSession.ts +342 -0
  34. package/src/hooks/useDomEditTextCommits.ts +330 -0
  35. package/src/hooks/useDomSelection.ts +398 -0
  36. package/src/hooks/useFileManager.ts +431 -0
  37. package/src/hooks/useFrameCapture.ts +77 -0
  38. package/src/hooks/useLintModal.ts +35 -0
  39. package/src/hooks/useManifestPersistence.ts +492 -0
  40. package/src/hooks/usePanelLayout.ts +68 -0
  41. package/src/hooks/usePreviewInteraction.ts +153 -0
  42. package/src/hooks/useRenderClipContent.ts +124 -0
  43. package/src/hooks/useTimelineEditing.ts +472 -0
  44. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  45. package/src/utils/domEditHelpers.ts +50 -0
  46. package/src/utils/studioFontHelpers.ts +83 -0
  47. package/src/utils/studioHelpers.ts +214 -0
  48. package/src/utils/studioPreviewHelpers.ts +185 -0
  49. package/dist/assets/hyperframes-player-DMgdgHZd.js +0 -418
  50. package/dist/assets/index-B0OzpJPU.css +0 -1
  51. package/dist/assets/index-SEkerIt9.js +0 -110
@@ -0,0 +1,472 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { usePlayerStore } from "../player";
4
+ import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
5
+ import {
6
+ buildTrackZIndexMap,
7
+ formatTimelineAttributeNumber,
8
+ } from "../player/components/timelineEditing";
9
+ import {
10
+ buildTimelineAssetId,
11
+ buildTimelineAssetInsertHtml,
12
+ buildTimelineFileDropPlacements,
13
+ getTimelineAssetKind,
14
+ insertTimelineAssetIntoSource,
15
+ resolveTimelineAssetInitialGeometry,
16
+ resolveTimelineAssetSrc,
17
+ } from "../utils/timelineAssetDrop";
18
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
19
+ import {
20
+ getTimelineElementLabel,
21
+ collectHtmlIds,
22
+ resolveDroppedAssetDuration,
23
+ } from "../utils/studioHelpers";
24
+ import type { EditHistoryKind } from "../utils/editHistory";
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
+ interface UseTimelineEditingOptions {
36
+ projectId: string | null;
37
+ activeCompPath: string | null;
38
+ timelineElements: TimelineElement[];
39
+ showToast: (message: string, tone?: "error" | "info") => void;
40
+ writeProjectFile: (path: string, content: string) => Promise<void>;
41
+ recordEdit: (input: RecordEditInput) => Promise<void>;
42
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
43
+ reloadPreview: () => void;
44
+ uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
45
+ }
46
+
47
+ // ── Helpers ──
48
+
49
+ function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
50
+ if (element.domId) {
51
+ return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
52
+ }
53
+ if (element.selector) {
54
+ return { selector: element.selector, selectorIndex: element.selectorIndex };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ async function readFileContent(projectId: string, targetPath: string): Promise<string> {
60
+ const response = await fetch(
61
+ `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
62
+ );
63
+ if (!response.ok) {
64
+ throw new Error(`Failed to read ${targetPath}`);
65
+ }
66
+ const data = (await response.json()) as { content?: string };
67
+ if (typeof data.content !== "string") {
68
+ throw new Error(`Missing file contents for ${targetPath}`);
69
+ }
70
+ return data.content;
71
+ }
72
+
73
+ // ── Hook ──
74
+
75
+ export function useTimelineEditing({
76
+ projectId,
77
+ activeCompPath,
78
+ timelineElements,
79
+ showToast,
80
+ writeProjectFile,
81
+ recordEdit,
82
+ domEditSaveTimestampRef,
83
+ reloadPreview,
84
+ uploadProjectFiles,
85
+ }: UseTimelineEditingOptions) {
86
+ const projectIdRef = useRef(projectId);
87
+ projectIdRef.current = projectId;
88
+
89
+ const lastBlockedTimelineToastAtRef = useRef(0);
90
+
91
+ const handleTimelineElementMove = useCallback(
92
+ async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
93
+ const pid = projectIdRef.current;
94
+ if (!pid) throw new Error("No active project");
95
+
96
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
97
+ const originalContent = await readFileContent(pid, targetPath);
98
+
99
+ const patchTarget = buildPatchTarget(element);
100
+ if (!patchTarget) {
101
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
102
+ }
103
+
104
+ const resolvedTargetPath = targetPath || "index.html";
105
+ const relevantElements = timelineElements
106
+ .map((te) =>
107
+ (te.key ?? te.id) === (element.key ?? element.id)
108
+ ? { ...te, start: updates.start, track: updates.track }
109
+ : te,
110
+ )
111
+ .filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath);
112
+ const trackZIndices = buildTrackZIndexMap(relevantElements.map((te) => te.track));
113
+
114
+ let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
115
+ type: "attribute",
116
+ property: "start",
117
+ value: formatTimelineAttributeNumber(updates.start),
118
+ });
119
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
120
+ type: "attribute",
121
+ property: "track-index",
122
+ value: String(updates.track),
123
+ });
124
+ for (const te of relevantElements) {
125
+ const elementTarget = buildPatchTarget(te);
126
+ if (!elementTarget) continue;
127
+ const nextZIndex = trackZIndices.get(te.track);
128
+ if (nextZIndex == null) continue;
129
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
130
+ type: "inline-style",
131
+ property: "z-index",
132
+ value: String(nextZIndex),
133
+ });
134
+ }
135
+
136
+ if (patchedContent === originalContent) {
137
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
138
+ }
139
+
140
+ domEditSaveTimestampRef.current = Date.now();
141
+ await saveProjectFilesWithHistory({
142
+ projectId: pid,
143
+ label: "Move timeline clip",
144
+ kind: "timeline",
145
+ files: { [targetPath]: patchedContent },
146
+ readFile: async () => originalContent,
147
+ writeFile: writeProjectFile,
148
+ recordEdit,
149
+ });
150
+
151
+ reloadPreview();
152
+ },
153
+ [
154
+ activeCompPath,
155
+ recordEdit,
156
+ timelineElements,
157
+ writeProjectFile,
158
+ domEditSaveTimestampRef,
159
+ reloadPreview,
160
+ ],
161
+ );
162
+
163
+ const handleTimelineElementResize = useCallback(
164
+ async (
165
+ element: TimelineElement,
166
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
167
+ ) => {
168
+ const pid = projectIdRef.current;
169
+ if (!pid) throw new Error("No active project");
170
+
171
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
172
+ const originalContent = await readFileContent(pid, targetPath);
173
+
174
+ const patchTarget = buildPatchTarget(element);
175
+ if (!patchTarget) {
176
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
177
+ }
178
+
179
+ const playbackStartAttrName =
180
+ element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
181
+ const currentPlaybackStartValue =
182
+ readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
183
+ readAttributeByTarget(originalContent, patchTarget, "media-start");
184
+ const currentPlaybackStart =
185
+ currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
186
+ const trimDelta = updates.start - element.start;
187
+ const fallbackPlaybackStart =
188
+ updates.playbackStart == null &&
189
+ trimDelta !== 0 &&
190
+ Number.isFinite(currentPlaybackStart) &&
191
+ currentPlaybackStart != null
192
+ ? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
193
+ : undefined;
194
+ const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
195
+
196
+ let patchedContent = originalContent;
197
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
198
+ type: "attribute",
199
+ property: "start",
200
+ value: formatTimelineAttributeNumber(updates.start),
201
+ });
202
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
203
+ type: "attribute",
204
+ property: "duration",
205
+ value: formatTimelineAttributeNumber(updates.duration),
206
+ });
207
+ if (nextPlaybackStart != null) {
208
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
209
+ type: "attribute",
210
+ property: playbackStartAttrName,
211
+ value: formatTimelineAttributeNumber(nextPlaybackStart),
212
+ });
213
+ }
214
+
215
+ if (patchedContent === originalContent) {
216
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
217
+ }
218
+
219
+ domEditSaveTimestampRef.current = Date.now();
220
+ await saveProjectFilesWithHistory({
221
+ projectId: pid,
222
+ label: "Resize timeline clip",
223
+ kind: "timeline",
224
+ files: { [targetPath]: patchedContent },
225
+ readFile: async () => originalContent,
226
+ writeFile: writeProjectFile,
227
+ recordEdit,
228
+ });
229
+
230
+ reloadPreview();
231
+ },
232
+ [activeCompPath, recordEdit, writeProjectFile, domEditSaveTimestampRef, reloadPreview],
233
+ );
234
+
235
+ const handleTimelineElementDelete = useCallback(
236
+ async (element: TimelineElement) => {
237
+ const pid = projectIdRef.current;
238
+ if (!pid) throw new Error("No active project");
239
+ const label = getTimelineElementLabel(element);
240
+
241
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
242
+ try {
243
+ const originalContent = await readFileContent(pid, targetPath);
244
+
245
+ const patchTarget = buildPatchTarget(element);
246
+ if (!patchTarget) {
247
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
248
+ }
249
+
250
+ const resolvedTargetPath = targetPath || "index.html";
251
+ const remainingElements = timelineElements.filter(
252
+ (te) =>
253
+ (te.key ?? te.id) !== (element.key ?? element.id) &&
254
+ (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
255
+ );
256
+ const trackZIndices = buildTrackZIndexMap(remainingElements.map((te) => te.track));
257
+
258
+ const removeResponse = await fetch(
259
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
260
+ {
261
+ method: "POST",
262
+ headers: { "Content-Type": "application/json" },
263
+ body: JSON.stringify({ target: patchTarget }),
264
+ },
265
+ );
266
+ if (!removeResponse.ok) {
267
+ throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
268
+ }
269
+
270
+ const removeData = (await removeResponse.json()) as {
271
+ changed?: boolean;
272
+ content?: string;
273
+ };
274
+ let patchedContent =
275
+ typeof removeData.content === "string" ? removeData.content : originalContent;
276
+ for (const te of remainingElements) {
277
+ const elementTarget = buildPatchTarget(te);
278
+ if (!elementTarget) continue;
279
+ const nextZIndex = trackZIndices.get(te.track);
280
+ if (nextZIndex == null) continue;
281
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
282
+ type: "inline-style",
283
+ property: "z-index",
284
+ value: String(nextZIndex),
285
+ });
286
+ }
287
+
288
+ domEditSaveTimestampRef.current = Date.now();
289
+ await saveProjectFilesWithHistory({
290
+ projectId: pid,
291
+ label: "Delete timeline clip",
292
+ kind: "timeline",
293
+ files: { [targetPath]: patchedContent },
294
+ readFile: async () => originalContent,
295
+ writeFile: writeProjectFile,
296
+ recordEdit,
297
+ });
298
+
299
+ usePlayerStore
300
+ .getState()
301
+ .setElements(
302
+ timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
303
+ );
304
+ usePlayerStore.getState().setSelectedElementId(null);
305
+ reloadPreview();
306
+ showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
307
+ } catch (error) {
308
+ const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
309
+ showToast(message);
310
+ }
311
+ },
312
+ [
313
+ activeCompPath,
314
+ recordEdit,
315
+ showToast,
316
+ timelineElements,
317
+ writeProjectFile,
318
+ domEditSaveTimestampRef,
319
+ reloadPreview,
320
+ ],
321
+ );
322
+
323
+ const handleTimelineAssetDrop = useCallback(
324
+ async (
325
+ assetPath: string,
326
+ placement: Pick<TimelineElement, "start" | "track">,
327
+ durationOverride?: number,
328
+ ) => {
329
+ const pid = projectIdRef.current;
330
+ if (!pid) throw new Error("No active project");
331
+
332
+ const kind = getTimelineAssetKind(assetPath);
333
+ if (!kind) {
334
+ showToast("Only image, video, and audio assets can be dropped onto the timeline.");
335
+ return;
336
+ }
337
+
338
+ const targetPath = activeCompPath || "index.html";
339
+ try {
340
+ const originalContent = await readFileContent(pid, targetPath);
341
+
342
+ const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
343
+ const duration =
344
+ Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
345
+ ? durationOverride
346
+ : await resolveDroppedAssetDuration(pid, assetPath, kind);
347
+ const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
348
+ const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
349
+ const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
350
+
351
+ const resolvedTargetPath = targetPath || "index.html";
352
+ const relevantElements = timelineElements.filter(
353
+ (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
354
+ );
355
+ const trackZIndices = buildTrackZIndexMap([
356
+ ...relevantElements.map((te) => te.track),
357
+ placement.track,
358
+ ]);
359
+
360
+ let patchedContent = originalContent;
361
+ for (const te of relevantElements) {
362
+ const elementTarget = buildPatchTarget(te);
363
+ if (!elementTarget) continue;
364
+ const nextZIndex = trackZIndices.get(te.track);
365
+ if (nextZIndex == null) continue;
366
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
367
+ type: "inline-style",
368
+ property: "z-index",
369
+ value: String(nextZIndex),
370
+ });
371
+ }
372
+
373
+ patchedContent = insertTimelineAssetIntoSource(
374
+ patchedContent,
375
+ buildTimelineAssetInsertHtml({
376
+ id: newId,
377
+ assetPath: resolvedAssetSrc,
378
+ kind,
379
+ start: normalizedStart,
380
+ duration: normalizedDuration,
381
+ track: placement.track,
382
+ zIndex: trackZIndices.get(placement.track) ?? 1,
383
+ geometry: resolveTimelineAssetInitialGeometry(originalContent),
384
+ }),
385
+ );
386
+
387
+ domEditSaveTimestampRef.current = Date.now();
388
+ await saveProjectFilesWithHistory({
389
+ projectId: pid,
390
+ label: "Add timeline asset",
391
+ kind: "timeline",
392
+ files: { [targetPath]: patchedContent },
393
+ readFile: async () => originalContent,
394
+ writeFile: writeProjectFile,
395
+ recordEdit,
396
+ });
397
+
398
+ reloadPreview();
399
+ } catch (error) {
400
+ const message =
401
+ error instanceof Error ? error.message : "Failed to drop asset onto timeline";
402
+ showToast(message);
403
+ }
404
+ },
405
+ [
406
+ activeCompPath,
407
+ recordEdit,
408
+ showToast,
409
+ timelineElements,
410
+ writeProjectFile,
411
+ domEditSaveTimestampRef,
412
+ reloadPreview,
413
+ ],
414
+ );
415
+
416
+ const handleTimelineFileDrop = useCallback(
417
+ async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
418
+ const pid = projectIdRef.current;
419
+ if (!pid) return;
420
+ const uploaded = await uploadProjectFiles(files);
421
+ if (uploaded.length === 0) return;
422
+ const durations: number[] = [];
423
+ for (const assetPath of uploaded) {
424
+ const kind = getTimelineAssetKind(assetPath);
425
+ const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
426
+ durations.push(Number(formatTimelineAttributeNumber(duration)));
427
+ }
428
+ const placements = buildTimelineFileDropPlacements(
429
+ placement ?? { start: 0, track: 0 },
430
+ durations,
431
+ timelineElements
432
+ .filter(
433
+ (te) =>
434
+ (te.sourceFile || activeCompPath || "index.html") ===
435
+ (activeCompPath || "index.html"),
436
+ )
437
+ .map((te) => ({
438
+ start: te.start,
439
+ duration: te.duration,
440
+ track: te.track,
441
+ })),
442
+ );
443
+ for (const [index, assetPath] of uploaded.entries()) {
444
+ await handleTimelineAssetDrop(
445
+ assetPath,
446
+ placements[index] ?? placements[0],
447
+ durations[index],
448
+ );
449
+ }
450
+ },
451
+ [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
452
+ );
453
+
454
+ const handleBlockedTimelineEdit = useCallback(
455
+ (_element: TimelineElement) => {
456
+ const now = Date.now();
457
+ if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
458
+ lastBlockedTimelineToastAtRef.current = now;
459
+ showToast("This clip can't be moved or resized from the timeline yet.", "info");
460
+ },
461
+ [showToast],
462
+ );
463
+
464
+ return {
465
+ handleTimelineElementMove,
466
+ handleTimelineElementResize,
467
+ handleTimelineElementDelete,
468
+ handleTimelineAssetDrop,
469
+ handleTimelineFileDrop,
470
+ handleBlockedTimelineEdit,
471
+ };
472
+ }