@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.
Files changed (36) hide show
  1. package/dist/assets/index-BExHzIDS.js +105 -0
  2. package/dist/assets/index-BpcIkyVP.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +1327 -76
  6. package/src/components/editor/DomEditOverlay.tsx +410 -0
  7. package/src/components/editor/PropertyPanel.tsx +2462 -206
  8. package/src/components/editor/colorValue.test.ts +82 -0
  9. package/src/components/editor/colorValue.ts +175 -0
  10. package/src/components/editor/domEditing.test.ts +427 -0
  11. package/src/components/editor/domEditing.ts +733 -0
  12. package/src/components/editor/floatingPanel.test.ts +34 -0
  13. package/src/components/editor/floatingPanel.ts +54 -0
  14. package/src/components/editor/fontAssets.ts +32 -0
  15. package/src/components/editor/fontCatalog.ts +126 -0
  16. package/src/components/editor/gradientValue.test.ts +89 -0
  17. package/src/components/editor/gradientValue.ts +445 -0
  18. package/src/components/nle/NLELayout.tsx +9 -4
  19. package/src/components/nle/NLEPreview.tsx +50 -5
  20. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  21. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  22. package/src/components/sidebar/LeftSidebar.tsx +38 -33
  23. package/src/player/components/Player.tsx +18 -70
  24. package/src/player/components/Timeline.test.ts +0 -1
  25. package/src/player/components/Timeline.tsx +0 -3
  26. package/src/player/components/TimelineClip.tsx +20 -7
  27. package/src/player/components/timelineEditing.test.ts +0 -2
  28. package/src/player/components/timelineEditing.ts +0 -2
  29. package/src/player/hooks/useTimelinePlayer.ts +0 -17
  30. package/src/utils/mediaTypes.ts +1 -1
  31. package/src/utils/sourcePatcher.test.ts +128 -1
  32. package/src/utils/sourcePatcher.ts +130 -18
  33. package/src/utils/timelineAssetDrop.test.ts +31 -11
  34. package/src/utils/timelineAssetDrop.ts +22 -2
  35. package/dist/assets/index-CAscydDF.js +0 -115
  36. package/dist/assets/index-dpgHnQGg.css +0 -1
@@ -0,0 +1,410 @@
1
+ import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { type DomEditSelection, findElementForSelection } from "./domEditing";
4
+
5
+ interface OverlayRect {
6
+ left: number;
7
+ top: number;
8
+ width: number;
9
+ height: number;
10
+ scaleX: number;
11
+ scaleY: number;
12
+ }
13
+
14
+ interface DomEditOverlayProps {
15
+ iframeRef: RefObject<HTMLIFrameElement | null>;
16
+ selection: DomEditSelection | null;
17
+ onCanvasMouseDown: (event: React.MouseEvent<HTMLDivElement>) => void;
18
+ onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
19
+ onSelectedDoubleClick: () => void;
20
+ onBlockedMove: (selection: DomEditSelection) => void;
21
+ onMoveCommit: (
22
+ selection: DomEditSelection,
23
+ next: { left: number; top: number },
24
+ ) => Promise<void> | void;
25
+ onResizeCommit: (
26
+ selection: DomEditSelection,
27
+ next: { width: number; height: number },
28
+ ) => Promise<void> | void;
29
+ }
30
+
31
+ function toOverlayRect(
32
+ overlayEl: HTMLDivElement,
33
+ iframe: HTMLIFrameElement,
34
+ element: HTMLElement,
35
+ ): OverlayRect | null {
36
+ const iframeRect = iframe.getBoundingClientRect();
37
+ const overlayRect = overlayEl.getBoundingClientRect();
38
+ const doc = iframe.contentDocument;
39
+ const root =
40
+ doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
41
+ const rootRect = root?.getBoundingClientRect();
42
+ const rootWidth = rootRect?.width;
43
+ const rootHeight = rootRect?.height;
44
+ if (!rootWidth || !rootHeight) return null;
45
+
46
+ const elementRect = element.getBoundingClientRect();
47
+ const scaleX = iframeRect.width / rootWidth;
48
+ const scaleY = iframeRect.height / rootHeight;
49
+
50
+ return {
51
+ left: iframeRect.left - overlayRect.left + elementRect.left * scaleX,
52
+ top: iframeRect.top - overlayRect.top + elementRect.top * scaleY,
53
+ width: elementRect.width * scaleX,
54
+ height: elementRect.height * scaleY,
55
+ scaleX,
56
+ scaleY,
57
+ };
58
+ }
59
+
60
+ type GestureKind = "drag" | "resize";
61
+ const BLOCKED_MOVE_THRESHOLD_PX = 4;
62
+ const OVERLAY_RECT_EPSILON_PX = 0.5;
63
+
64
+ function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
65
+ if (a === b) return true;
66
+ if (!a || !b) return false;
67
+ return (
68
+ Math.abs(a.left - b.left) < OVERLAY_RECT_EPSILON_PX &&
69
+ Math.abs(a.top - b.top) < OVERLAY_RECT_EPSILON_PX &&
70
+ Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
71
+ Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
72
+ Math.abs(a.scaleX - b.scaleX) < 0.001 &&
73
+ Math.abs(a.scaleY - b.scaleY) < 0.001
74
+ );
75
+ }
76
+
77
+ function selectionCacheKey(
78
+ selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
79
+ ): string {
80
+ return [
81
+ selection.sourceFile ?? "",
82
+ selection.id ?? "",
83
+ selection.selector ?? "",
84
+ selection.selectorIndex ?? "",
85
+ ].join("|");
86
+ }
87
+
88
+ interface GestureState {
89
+ kind: GestureKind;
90
+ startX: number;
91
+ startY: number;
92
+ originLeft: number;
93
+ originTop: number;
94
+ originWidth: number;
95
+ originHeight: number;
96
+ actualLeft: number;
97
+ actualTop: number;
98
+ actualWidth: number;
99
+ actualHeight: number;
100
+ scaleX: number;
101
+ scaleY: number;
102
+ }
103
+
104
+ interface BlockedMoveState {
105
+ pointerId: number;
106
+ startX: number;
107
+ startY: number;
108
+ notified: boolean;
109
+ }
110
+
111
+ export const DomEditOverlay = memo(function DomEditOverlay({
112
+ iframeRef,
113
+ selection,
114
+ onCanvasMouseDown,
115
+ onCanvasDoubleClick,
116
+ onSelectedDoubleClick,
117
+ onBlockedMove,
118
+ onMoveCommit,
119
+ onResizeCommit,
120
+ }: DomEditOverlayProps) {
121
+ const overlayRef = useRef<HTMLDivElement | null>(null);
122
+ const boxRef = useRef<HTMLDivElement | null>(null);
123
+ const [overlayRect, setOverlayRect] = useState<OverlayRect | null>(null);
124
+ const gestureRef = useRef<GestureState | null>(null);
125
+ const blockedMoveRef = useRef<BlockedMoveState | null>(null);
126
+ const suppressNextBoxClickRef = useRef(false);
127
+ const rafPausedRef = useRef(false);
128
+ const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
129
+
130
+ const selectionRef = useRef(selection);
131
+ selectionRef.current = selection;
132
+ const overlayRectRef = useRef(overlayRect);
133
+ overlayRectRef.current = overlayRect;
134
+ const onMoveCommitRef = useRef(onMoveCommit);
135
+ onMoveCommitRef.current = onMoveCommit;
136
+ const onResizeCommitRef = useRef(onResizeCommit);
137
+ onResizeCommitRef.current = onResizeCommit;
138
+ const onBlockedMoveRef = useRef(onBlockedMove);
139
+ onBlockedMoveRef.current = onBlockedMove;
140
+
141
+ useMountEffect(() => {
142
+ let frame = 0;
143
+ const clearOverlayRect = () => {
144
+ if (!overlayRectRef.current) return;
145
+ overlayRectRef.current = null;
146
+ setOverlayRect(null);
147
+ };
148
+ const setNextOverlayRect = (next: OverlayRect | null) => {
149
+ if (rectsEqual(overlayRectRef.current, next)) return;
150
+ overlayRectRef.current = next;
151
+ setOverlayRect(next);
152
+ };
153
+ const resolveElement = (doc: Document, sel: DomEditSelection) => {
154
+ const key = selectionCacheKey(sel);
155
+ const cached = resolvedElementRef.current;
156
+ if (
157
+ cached?.key === key &&
158
+ cached.element.isConnected &&
159
+ cached.element.ownerDocument === doc
160
+ ) {
161
+ return cached.element;
162
+ }
163
+
164
+ const next = findElementForSelection(doc, sel, sel.sourceFile);
165
+ resolvedElementRef.current = next ? { key, element: next } : null;
166
+ return next;
167
+ };
168
+
169
+ const update = () => {
170
+ frame = requestAnimationFrame(update);
171
+ if (rafPausedRef.current) return;
172
+
173
+ const sel = selectionRef.current;
174
+ const iframe = iframeRef.current;
175
+ const overlayEl = overlayRef.current;
176
+ if (!sel || !iframe || !overlayEl) {
177
+ resolvedElementRef.current = null;
178
+ clearOverlayRect();
179
+ return;
180
+ }
181
+
182
+ const doc = iframe.contentDocument;
183
+ if (!doc) return;
184
+
185
+ const el = resolveElement(doc, sel);
186
+ if (!el) {
187
+ clearOverlayRect();
188
+ return;
189
+ }
190
+
191
+ const next = toOverlayRect(overlayEl, iframe, el);
192
+ setNextOverlayRect(next);
193
+ };
194
+
195
+ frame = requestAnimationFrame(update);
196
+ return () => cancelAnimationFrame(frame);
197
+ });
198
+
199
+ const selectionKey = useMemo(() => {
200
+ if (!selection) return "none";
201
+ return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${
202
+ selection.selectorIndex ?? 0
203
+ }`;
204
+ }, [selection]);
205
+
206
+ const startGesture = (kind: GestureKind, e: React.PointerEvent) => {
207
+ const sel = selectionRef.current;
208
+ const rect = overlayRectRef.current;
209
+ const box = boxRef.current;
210
+ if (!sel || !rect || !box) return;
211
+
212
+ const left = Number.parseFloat(sel.computedStyles.left ?? "");
213
+ const top = Number.parseFloat(sel.computedStyles.top ?? "");
214
+ const width = Number.parseFloat(sel.computedStyles.width ?? "");
215
+ const height = Number.parseFloat(sel.computedStyles.height ?? "");
216
+ if (!Number.isFinite(left) || !Number.isFinite(top)) return;
217
+ if (kind === "resize" && !Number.isFinite(width) && !Number.isFinite(height)) return;
218
+
219
+ e.preventDefault();
220
+ e.stopPropagation();
221
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
222
+
223
+ rafPausedRef.current = true;
224
+
225
+ gestureRef.current = {
226
+ kind,
227
+ startX: e.clientX,
228
+ startY: e.clientY,
229
+ originLeft: rect.left,
230
+ originTop: rect.top,
231
+ originWidth: rect.width,
232
+ originHeight: rect.height,
233
+ actualLeft: left,
234
+ actualTop: top,
235
+ actualWidth: Number.isFinite(width) ? width : 0,
236
+ actualHeight: Number.isFinite(height) ? height : 0,
237
+ scaleX: rect.scaleX,
238
+ scaleY: rect.scaleY,
239
+ };
240
+ };
241
+
242
+ const onPointerMove = (e: React.PointerEvent) => {
243
+ const g = gestureRef.current;
244
+ const sel = selectionRef.current;
245
+ const box = boxRef.current;
246
+ const blockedMove = blockedMoveRef.current;
247
+ if (blockedMove && sel) {
248
+ const dx = e.clientX - blockedMove.startX;
249
+ const dy = e.clientY - blockedMove.startY;
250
+ if (!blockedMove.notified && Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
251
+ blockedMove.notified = true;
252
+ suppressNextBoxClickRef.current = true;
253
+ onBlockedMoveRef.current(sel);
254
+ }
255
+ return;
256
+ }
257
+
258
+ if (!g || !sel || !box) return;
259
+
260
+ const dx = e.clientX - g.startX;
261
+ const dy = e.clientY - g.startY;
262
+
263
+ if (g.kind === "drag") {
264
+ const nextBoxLeft = g.originLeft + dx;
265
+ const nextBoxTop = g.originTop + dy;
266
+ box.style.left = `${nextBoxLeft}px`;
267
+ box.style.top = `${nextBoxTop}px`;
268
+ sel.element.style.left = `${Math.round(g.actualLeft + dx / g.scaleX)}px`;
269
+ sel.element.style.top = `${Math.round(g.actualTop + dy / g.scaleY)}px`;
270
+ } else {
271
+ const newW = Math.max(20, g.originWidth + dx);
272
+ const newH = Math.max(20, g.originHeight + dy);
273
+ box.style.width = `${newW}px`;
274
+ box.style.height = `${newH}px`;
275
+ sel.element.style.width = `${Math.round(g.actualWidth + dx / g.scaleX)}px`;
276
+ sel.element.style.height = `${Math.round(g.actualHeight + dy / g.scaleY)}px`;
277
+ }
278
+ };
279
+
280
+ const onPointerUp = () => {
281
+ const g = gestureRef.current;
282
+ const sel = selectionRef.current;
283
+ blockedMoveRef.current = null;
284
+ if (!g || !sel) {
285
+ gestureRef.current = null;
286
+ rafPausedRef.current = false;
287
+ return;
288
+ }
289
+
290
+ gestureRef.current = null;
291
+ rafPausedRef.current = false;
292
+
293
+ if (g.kind === "drag") {
294
+ const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
295
+ const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
296
+ void Promise.resolve(onMoveCommitRef.current(sel, { left: finalLeft, top: finalTop })).catch(
297
+ () => {
298
+ sel.element.style.left = `${Math.round(g.actualLeft)}px`;
299
+ sel.element.style.top = `${Math.round(g.actualTop)}px`;
300
+ },
301
+ );
302
+ } else {
303
+ const finalW = Number.parseFloat(sel.element.style.width) || g.actualWidth;
304
+ const finalH = Number.parseFloat(sel.element.style.height) || g.actualHeight;
305
+ void Promise.resolve(onResizeCommitRef.current(sel, { width: finalW, height: finalH })).catch(
306
+ () => {
307
+ if (g.actualWidth > 0) sel.element.style.width = `${Math.round(g.actualWidth)}px`;
308
+ else sel.element.style.removeProperty("width");
309
+ if (g.actualHeight > 0) sel.element.style.height = `${Math.round(g.actualHeight)}px`;
310
+ else sel.element.style.removeProperty("height");
311
+ },
312
+ );
313
+ }
314
+ };
315
+
316
+ // Click on overlay background → select whatever is under the pointer in the iframe.
317
+ // This handles clicking children inside an already-selected parent: the selection
318
+ // box stops propagation for drag gestures, but clicks on the transparent overlay
319
+ // area outside the box pass through to the iframe pick logic.
320
+ const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
321
+ const target = event.target as HTMLElement | null;
322
+ if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
323
+ onCanvasMouseDown(event);
324
+ };
325
+
326
+ const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
327
+ const target = event.target as HTMLElement | null;
328
+ if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
329
+ onCanvasDoubleClick(event);
330
+ };
331
+
332
+ // Click on the selection box itself → re-pick the element under the pointer.
333
+ // This lets you click a child element even when a parent is selected, because
334
+ // the click coordinates are forwarded to the iframe's element picker.
335
+ const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
336
+ if (gestureRef.current) return;
337
+ if (suppressNextBoxClickRef.current) {
338
+ suppressNextBoxClickRef.current = false;
339
+ event.stopPropagation();
340
+ return;
341
+ }
342
+ onCanvasMouseDown(event);
343
+ };
344
+
345
+ const clearPointerState = () => {
346
+ blockedMoveRef.current = null;
347
+ gestureRef.current = null;
348
+ rafPausedRef.current = false;
349
+ };
350
+
351
+ return (
352
+ <div
353
+ key={selectionKey}
354
+ ref={overlayRef}
355
+ className="absolute inset-0 z-10 pointer-events-auto"
356
+ onMouseDown={handleOverlayMouseDown}
357
+ onDoubleClick={handleOverlayDoubleClick}
358
+ onPointerMove={onPointerMove}
359
+ onPointerUp={onPointerUp}
360
+ onPointerCancel={clearPointerState}
361
+ >
362
+ {selection && overlayRect && (
363
+ <>
364
+ <div
365
+ key={selectionKey}
366
+ ref={boxRef}
367
+ data-dom-edit-selection-box="true"
368
+ className="pointer-events-auto absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
369
+ style={{
370
+ left: overlayRect.left,
371
+ top: overlayRect.top,
372
+ width: overlayRect.width,
373
+ height: overlayRect.height,
374
+ cursor: selection.capabilities.canMove ? "move" : "default",
375
+ }}
376
+ onPointerDown={(e) => {
377
+ if (selection.capabilities.canMove) {
378
+ startGesture("drag", e);
379
+ return;
380
+ }
381
+ e.preventDefault();
382
+ e.stopPropagation();
383
+ e.currentTarget.setPointerCapture(e.pointerId);
384
+ blockedMoveRef.current = {
385
+ pointerId: e.pointerId,
386
+ startX: e.clientX,
387
+ startY: e.clientY,
388
+ notified: false,
389
+ };
390
+ }}
391
+ onClick={handleBoxClick}
392
+ onDoubleClick={onSelectedDoubleClick}
393
+ >
394
+ {/* Resize handle — bottom-right corner */}
395
+ {selection.capabilities.canResize && (
396
+ <div
397
+ className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
398
+ style={{ cursor: "se-resize", touchAction: "none" }}
399
+ onPointerDown={(e) => {
400
+ e.stopPropagation();
401
+ startGesture("resize", e);
402
+ }}
403
+ />
404
+ )}
405
+ </div>
406
+ </>
407
+ )}
408
+ </div>
409
+ );
410
+ });