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