@hyperframes/studio 0.5.4 → 0.6.0-alpha.1

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 (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-D04_ZoMm.js +107 -0
  3. package/dist/assets/index-UWFaHilT.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +120 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -0,0 +1,1300 @@
1
+ import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { type DomEditSelection, findElementForSelection } from "./domEditing";
4
+ import {
5
+ applyManualOffsetDragCommit,
6
+ applyManualOffsetDragDraft,
7
+ createManualOffsetDragMember,
8
+ endManualOffsetDragMembers,
9
+ restoreManualOffsetDragMembers,
10
+ type ManualOffsetDragMember,
11
+ } from "./manualOffsetDrag";
12
+ import {
13
+ applyStudioBoxSize,
14
+ applyStudioBoxSizeDraft,
15
+ applyStudioRotation,
16
+ applyStudioRotationDraft,
17
+ beginStudioManualEditGesture,
18
+ captureStudioBoxSize,
19
+ captureStudioPathOffset,
20
+ captureStudioRotation,
21
+ endStudioManualEditGesture,
22
+ isStudioManualEditGestureCurrent,
23
+ readStudioBoxSize,
24
+ readStudioRotation,
25
+ restoreStudioBoxSize,
26
+ restoreStudioPathOffset,
27
+ restoreStudioRotation,
28
+ type StudioBoxSizeSnapshot,
29
+ type StudioPathOffsetSnapshot,
30
+ type StudioRotationSnapshot,
31
+ } from "./manualEdits";
32
+
33
+ interface OverlayRect {
34
+ left: number;
35
+ top: number;
36
+ width: number;
37
+ height: number;
38
+ editScaleX: number;
39
+ editScaleY: number;
40
+ }
41
+
42
+ interface GroupOverlayItem {
43
+ key: string;
44
+ selection: DomEditSelection;
45
+ element: HTMLElement;
46
+ rect: OverlayRect;
47
+ }
48
+
49
+ export interface DomEditGroupPathOffsetCommit {
50
+ selection: DomEditSelection;
51
+ next: { x: number; y: number };
52
+ }
53
+
54
+ interface DomEditOverlayProps {
55
+ iframeRef: RefObject<HTMLIFrameElement | null>;
56
+ activeCompositionPath: string | null;
57
+ selection: DomEditSelection | null;
58
+ groupSelections?: DomEditSelection[];
59
+ hoverSelection: DomEditSelection | null;
60
+ allowCanvasMovement?: boolean;
61
+ onCanvasMouseDown: (
62
+ event: React.MouseEvent<HTMLDivElement>,
63
+ options?: { preferClipAncestor?: boolean },
64
+ ) => void;
65
+ onCanvasPointerMove: (
66
+ event: React.PointerEvent<HTMLDivElement>,
67
+ options?: { preferClipAncestor?: boolean },
68
+ ) => DomEditSelection | null;
69
+ onCanvasPointerLeave: () => void;
70
+ onSelectionChange: (
71
+ selection: DomEditSelection,
72
+ options?: { revealPanel?: boolean; additive?: boolean },
73
+ ) => void;
74
+ onBlockedMove: (selection: DomEditSelection) => void;
75
+ onManualDragStart?: () => void;
76
+ onPathOffsetCommit: (
77
+ selection: DomEditSelection,
78
+ next: { x: number; y: number },
79
+ ) => Promise<void> | void;
80
+ onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void;
81
+ onBoxSizeCommit: (
82
+ selection: DomEditSelection,
83
+ next: { width: number; height: number },
84
+ ) => Promise<void> | void;
85
+ onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
86
+ }
87
+
88
+ function toOverlayRect(
89
+ overlayEl: HTMLDivElement,
90
+ iframe: HTMLIFrameElement,
91
+ element: HTMLElement,
92
+ ): OverlayRect | null {
93
+ const iframeRect = iframe.getBoundingClientRect();
94
+ const overlayRect = overlayEl.getBoundingClientRect();
95
+ const doc = iframe.contentDocument;
96
+ const root =
97
+ doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
98
+ const rootRect = root?.getBoundingClientRect();
99
+ const rootWidth = rootRect?.width;
100
+ const rootHeight = rootRect?.height;
101
+ if (!rootWidth || !rootHeight) return null;
102
+
103
+ const elementRect = element.getBoundingClientRect();
104
+ const rootScaleX = iframeRect.width / rootWidth;
105
+ const rootScaleY = iframeRect.height / rootHeight;
106
+ const sourceBoundary = findSourceBoundary(element);
107
+ const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect();
108
+ const editScale = resolveDomEditCoordinateScale({
109
+ rootScaleX,
110
+ rootScaleY,
111
+ sourceRectWidth: sourceBoundaryRect?.width,
112
+ sourceRectHeight: sourceBoundaryRect?.height,
113
+ sourceWidth: readPositiveDimension(sourceBoundary?.getAttribute("data-width") ?? null),
114
+ sourceHeight: readPositiveDimension(sourceBoundary?.getAttribute("data-height") ?? null),
115
+ });
116
+
117
+ return {
118
+ left: iframeRect.left - overlayRect.left + (elementRect.left - rootRect.left) * rootScaleX,
119
+ top: iframeRect.top - overlayRect.top + (elementRect.top - rootRect.top) * rootScaleY,
120
+ width: elementRect.width * rootScaleX,
121
+ height: elementRect.height * rootScaleY,
122
+ editScaleX: editScale.scaleX,
123
+ editScaleY: editScale.scaleY,
124
+ };
125
+ }
126
+
127
+ function readPositiveDimension(value: string | null): number | null {
128
+ if (!value) return null;
129
+ const parsed = Number.parseFloat(value);
130
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
131
+ }
132
+
133
+ function findSourceBoundary(element: HTMLElement): HTMLElement | null {
134
+ let current: HTMLElement | null = element;
135
+ while (current) {
136
+ if (
137
+ current.hasAttribute("data-composition-file") ||
138
+ current.hasAttribute("data-composition-src")
139
+ ) {
140
+ return current;
141
+ }
142
+ current = current.parentElement;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ export function resolveDomEditCoordinateScale(input: {
148
+ rootScaleX: number;
149
+ rootScaleY: number;
150
+ sourceRectWidth?: number;
151
+ sourceRectHeight?: number;
152
+ sourceWidth?: number | null;
153
+ sourceHeight?: number | null;
154
+ }): { scaleX: number; scaleY: number } {
155
+ const rootScaleX = input.rootScaleX > 0 ? input.rootScaleX : 1;
156
+ const rootScaleY = input.rootScaleY > 0 ? input.rootScaleY : 1;
157
+ const sourceScaleX =
158
+ input.sourceRectWidth && input.sourceRectWidth > 0 && input.sourceWidth && input.sourceWidth > 0
159
+ ? (input.sourceRectWidth * rootScaleX) / input.sourceWidth
160
+ : rootScaleX;
161
+ const sourceScaleY =
162
+ input.sourceRectHeight &&
163
+ input.sourceRectHeight > 0 &&
164
+ input.sourceHeight &&
165
+ input.sourceHeight > 0
166
+ ? (input.sourceRectHeight * rootScaleY) / input.sourceHeight
167
+ : rootScaleY;
168
+ return {
169
+ scaleX: sourceScaleX > 0 ? sourceScaleX : rootScaleX,
170
+ scaleY: sourceScaleY > 0 ? sourceScaleY : rootScaleY,
171
+ };
172
+ }
173
+
174
+ type GestureKind = "drag" | "resize" | "rotate";
175
+ const BLOCKED_MOVE_THRESHOLD_PX = 4;
176
+ const MIN_RESIZE_EDGE_PX = 20;
177
+ const OVERLAY_RECT_EPSILON_PX = 0.5;
178
+ const ROTATION_COMMIT_EPSILON_DEGREES = 0.05;
179
+ const ROTATION_SNAP_DEGREES = 15;
180
+
181
+ function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
182
+ if (a === b) return true;
183
+ if (!a || !b) return false;
184
+ return (
185
+ Math.abs(a.left - b.left) < OVERLAY_RECT_EPSILON_PX &&
186
+ Math.abs(a.top - b.top) < OVERLAY_RECT_EPSILON_PX &&
187
+ Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
188
+ Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
189
+ Math.abs(a.editScaleX - b.editScaleX) < 0.001 &&
190
+ Math.abs(a.editScaleY - b.editScaleY) < 0.001
191
+ );
192
+ }
193
+
194
+ function groupOverlayItemsEqual(a: GroupOverlayItem[], b: GroupOverlayItem[]): boolean {
195
+ if (a === b) return true;
196
+ if (a.length !== b.length) return false;
197
+ return a.every((item, index) => {
198
+ const other = b[index];
199
+ return Boolean(
200
+ other &&
201
+ item.key === other.key &&
202
+ item.element === other.element &&
203
+ item.selection === other.selection &&
204
+ rectsEqual(item.rect, other.rect),
205
+ );
206
+ });
207
+ }
208
+
209
+ export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRect | null {
210
+ const first = rects[0];
211
+ if (!first) return null;
212
+
213
+ let left = first.left;
214
+ let top = first.top;
215
+ let right = first.left + first.width;
216
+ let bottom = first.top + first.height;
217
+
218
+ for (const rect of rects.slice(1)) {
219
+ left = Math.min(left, rect.left);
220
+ top = Math.min(top, rect.top);
221
+ right = Math.max(right, rect.left + rect.width);
222
+ bottom = Math.max(bottom, rect.top + rect.height);
223
+ }
224
+
225
+ return {
226
+ left,
227
+ top,
228
+ width: right - left,
229
+ height: bottom - top,
230
+ editScaleX: 1,
231
+ editScaleY: 1,
232
+ };
233
+ }
234
+
235
+ export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
236
+ return items.filter(
237
+ (item) => !items.some((other) => other !== item && other.element.contains(item.element)),
238
+ );
239
+ }
240
+
241
+ function selectionCacheKey(
242
+ selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
243
+ ): string {
244
+ return [
245
+ selection.sourceFile ?? "",
246
+ selection.id ?? "",
247
+ selection.selector ?? "",
248
+ selection.selectorIndex ?? "",
249
+ ].join("|");
250
+ }
251
+
252
+ type FocusableDomEditOverlay = {
253
+ focus(options?: FocusOptions): void;
254
+ };
255
+
256
+ export function focusDomEditOverlayElement(element: FocusableDomEditOverlay | null): void {
257
+ element?.focus({ preventScroll: true });
258
+ }
259
+
260
+ export function resolveDomEditResizeGesture(input: {
261
+ originWidth: number;
262
+ originHeight: number;
263
+ actualWidth: number;
264
+ actualHeight: number;
265
+ scaleX: number;
266
+ scaleY: number;
267
+ dx: number;
268
+ dy: number;
269
+ uniform: boolean;
270
+ }): { overlayWidth: number; overlayHeight: number; width: number; height: number } {
271
+ const scaleX = input.scaleX > 0 ? input.scaleX : 1;
272
+ const scaleY = input.scaleY > 0 ? input.scaleY : 1;
273
+
274
+ if (input.uniform) {
275
+ const deltaX = input.dx / scaleX;
276
+ const deltaY = input.dy / scaleY;
277
+ const delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY;
278
+ const side = Math.max(1, Math.max(input.actualWidth, input.actualHeight) + delta);
279
+ return {
280
+ overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX),
281
+ overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY),
282
+ width: side,
283
+ height: side,
284
+ };
285
+ }
286
+
287
+ return {
288
+ overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, input.originWidth + input.dx),
289
+ overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, input.originHeight + input.dy),
290
+ width: Math.max(1, input.actualWidth + input.dx / scaleX),
291
+ height: Math.max(1, input.actualHeight + input.dy / scaleY),
292
+ };
293
+ }
294
+
295
+ function pointerAngleDegrees(centerX: number, centerY: number, x: number, y: number): number {
296
+ return (Math.atan2(y - centerY, x - centerX) * 180) / Math.PI;
297
+ }
298
+
299
+ function normalizeAngleDelta(delta: number): number {
300
+ return ((((delta + 180) % 360) + 360) % 360) - 180;
301
+ }
302
+
303
+ function roundAngle(angle: number): number {
304
+ return Math.round(angle * 10) / 10;
305
+ }
306
+
307
+ export function resolveDomEditRotationGesture(input: {
308
+ centerX: number;
309
+ centerY: number;
310
+ startX: number;
311
+ startY: number;
312
+ currentX: number;
313
+ currentY: number;
314
+ actualAngle: number;
315
+ snap: boolean;
316
+ }): { angle: number } {
317
+ const startAngle = pointerAngleDegrees(input.centerX, input.centerY, input.startX, input.startY);
318
+ const currentAngle = pointerAngleDegrees(
319
+ input.centerX,
320
+ input.centerY,
321
+ input.currentX,
322
+ input.currentY,
323
+ );
324
+ const delta = normalizeAngleDelta(currentAngle - startAngle);
325
+ const angle = input.actualAngle + delta;
326
+ return {
327
+ angle: input.snap
328
+ ? Math.round(angle / ROTATION_SNAP_DEGREES) * ROTATION_SNAP_DEGREES
329
+ : roundAngle(angle),
330
+ };
331
+ }
332
+
333
+ export function hasDomEditRotationChanged(initialAngle: number, nextAngle: number): boolean {
334
+ return Math.abs(nextAngle - initialAngle) >= ROTATION_COMMIT_EPSILON_DEGREES;
335
+ }
336
+
337
+ interface GestureState {
338
+ kind: GestureKind;
339
+ mode: "path-offset" | "box-size" | "rotation";
340
+ selection: DomEditSelection;
341
+ startX: number;
342
+ startY: number;
343
+ centerX: number;
344
+ centerY: number;
345
+ initialPathOffset: StudioPathOffsetSnapshot;
346
+ initialRotation: StudioRotationSnapshot;
347
+ initialBoxSize: StudioBoxSizeSnapshot;
348
+ pathOffsetMember?: ManualOffsetDragMember;
349
+ originLeft: number;
350
+ originTop: number;
351
+ originWidth: number;
352
+ originHeight: number;
353
+ actualWidth: number;
354
+ actualHeight: number;
355
+ actualRotation: number;
356
+ editScaleX: number;
357
+ editScaleY: number;
358
+ manualEditDragToken?: string;
359
+ }
360
+
361
+ interface GroupGestureState {
362
+ startX: number;
363
+ startY: number;
364
+ originItems: GroupOverlayItem[];
365
+ members: ManualOffsetDragMember[];
366
+ }
367
+
368
+ interface BlockedMoveState {
369
+ pointerId: number;
370
+ startX: number;
371
+ startY: number;
372
+ notified: boolean;
373
+ }
374
+
375
+ type ResolvedElementRef = {
376
+ current: { key: string; element: HTMLElement } | null;
377
+ };
378
+
379
+ export const DomEditOverlay = memo(function DomEditOverlay({
380
+ iframeRef,
381
+ activeCompositionPath,
382
+ selection,
383
+ groupSelections = [],
384
+ hoverSelection,
385
+ allowCanvasMovement = true,
386
+ onCanvasMouseDown,
387
+ onCanvasPointerMove,
388
+ onCanvasPointerLeave,
389
+ onSelectionChange,
390
+ onBlockedMove,
391
+ onManualDragStart,
392
+ onPathOffsetCommit,
393
+ onGroupPathOffsetCommit,
394
+ onBoxSizeCommit,
395
+ onRotationCommit,
396
+ }: DomEditOverlayProps) {
397
+ const overlayRef = useRef<HTMLDivElement | null>(null);
398
+ const boxRef = useRef<HTMLDivElement | null>(null);
399
+ const [overlayRect, setOverlayRect] = useState<OverlayRect | null>(null);
400
+ const [hoverRect, setHoverRect] = useState<OverlayRect | null>(null);
401
+ const [groupOverlayItems, setGroupOverlayItems] = useState<GroupOverlayItem[]>([]);
402
+ const gestureRef = useRef<GestureState | null>(null);
403
+ const groupGestureRef = useRef<GroupGestureState | null>(null);
404
+ const blockedMoveRef = useRef<BlockedMoveState | null>(null);
405
+ const suppressNextBoxClickRef = useRef(false);
406
+ const suppressNextBoxMouseDownRef = useRef(false);
407
+ const suppressNextOverlayMouseDownRef = useRef(false);
408
+ const rafPausedRef = useRef(false);
409
+ const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
410
+ const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
411
+ const resolvedGroupElementRef = useRef<Map<string, HTMLElement>>(new Map());
412
+
413
+ const selectionRef = useRef(selection);
414
+ selectionRef.current = selection;
415
+ const activeCompositionPathRef = useRef(activeCompositionPath);
416
+ activeCompositionPathRef.current = activeCompositionPath;
417
+ const groupSelectionsRef = useRef(groupSelections);
418
+ groupSelectionsRef.current = groupSelections;
419
+ const hoverSelectionRef = useRef(hoverSelection);
420
+ hoverSelectionRef.current = hoverSelection;
421
+ const overlayRectRef = useRef(overlayRect);
422
+ overlayRectRef.current = overlayRect;
423
+ const hoverRectRef = useRef(hoverRect);
424
+ hoverRectRef.current = hoverRect;
425
+ const groupOverlayItemsRef = useRef(groupOverlayItems);
426
+ groupOverlayItemsRef.current = groupOverlayItems;
427
+ const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
428
+ onPathOffsetCommitRef.current = onPathOffsetCommit;
429
+ const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
430
+ onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit;
431
+ const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
432
+ onBoxSizeCommitRef.current = onBoxSizeCommit;
433
+ const onRotationCommitRef = useRef(onRotationCommit);
434
+ onRotationCommitRef.current = onRotationCommit;
435
+ const onBlockedMoveRef = useRef(onBlockedMove);
436
+ onBlockedMoveRef.current = onBlockedMove;
437
+ const onManualDragStartRef = useRef(onManualDragStart);
438
+ onManualDragStartRef.current = onManualDragStart;
439
+ const onCanvasPointerMoveRef = useRef(onCanvasPointerMove);
440
+ onCanvasPointerMoveRef.current = onCanvasPointerMove;
441
+ const onCanvasPointerLeaveRef = useRef(onCanvasPointerLeave);
442
+ onCanvasPointerLeaveRef.current = onCanvasPointerLeave;
443
+ const onSelectionChangeRef = useRef(onSelectionChange);
444
+ onSelectionChangeRef.current = onSelectionChange;
445
+
446
+ useMountEffect(() => {
447
+ let frame = 0;
448
+ const clearOverlayRect = () => {
449
+ if (!overlayRectRef.current) return;
450
+ overlayRectRef.current = null;
451
+ setOverlayRect(null);
452
+ };
453
+ const setNextOverlayRect = (next: OverlayRect | null) => {
454
+ if (rectsEqual(overlayRectRef.current, next)) return;
455
+ overlayRectRef.current = next;
456
+ setOverlayRect(next);
457
+ };
458
+ const clearHoverRect = () => {
459
+ if (!hoverRectRef.current) return;
460
+ hoverRectRef.current = null;
461
+ setHoverRect(null);
462
+ };
463
+ const setNextHoverRect = (next: OverlayRect | null) => {
464
+ if (rectsEqual(hoverRectRef.current, next)) return;
465
+ hoverRectRef.current = next;
466
+ setHoverRect(next);
467
+ };
468
+ const clearGroupOverlayItems = () => {
469
+ if (groupOverlayItemsRef.current.length === 0) return;
470
+ groupOverlayItemsRef.current = [];
471
+ setGroupOverlayItems([]);
472
+ };
473
+ const setNextGroupOverlayItems = (next: GroupOverlayItem[]) => {
474
+ if (groupOverlayItemsEqual(groupOverlayItemsRef.current, next)) return;
475
+ groupOverlayItemsRef.current = next;
476
+ setGroupOverlayItems(next);
477
+ };
478
+ const resolveElement = (doc: Document, sel: DomEditSelection, cacheRef: ResolvedElementRef) => {
479
+ const key = selectionCacheKey(sel);
480
+ const cached = cacheRef.current;
481
+ if (
482
+ cached?.key === key &&
483
+ cached.element.isConnected &&
484
+ cached.element.ownerDocument === doc
485
+ ) {
486
+ return cached.element;
487
+ }
488
+
489
+ const next = findElementForSelection(doc, sel, activeCompositionPathRef.current);
490
+ cacheRef.current = next ? { key, element: next } : null;
491
+ return next;
492
+ };
493
+ const resolveGroupElement = (doc: Document, sel: DomEditSelection) => {
494
+ const key = selectionCacheKey(sel);
495
+ const cached = resolvedGroupElementRef.current.get(key);
496
+ if (cached?.isConnected && cached.ownerDocument === doc) return cached;
497
+
498
+ const next = findElementForSelection(doc, sel, activeCompositionPathRef.current);
499
+ if (next) {
500
+ resolvedGroupElementRef.current.set(key, next);
501
+ } else {
502
+ resolvedGroupElementRef.current.delete(key);
503
+ }
504
+ return next;
505
+ };
506
+
507
+ const update = () => {
508
+ frame = requestAnimationFrame(update);
509
+ if (rafPausedRef.current) return;
510
+
511
+ const sel = selectionRef.current;
512
+ const iframe = iframeRef.current;
513
+ const overlayEl = overlayRef.current;
514
+ if (!iframe || !overlayEl) {
515
+ resolvedElementRef.current = null;
516
+ resolvedHoverElementRef.current = null;
517
+ resolvedGroupElementRef.current.clear();
518
+ clearOverlayRect();
519
+ clearHoverRect();
520
+ clearGroupOverlayItems();
521
+ return;
522
+ }
523
+
524
+ const doc = iframe.contentDocument;
525
+ if (!doc) {
526
+ resolvedElementRef.current = null;
527
+ resolvedHoverElementRef.current = null;
528
+ resolvedGroupElementRef.current.clear();
529
+ clearOverlayRect();
530
+ clearHoverRect();
531
+ clearGroupOverlayItems();
532
+ return;
533
+ }
534
+
535
+ if (sel) {
536
+ const el = resolveElement(doc, sel, resolvedElementRef);
537
+ if (el) {
538
+ setNextOverlayRect(toOverlayRect(overlayEl, iframe, el));
539
+ } else {
540
+ clearOverlayRect();
541
+ }
542
+ } else {
543
+ resolvedElementRef.current = null;
544
+ clearOverlayRect();
545
+ }
546
+
547
+ const group = groupSelectionsRef.current;
548
+ if (group.length > 0) {
549
+ const nextGroupItems: GroupOverlayItem[] = [];
550
+ const liveGroupKeys = new Set<string>();
551
+ for (const groupSelection of group) {
552
+ const key = selectionCacheKey(groupSelection);
553
+ liveGroupKeys.add(key);
554
+ const el = resolveGroupElement(doc, groupSelection);
555
+ const rect = el ? toOverlayRect(overlayEl, iframe, el) : null;
556
+ if (el && rect)
557
+ nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
558
+ }
559
+ for (const key of resolvedGroupElementRef.current.keys()) {
560
+ if (!liveGroupKeys.has(key)) resolvedGroupElementRef.current.delete(key);
561
+ }
562
+ setNextGroupOverlayItems(nextGroupItems);
563
+ } else {
564
+ resolvedGroupElementRef.current.clear();
565
+ clearGroupOverlayItems();
566
+ }
567
+
568
+ const hoverSel = hoverSelectionRef.current;
569
+ const hoverMatchesSelection = Boolean(
570
+ sel && hoverSel && selectionCacheKey(sel) === selectionCacheKey(hoverSel),
571
+ );
572
+ const hoverMatchesGroup = Boolean(
573
+ hoverSel && group.some((entry) => selectionCacheKey(entry) === selectionCacheKey(hoverSel)),
574
+ );
575
+ if (!hoverSel || hoverMatchesSelection || hoverMatchesGroup) {
576
+ resolvedHoverElementRef.current = null;
577
+ clearHoverRect();
578
+ return;
579
+ }
580
+
581
+ const hoverEl = resolveElement(doc, hoverSel, resolvedHoverElementRef);
582
+ if (!hoverEl) {
583
+ clearHoverRect();
584
+ return;
585
+ }
586
+
587
+ setNextHoverRect(toOverlayRect(overlayEl, iframe, hoverEl));
588
+ };
589
+
590
+ frame = requestAnimationFrame(update);
591
+ return () => cancelAnimationFrame(frame);
592
+ });
593
+
594
+ const selectionKey = useMemo(() => {
595
+ if (!selection) return "none";
596
+ return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${
597
+ selection.selectorIndex ?? 0
598
+ }`;
599
+ }, [selection]);
600
+ const groupBounds = useMemo(
601
+ () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
602
+ [groupOverlayItems],
603
+ );
604
+ const hasGroupSelection = groupSelections.length > 1;
605
+ const groupCanMove =
606
+ hasGroupSelection &&
607
+ groupOverlayItems.length > 1 &&
608
+ groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
609
+
610
+ const setDraftOverlayRect = (next: OverlayRect) => {
611
+ if (rectsEqual(overlayRectRef.current, next)) return;
612
+ overlayRectRef.current = next;
613
+ setOverlayRect(next);
614
+ };
615
+
616
+ const restoreGestureOverlayRect = (g: GestureState) => {
617
+ setDraftOverlayRect({
618
+ left: g.originLeft,
619
+ top: g.originTop,
620
+ width: g.originWidth,
621
+ height: g.originHeight,
622
+ editScaleX: g.editScaleX,
623
+ editScaleY: g.editScaleY,
624
+ });
625
+ };
626
+
627
+ const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
628
+ if (groupOverlayItemsEqual(groupOverlayItemsRef.current, next)) return;
629
+ groupOverlayItemsRef.current = next;
630
+ setGroupOverlayItems(next);
631
+ };
632
+
633
+ const restoreGroupGestureOverlayItems = (g: GroupGestureState) => {
634
+ setDraftGroupOverlayItems(g.originItems);
635
+ };
636
+
637
+ const startGroupDrag = (e: React.PointerEvent<HTMLElement>) => {
638
+ const items = groupOverlayItemsRef.current;
639
+ if (items.length <= 1) return false;
640
+
641
+ const blockedSelection = items.find(
642
+ (item) => !item.selection.capabilities.canApplyManualOffset,
643
+ )?.selection;
644
+ if (blockedSelection) {
645
+ e.preventDefault();
646
+ e.stopPropagation();
647
+ onBlockedMoveRef.current(blockedSelection);
648
+ return false;
649
+ }
650
+
651
+ onManualDragStartRef.current?.();
652
+
653
+ const dragItems = filterNestedDomEditGroupItems(items);
654
+
655
+ const members: ManualOffsetDragMember[] = [];
656
+ for (const item of dragItems) {
657
+ const result = createManualOffsetDragMember({
658
+ key: item.key,
659
+ selection: item.selection,
660
+ element: item.element,
661
+ rect: item.rect,
662
+ });
663
+ if (!result.ok) {
664
+ restoreManualOffsetDragMembers(members);
665
+ e.preventDefault();
666
+ e.stopPropagation();
667
+ onBlockedMoveRef.current(result.selection);
668
+ return false;
669
+ }
670
+ members.push(result.member);
671
+ }
672
+
673
+ e.preventDefault();
674
+ e.stopPropagation();
675
+ e.currentTarget.setPointerCapture(e.pointerId);
676
+
677
+ rafPausedRef.current = true;
678
+ groupGestureRef.current = {
679
+ startX: e.clientX,
680
+ startY: e.clientY,
681
+ originItems: items,
682
+ members,
683
+ };
684
+ return true;
685
+ };
686
+
687
+ const restoreGroupPathOffsets = (g: GroupGestureState) => {
688
+ restoreManualOffsetDragMembers(g.members);
689
+ restoreGroupGestureOverlayItems(g);
690
+ };
691
+
692
+ const startGesture = (
693
+ kind: GestureKind,
694
+ e: React.PointerEvent<HTMLElement>,
695
+ options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
696
+ ) => {
697
+ const sel = options?.selection ?? selectionRef.current;
698
+ const rect = options?.rect ?? overlayRectRef.current;
699
+ const box = boxRef.current;
700
+ const overlayEl = overlayRef.current;
701
+ if (!sel || !rect) return false;
702
+ if (kind !== "drag" && !box) return false;
703
+ const mode: GestureState["mode"] =
704
+ kind === "rotate" ? "rotation" : kind === "drag" ? "path-offset" : "box-size";
705
+ if (kind === "drag" && !sel.capabilities.canApplyManualOffset) return false;
706
+ if (kind === "resize" && !sel.capabilities.canApplyManualSize) return false;
707
+ if (kind === "rotate" && !sel.capabilities.canApplyManualRotation) return false;
708
+ if (kind === "resize" && (!Number.isFinite(rect.width) || !Number.isFinite(rect.height))) {
709
+ return false;
710
+ }
711
+ const size = readStudioBoxSize(sel.element);
712
+ const rotation = readStudioRotation(sel.element);
713
+ const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
714
+ const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
715
+ let initialPathOffset = captureStudioPathOffset(sel.element);
716
+ let manualEditDragToken: string | undefined;
717
+ let pathOffsetMember: ManualOffsetDragMember | undefined;
718
+ if (kind === "drag") {
719
+ onManualDragStartRef.current?.();
720
+ const result = createManualOffsetDragMember({
721
+ key: selectionCacheKey(sel),
722
+ selection: sel,
723
+ element: sel.element,
724
+ rect,
725
+ });
726
+ if (!result.ok) {
727
+ onBlockedMoveRef.current(result.selection);
728
+ return false;
729
+ }
730
+ pathOffsetMember = result.member;
731
+ initialPathOffset = result.member.initialPathOffset;
732
+ manualEditDragToken = result.member.gestureToken;
733
+ } else {
734
+ manualEditDragToken = beginStudioManualEditGesture(sel.element);
735
+ }
736
+ const overlayBounds = overlayEl?.getBoundingClientRect();
737
+ const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
738
+ const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
739
+
740
+ e.preventDefault();
741
+ e.stopPropagation();
742
+ e.currentTarget.setPointerCapture(e.pointerId);
743
+
744
+ rafPausedRef.current = true;
745
+
746
+ gestureRef.current = {
747
+ kind,
748
+ mode,
749
+ selection: sel,
750
+ startX: e.clientX,
751
+ startY: e.clientY,
752
+ centerX,
753
+ centerY,
754
+ initialPathOffset,
755
+ initialRotation: captureStudioRotation(sel.element),
756
+ initialBoxSize: captureStudioBoxSize(sel.element),
757
+ pathOffsetMember,
758
+ originLeft: rect.left,
759
+ originTop: rect.top,
760
+ originWidth: rect.width,
761
+ originHeight: rect.height,
762
+ actualWidth,
763
+ actualHeight,
764
+ actualRotation: rotation.angle,
765
+ editScaleX: rect.editScaleX,
766
+ editScaleY: rect.editScaleY,
767
+ manualEditDragToken,
768
+ };
769
+ return true;
770
+ };
771
+
772
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
773
+ const g = gestureRef.current;
774
+ const groupG = groupGestureRef.current;
775
+ const sel = g?.selection ?? selectionRef.current;
776
+ const box = boxRef.current;
777
+ const blockedMove = blockedMoveRef.current;
778
+ if (!blockedMove && !g && !groupG) {
779
+ onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
780
+ }
781
+
782
+ if (blockedMove && sel) {
783
+ const dx = e.clientX - blockedMove.startX;
784
+ const dy = e.clientY - blockedMove.startY;
785
+ if (!blockedMove.notified && Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
786
+ blockedMove.notified = true;
787
+ suppressNextBoxClickRef.current = true;
788
+ onBlockedMoveRef.current(sel);
789
+ }
790
+ return;
791
+ }
792
+
793
+ if (groupG) {
794
+ const dx = e.clientX - groupG.startX;
795
+ const dy = e.clientY - groupG.startY;
796
+ setDraftGroupOverlayItems(
797
+ groupG.originItems.map((item) => ({
798
+ ...item,
799
+ rect: {
800
+ ...item.rect,
801
+ left: item.rect.left + dx,
802
+ top: item.rect.top + dy,
803
+ },
804
+ })),
805
+ );
806
+ for (const member of groupG.members) {
807
+ applyManualOffsetDragDraft(member, dx, dy);
808
+ }
809
+ return;
810
+ }
811
+
812
+ if (!g || !sel) return;
813
+
814
+ const dx = e.clientX - g.startX;
815
+ const dy = e.clientY - g.startY;
816
+
817
+ if (g.kind === "rotate") {
818
+ const nextRotation = resolveDomEditRotationGesture({
819
+ centerX: g.centerX,
820
+ centerY: g.centerY,
821
+ startX: g.startX,
822
+ startY: g.startY,
823
+ currentX: e.clientX,
824
+ currentY: e.clientY,
825
+ actualAngle: g.actualRotation,
826
+ snap: e.shiftKey,
827
+ });
828
+ applyStudioRotationDraft(sel.element, nextRotation);
829
+ return;
830
+ }
831
+
832
+ if (g.kind === "drag") {
833
+ const nextBoxLeft = g.originLeft + dx;
834
+ const nextBoxTop = g.originTop + dy;
835
+ setDraftOverlayRect({
836
+ left: nextBoxLeft,
837
+ top: nextBoxTop,
838
+ width: g.originWidth,
839
+ height: g.originHeight,
840
+ editScaleX: g.editScaleX,
841
+ editScaleY: g.editScaleY,
842
+ });
843
+ if (box) {
844
+ box.style.left = `${nextBoxLeft}px`;
845
+ box.style.top = `${nextBoxTop}px`;
846
+ }
847
+ if (g.pathOffsetMember) applyManualOffsetDragDraft(g.pathOffsetMember, dx, dy);
848
+ } else {
849
+ if (!box) return;
850
+ const nextSize = resolveDomEditResizeGesture({
851
+ originWidth: g.originWidth,
852
+ originHeight: g.originHeight,
853
+ actualWidth: g.actualWidth,
854
+ actualHeight: g.actualHeight,
855
+ scaleX: g.editScaleX,
856
+ scaleY: g.editScaleY,
857
+ dx,
858
+ dy,
859
+ uniform: e.shiftKey,
860
+ });
861
+ setDraftOverlayRect({
862
+ left: g.originLeft,
863
+ top: g.originTop,
864
+ width: nextSize.overlayWidth,
865
+ height: nextSize.overlayHeight,
866
+ editScaleX: g.editScaleX,
867
+ editScaleY: g.editScaleY,
868
+ });
869
+ box.style.width = `${nextSize.overlayWidth}px`;
870
+ box.style.height = `${nextSize.overlayHeight}px`;
871
+ applyStudioBoxSizeDraft(sel.element, nextSize);
872
+ }
873
+ };
874
+
875
+ const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
876
+ const g = gestureRef.current;
877
+ const groupG = groupGestureRef.current;
878
+ const sel = g?.selection ?? selectionRef.current;
879
+ const box = boxRef.current;
880
+ blockedMoveRef.current = null;
881
+
882
+ if (groupG) {
883
+ groupGestureRef.current = null;
884
+ rafPausedRef.current = false;
885
+
886
+ const dx = e.clientX - groupG.startX;
887
+ const dy = e.clientY - groupG.startY;
888
+ const movedDistance = Math.hypot(e.clientX - groupG.startX, e.clientY - groupG.startY);
889
+ if (movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
890
+ restoreGroupPathOffsets(groupG);
891
+ suppressNextBoxClickRef.current = true;
892
+ return;
893
+ }
894
+
895
+ setDraftGroupOverlayItems(
896
+ groupG.originItems.map((item) => ({
897
+ ...item,
898
+ rect: {
899
+ ...item.rect,
900
+ left: item.rect.left + dx,
901
+ top: item.rect.top + dy,
902
+ },
903
+ })),
904
+ );
905
+ const updates = groupG.members.map((member) => {
906
+ const finalOffset = applyManualOffsetDragCommit(member, dx, dy);
907
+ return { selection: member.selection, next: finalOffset };
908
+ });
909
+ void Promise.resolve(onGroupPathOffsetCommitRef.current(updates))
910
+ .catch(() => {
911
+ for (const member of groupG.members) {
912
+ if (
913
+ member.gestureToken &&
914
+ isStudioManualEditGestureCurrent(member.element, member.gestureToken)
915
+ ) {
916
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
917
+ }
918
+ }
919
+ })
920
+ .finally(() => {
921
+ endManualOffsetDragMembers(groupG.members);
922
+ });
923
+ return;
924
+ }
925
+
926
+ if (!g || !sel) {
927
+ gestureRef.current = null;
928
+ rafPausedRef.current = false;
929
+ return;
930
+ }
931
+
932
+ gestureRef.current = null;
933
+ rafPausedRef.current = false;
934
+
935
+ const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
936
+ if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
937
+ restoreStudioPathOffset(sel.element, g.initialPathOffset);
938
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
939
+ if (box) {
940
+ box.style.left = `${g.originLeft}px`;
941
+ box.style.top = `${g.originTop}px`;
942
+ }
943
+ restoreGestureOverlayRect(g);
944
+ suppressNextBoxClickRef.current = true;
945
+ onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
946
+ preferClipAncestor: false,
947
+ });
948
+ return;
949
+ }
950
+
951
+ if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
952
+ restoreStudioBoxSize(sel.element, g.initialBoxSize);
953
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
954
+ if (box) {
955
+ box.style.width = `${g.originWidth}px`;
956
+ box.style.height = `${g.originHeight}px`;
957
+ }
958
+ restoreGestureOverlayRect(g);
959
+ return;
960
+ }
961
+
962
+ if (g.kind === "rotate") {
963
+ const finalRotation = resolveDomEditRotationGesture({
964
+ centerX: g.centerX,
965
+ centerY: g.centerY,
966
+ startX: g.startX,
967
+ startY: g.startY,
968
+ currentX: e.clientX,
969
+ currentY: e.clientY,
970
+ actualAngle: g.actualRotation,
971
+ snap: e.shiftKey,
972
+ });
973
+ if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
974
+ restoreStudioRotation(sel.element, g.initialRotation);
975
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
976
+ return;
977
+ }
978
+ applyStudioRotation(sel.element, finalRotation);
979
+ void Promise.resolve(onRotationCommitRef.current(sel, finalRotation))
980
+ .catch(() => {
981
+ if (
982
+ g.manualEditDragToken &&
983
+ isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
984
+ ) {
985
+ restoreStudioRotation(sel.element, g.initialRotation);
986
+ }
987
+ })
988
+ .finally(() => {
989
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
990
+ });
991
+ } else if (g.kind === "drag") {
992
+ const dx = e.clientX - g.startX;
993
+ const dy = e.clientY - g.startY;
994
+ if (!g.pathOffsetMember) return;
995
+ const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy);
996
+ const nextBoxLeft = g.originLeft + dx;
997
+ const nextBoxTop = g.originTop + dy;
998
+ setDraftOverlayRect({
999
+ left: nextBoxLeft,
1000
+ top: nextBoxTop,
1001
+ width: g.originWidth,
1002
+ height: g.originHeight,
1003
+ editScaleX: g.editScaleX,
1004
+ editScaleY: g.editScaleY,
1005
+ });
1006
+ if (box) {
1007
+ box.style.left = `${nextBoxLeft}px`;
1008
+ box.style.top = `${nextBoxTop}px`;
1009
+ }
1010
+ void Promise.resolve(onPathOffsetCommitRef.current(sel, finalOffset))
1011
+ .catch(() => {
1012
+ if (
1013
+ g.pathOffsetMember?.gestureToken &&
1014
+ isStudioManualEditGestureCurrent(sel.element, g.pathOffsetMember.gestureToken)
1015
+ ) {
1016
+ restoreStudioPathOffset(sel.element, g.initialPathOffset);
1017
+ }
1018
+ })
1019
+ .finally(() => {
1020
+ if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
1021
+ });
1022
+ } else {
1023
+ const finalSize = readStudioBoxSize(sel.element);
1024
+ applyStudioBoxSize(sel.element, finalSize);
1025
+ void Promise.resolve(onBoxSizeCommitRef.current(sel, finalSize))
1026
+ .catch(() => {
1027
+ if (
1028
+ g.manualEditDragToken &&
1029
+ isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
1030
+ ) {
1031
+ restoreStudioBoxSize(sel.element, g.initialBoxSize);
1032
+ }
1033
+ })
1034
+ .finally(() => {
1035
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
1036
+ });
1037
+ }
1038
+ };
1039
+
1040
+ // Click on overlay background → select whatever is under the pointer in the iframe.
1041
+ // This handles clicking children inside an already-selected parent: the selection
1042
+ // box stops propagation for drag gestures, but clicks on the transparent overlay
1043
+ // area outside the box pass through to the iframe pick logic.
1044
+ const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
1045
+ if (suppressNextOverlayMouseDownRef.current) {
1046
+ suppressNextOverlayMouseDownRef.current = false;
1047
+ suppressNextBoxMouseDownRef.current = false;
1048
+ suppressNextBoxClickRef.current = false;
1049
+ event.preventDefault();
1050
+ event.stopPropagation();
1051
+ return;
1052
+ }
1053
+ const target = event.target as HTMLElement | null;
1054
+ if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
1055
+ onCanvasMouseDown(event, { preferClipAncestor: false });
1056
+ if (event.shiftKey) {
1057
+ suppressNextBoxMouseDownRef.current = true;
1058
+ suppressNextBoxClickRef.current = true;
1059
+ }
1060
+ };
1061
+
1062
+ const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
1063
+ if (!allowCanvasMovement || event.button !== 0) return;
1064
+ if (event.shiftKey) {
1065
+ const candidate =
1066
+ onCanvasPointerMoveRef.current(event, {
1067
+ preferClipAncestor: false,
1068
+ }) ?? hoverSelectionRef.current;
1069
+ if (!candidate) return;
1070
+
1071
+ event.preventDefault();
1072
+ event.stopPropagation();
1073
+ suppressNextOverlayMouseDownRef.current = true;
1074
+ suppressNextBoxMouseDownRef.current = true;
1075
+ suppressNextBoxClickRef.current = true;
1076
+ onSelectionChangeRef.current(candidate, { additive: true });
1077
+ return;
1078
+ }
1079
+
1080
+ const target = event.target as HTMLElement | null;
1081
+ if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
1082
+
1083
+ const hoverCandidate = onCanvasPointerMoveRef.current(event, {
1084
+ preferClipAncestor: false,
1085
+ });
1086
+ const candidate = hoverCandidate ?? hoverSelectionRef.current;
1087
+ if (!candidate?.capabilities.canApplyManualOffset) return;
1088
+
1089
+ const overlayEl = overlayRef.current;
1090
+ const iframe = iframeRef.current;
1091
+ const candidateRect =
1092
+ overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null;
1093
+ if (!candidateRect) return;
1094
+
1095
+ suppressNextOverlayMouseDownRef.current = true;
1096
+ selectionRef.current = candidate;
1097
+ overlayRectRef.current = candidateRect;
1098
+ hoverRectRef.current = null;
1099
+ setOverlayRect(candidateRect);
1100
+ setHoverRect(null);
1101
+ const didStartGesture = startGesture("drag", event, {
1102
+ selection: candidate,
1103
+ rect: candidateRect,
1104
+ });
1105
+ if (!didStartGesture) {
1106
+ suppressNextOverlayMouseDownRef.current = false;
1107
+ return;
1108
+ }
1109
+ onSelectionChangeRef.current(candidate);
1110
+ };
1111
+
1112
+ // Click on the selection box itself → re-pick the element under the pointer.
1113
+ // This lets you click a child element even when a parent is selected, because
1114
+ // the click coordinates are forwarded to the iframe's element picker.
1115
+ const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
1116
+ if (gestureRef.current || groupGestureRef.current) return;
1117
+ if (suppressNextBoxClickRef.current) {
1118
+ suppressNextBoxClickRef.current = false;
1119
+ event.stopPropagation();
1120
+ return;
1121
+ }
1122
+ onCanvasMouseDown(event, { preferClipAncestor: false });
1123
+ };
1124
+
1125
+ const clearPointerState = () => {
1126
+ const groupG = groupGestureRef.current;
1127
+ if (groupG) restoreGroupPathOffsets(groupG);
1128
+ const g = gestureRef.current;
1129
+ const sel = g?.selection ?? selectionRef.current;
1130
+ if (g?.mode === "path-offset" && sel) {
1131
+ restoreStudioPathOffset(sel.element, g.initialPathOffset);
1132
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
1133
+ restoreGestureOverlayRect(g);
1134
+ }
1135
+ if (g?.mode === "box-size" && sel) {
1136
+ restoreStudioBoxSize(sel.element, g.initialBoxSize);
1137
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
1138
+ restoreGestureOverlayRect(g);
1139
+ }
1140
+ if (g?.mode === "rotation" && sel) {
1141
+ restoreStudioRotation(sel.element, g.initialRotation);
1142
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
1143
+ }
1144
+ blockedMoveRef.current = null;
1145
+ groupGestureRef.current = null;
1146
+ gestureRef.current = null;
1147
+ rafPausedRef.current = false;
1148
+ };
1149
+
1150
+ return (
1151
+ <div
1152
+ ref={overlayRef}
1153
+ className="absolute inset-0 z-10 pointer-events-auto outline-none"
1154
+ tabIndex={-1}
1155
+ aria-label="Composition canvas"
1156
+ onPointerDownCapture={(event) => focusDomEditOverlayElement(event.currentTarget)}
1157
+ onPointerDown={handleOverlayPointerDown}
1158
+ onMouseDown={handleOverlayMouseDown}
1159
+ onPointerMove={onPointerMove}
1160
+ onPointerLeave={() => onCanvasPointerLeaveRef.current()}
1161
+ onPointerUp={onPointerUp}
1162
+ onPointerCancel={clearPointerState}
1163
+ >
1164
+ {hoverSelection && hoverRect && (
1165
+ <div
1166
+ aria-hidden="true"
1167
+ data-dom-edit-hover-box="true"
1168
+ className="pointer-events-none absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
1169
+ style={{
1170
+ left: hoverRect.left,
1171
+ top: hoverRect.top,
1172
+ width: hoverRect.width,
1173
+ height: hoverRect.height,
1174
+ }}
1175
+ />
1176
+ )}
1177
+ {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
1178
+ <>
1179
+ {groupOverlayItems.map((item) => (
1180
+ <div
1181
+ key={item.key}
1182
+ aria-hidden="true"
1183
+ className="pointer-events-none absolute rounded-xl border border-studio-accent/70 bg-studio-accent/[0.03]"
1184
+ style={{
1185
+ left: item.rect.left,
1186
+ top: item.rect.top,
1187
+ width: item.rect.width,
1188
+ height: item.rect.height,
1189
+ }}
1190
+ />
1191
+ ))}
1192
+ <div
1193
+ data-dom-edit-selection-box="true"
1194
+ className="pointer-events-auto absolute rounded-xl border border-studio-accent bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
1195
+ style={{
1196
+ left: groupBounds.left,
1197
+ top: groupBounds.top,
1198
+ width: groupBounds.width,
1199
+ height: groupBounds.height,
1200
+ cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
1201
+ }}
1202
+ onPointerDown={(e) => {
1203
+ if (!allowCanvasMovement) return;
1204
+ if (e.shiftKey) return;
1205
+ startGroupDrag(e);
1206
+ }}
1207
+ onMouseDown={(e) => {
1208
+ if (!suppressNextBoxMouseDownRef.current) return;
1209
+ suppressNextBoxMouseDownRef.current = false;
1210
+ e.preventDefault();
1211
+ e.stopPropagation();
1212
+ }}
1213
+ onClick={handleBoxClick}
1214
+ />
1215
+ </>
1216
+ )}
1217
+ {!hasGroupSelection && selection && overlayRect && (
1218
+ <>
1219
+ {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
1220
+ <div
1221
+ className="pointer-events-none absolute"
1222
+ style={{
1223
+ left: overlayRect.left + overlayRect.width / 2,
1224
+ top: overlayRect.top - 34,
1225
+ width: 28,
1226
+ height: 34,
1227
+ transform: "translateX(-50%)",
1228
+ }}
1229
+ >
1230
+ <div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
1231
+ <button
1232
+ type="button"
1233
+ className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
1234
+ style={{ cursor: "grab", touchAction: "none" }}
1235
+ title="Rotate"
1236
+ aria-label="Rotate selection"
1237
+ onPointerDown={(e) => {
1238
+ e.stopPropagation();
1239
+ startGesture("rotate", e);
1240
+ }}
1241
+ />
1242
+ </div>
1243
+ )}
1244
+ <div
1245
+ key={selectionKey}
1246
+ ref={boxRef}
1247
+ data-dom-edit-selection-box="true"
1248
+ 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)]"
1249
+ style={{
1250
+ left: overlayRect.left,
1251
+ top: overlayRect.top,
1252
+ width: overlayRect.width,
1253
+ height: overlayRect.height,
1254
+ cursor:
1255
+ allowCanvasMovement && selection.capabilities.canApplyManualOffset
1256
+ ? "move"
1257
+ : "default",
1258
+ }}
1259
+ onPointerDown={(e) => {
1260
+ if (!allowCanvasMovement) return;
1261
+ if (e.shiftKey) return;
1262
+ if (selection.capabilities.canApplyManualOffset) {
1263
+ startGesture("drag", e);
1264
+ return;
1265
+ }
1266
+ e.preventDefault();
1267
+ e.stopPropagation();
1268
+ e.currentTarget.setPointerCapture(e.pointerId);
1269
+ blockedMoveRef.current = {
1270
+ pointerId: e.pointerId,
1271
+ startX: e.clientX,
1272
+ startY: e.clientY,
1273
+ notified: false,
1274
+ };
1275
+ }}
1276
+ onMouseDown={(e) => {
1277
+ if (!suppressNextBoxMouseDownRef.current) return;
1278
+ suppressNextBoxMouseDownRef.current = false;
1279
+ e.preventDefault();
1280
+ e.stopPropagation();
1281
+ }}
1282
+ onClick={handleBoxClick}
1283
+ >
1284
+ {/* Resize handle — bottom-right corner */}
1285
+ {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
1286
+ <div
1287
+ className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
1288
+ style={{ cursor: "se-resize", touchAction: "none" }}
1289
+ onPointerDown={(e) => {
1290
+ e.stopPropagation();
1291
+ startGesture("resize", e);
1292
+ }}
1293
+ />
1294
+ )}
1295
+ </div>
1296
+ </>
1297
+ )}
1298
+ </div>
1299
+ );
1300
+ });