@hyperframes/studio 0.6.0-alpha.9 → 0.6.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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.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 +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -1,50 +1,28 @@
1
- import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
3
- import { type DomEditSelection, findElementForSelection } from "./domEditing";
1
+ import { memo, useMemo, useRef, type RefObject } from "react";
2
+ import { type DomEditSelection } from "./domEditing";
3
+ import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
4
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
- }
5
+ type BlockedMoveState,
6
+ type FocusableDomEditOverlay,
7
+ type GestureState,
8
+ type GroupGestureState,
9
+ focusDomEditOverlayElement,
10
+ } from "./domEditOverlayGestures";
11
+ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
12
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
13
+
14
+ // Re-exports for external consumers — preserving existing import paths.
15
+ export {
16
+ filterNestedDomEditGroupItems,
17
+ resolveDomEditCoordinateScale,
18
+ resolveDomEditGroupOverlayRect,
19
+ } from "./domEditOverlayGeometry";
20
+ export {
21
+ focusDomEditOverlayElement,
22
+ hasDomEditRotationChanged,
23
+ resolveDomEditResizeGesture,
24
+ resolveDomEditRotationGesture,
25
+ } from "./domEditOverlayGestures";
48
26
 
49
27
  export interface DomEditGroupPathOffsetCommit {
50
28
  selection: DomEditSelection;
@@ -85,297 +63,6 @@ interface DomEditOverlayProps {
85
63
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
86
64
  }
87
65
 
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
66
  export const DomEditOverlay = memo(function DomEditOverlay({
380
67
  iframeRef,
381
68
  activeCompositionPath,
@@ -396,9 +83,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
396
83
  }: DomEditOverlayProps) {
397
84
  const overlayRef = useRef<HTMLDivElement | null>(null);
398
85
  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
86
  const gestureRef = useRef<GestureState | null>(null);
403
87
  const groupGestureRef = useRef<GroupGestureState | null>(null);
404
88
  const blockedMoveRef = useRef<BlockedMoveState | null>(null);
@@ -406,9 +90,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
406
90
  const suppressNextBoxMouseDownRef = useRef(false);
407
91
  const suppressNextOverlayMouseDownRef = useRef(false);
408
92
  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
93
 
413
94
  const selectionRef = useRef(selection);
414
95
  selectionRef.current = selection;
@@ -418,12 +99,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
418
99
  groupSelectionsRef.current = groupSelections;
419
100
  const hoverSelectionRef = useRef(hoverSelection);
420
101
  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
102
  const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
428
103
  onPathOffsetCommitRef.current = onPathOffsetCommit;
429
104
  const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
@@ -443,159 +118,50 @@ export const DomEditOverlay = memo(function DomEditOverlay({
443
118
  const onSelectionChangeRef = useRef(onSelectionChange);
444
119
  onSelectionChangeRef.current = onSelectionChange;
445
120
 
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
- };
121
+ const {
122
+ overlayRect,
123
+ overlayRectRef,
124
+ setOverlayRect,
125
+ hoverRect,
126
+ groupOverlayItems,
127
+ groupOverlayItemsRef,
128
+ setGroupOverlayItems,
129
+ } = useDomEditOverlayRects({
130
+ iframeRef,
131
+ overlayRef,
132
+ selectionRef,
133
+ activeCompositionPathRef,
134
+ groupSelectionsRef,
135
+ hoverSelectionRef,
136
+ rafPausedRef,
137
+ });
589
138
 
590
- frame = requestAnimationFrame(update);
591
- return () => cancelAnimationFrame(frame);
139
+ const gestures = createDomEditOverlayGestureHandlers({
140
+ overlayRef,
141
+ boxRef,
142
+ selectionRef,
143
+ overlayRectRef,
144
+ groupOverlayItemsRef,
145
+ gestureRef,
146
+ groupGestureRef,
147
+ blockedMoveRef,
148
+ rafPausedRef,
149
+ suppressNextBoxClickRef,
150
+ setOverlayRect,
151
+ setGroupOverlayItems,
152
+ onBlockedMoveRef,
153
+ onManualDragStartRef,
154
+ onPathOffsetCommitRef,
155
+ onGroupPathOffsetCommitRef,
156
+ onBoxSizeCommitRef,
157
+ onRotationCommitRef,
158
+ onCanvasPointerMoveRef,
159
+ onCanvasMouseDown,
592
160
  });
593
161
 
594
162
  const selectionKey = useMemo(() => {
595
163
  if (!selection) return "none";
596
- return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${
597
- selection.selectorIndex ?? 0
598
- }`;
164
+ return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
599
165
  }, [selection]);
600
166
  const groupBounds = useMemo(
601
167
  () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
@@ -607,440 +173,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
607
173
  groupOverlayItems.length > 1 &&
608
174
  groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
609
175
 
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
176
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
1045
177
  if (suppressNextOverlayMouseDownRef.current) {
1046
178
  suppressNextOverlayMouseDownRef.current = false;
@@ -1063,11 +195,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1063
195
  if (!allowCanvasMovement || event.button !== 0) return;
1064
196
  if (event.shiftKey) {
1065
197
  const candidate =
1066
- onCanvasPointerMoveRef.current(event, {
1067
- preferClipAncestor: false,
1068
- }) ?? hoverSelectionRef.current;
198
+ onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ??
199
+ hoverSelectionRef.current;
1069
200
  if (!candidate) return;
1070
-
1071
201
  event.preventDefault();
1072
202
  event.stopPropagation();
1073
203
  suppressNextOverlayMouseDownRef.current = true;
@@ -1080,10 +210,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1080
210
  const target = event.target as HTMLElement | null;
1081
211
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
1082
212
 
1083
- const hoverCandidate = onCanvasPointerMoveRef.current(event, {
1084
- preferClipAncestor: false,
1085
- });
1086
- const candidate = hoverCandidate ?? hoverSelectionRef.current;
213
+ const candidate =
214
+ onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ??
215
+ hoverSelectionRef.current;
1087
216
  if (!candidate?.capabilities.canApplyManualOffset) return;
1088
217
 
1089
218
  const overlayEl = overlayRef.current;
@@ -1095,10 +224,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1095
224
  suppressNextOverlayMouseDownRef.current = true;
1096
225
  selectionRef.current = candidate;
1097
226
  overlayRectRef.current = candidateRect;
1098
- hoverRectRef.current = null;
1099
227
  setOverlayRect(candidateRect);
1100
- setHoverRect(null);
1101
- const didStartGesture = startGesture("drag", event, {
228
+ const didStartGesture = gestures.startGesture("drag", event, {
1102
229
  selection: candidate,
1103
230
  rect: candidateRect,
1104
231
  });
@@ -1109,9 +236,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1109
236
  onSelectionChangeRef.current(candidate);
1110
237
  };
1111
238
 
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
239
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
1116
240
  if (gestureRef.current || groupGestureRef.current) return;
1117
241
  if (suppressNextBoxClickRef.current) {
@@ -1122,29 +246,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1122
246
  onCanvasMouseDown(event, { preferClipAncestor: false });
1123
247
  };
1124
248
 
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;
249
+ const suppressBoxMouseDown = (e: React.MouseEvent) => {
250
+ if (!suppressNextBoxMouseDownRef.current) return;
251
+ suppressNextBoxMouseDownRef.current = false;
252
+ e.preventDefault();
253
+ e.stopPropagation();
1148
254
  };
1149
255
 
1150
256
  return (
@@ -1153,13 +259,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1153
259
  className="absolute inset-0 z-10 pointer-events-auto outline-none"
1154
260
  tabIndex={-1}
1155
261
  aria-label="Composition canvas"
1156
- onPointerDownCapture={(event) => focusDomEditOverlayElement(event.currentTarget)}
262
+ onPointerDownCapture={(event) =>
263
+ focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
264
+ }
1157
265
  onPointerDown={handleOverlayPointerDown}
1158
266
  onMouseDown={handleOverlayMouseDown}
1159
- onPointerMove={onPointerMove}
267
+ onPointerMove={gestures.onPointerMove}
1160
268
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
1161
- onPointerUp={onPointerUp}
1162
- onPointerCancel={clearPointerState}
269
+ onPointerUp={gestures.onPointerUp}
270
+ onPointerCancel={() => gestures.clearPointerState(selectionRef)}
1163
271
  >
1164
272
  {hoverSelection && hoverRect && (
1165
273
  <div
@@ -1200,16 +308,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1200
308
  cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
1201
309
  }}
1202
310
  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();
311
+ if (!allowCanvasMovement || e.shiftKey) return;
312
+ gestures.startGroupDrag(e);
1212
313
  }}
314
+ onMouseDown={suppressBoxMouseDown}
1213
315
  onClick={handleBoxClick}
1214
316
  />
1215
317
  </>
@@ -1236,7 +338,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1236
338
  aria-label="Rotate selection"
1237
339
  onPointerDown={(e) => {
1238
340
  e.stopPropagation();
1239
- startGesture("rotate", e);
341
+ gestures.startGesture("rotate", e);
1240
342
  }}
1241
343
  />
1242
344
  </div>
@@ -1257,10 +359,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1257
359
  : "default",
1258
360
  }}
1259
361
  onPointerDown={(e) => {
1260
- if (!allowCanvasMovement) return;
1261
- if (e.shiftKey) return;
362
+ if (!allowCanvasMovement || e.shiftKey) return;
1262
363
  if (selection.capabilities.canApplyManualOffset) {
1263
- startGesture("drag", e);
364
+ gestures.startGesture("drag", e);
1264
365
  return;
1265
366
  }
1266
367
  e.preventDefault();
@@ -1273,22 +374,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
1273
374
  notified: false,
1274
375
  };
1275
376
  }}
1276
- onMouseDown={(e) => {
1277
- if (!suppressNextBoxMouseDownRef.current) return;
1278
- suppressNextBoxMouseDownRef.current = false;
1279
- e.preventDefault();
1280
- e.stopPropagation();
1281
- }}
377
+ onMouseDown={suppressBoxMouseDown}
1282
378
  onClick={handleBoxClick}
1283
379
  >
1284
- {/* Resize handle — bottom-right corner */}
1285
380
  {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
1286
381
  <div
1287
382
  className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
1288
383
  style={{ cursor: "se-resize", touchAction: "none" }}
1289
384
  onPointerDown={(e) => {
1290
385
  e.stopPropagation();
1291
- startGesture("resize", e);
386
+ gestures.startGesture("resize", e);
1292
387
  }}
1293
388
  />
1294
389
  )}